Webhooks
Register signed, real-time event endpoints and verify their signatures.
/api/v1/webhookssecret keyWebhooks
A webhook turns the flow around: instead of your code repeatedly asking “anything new?”, Sentriment calls you the moment something happens. You give us a URL; we POST a signed JSON event to it. What teams build with the events:
- Alert the room — on
cluster.regressed, post to Slack or page on-call: an issue you marked fixed is being reported again. This is the event most customers wire first. - File the work — on
feedback.processedwheretypeisbug, open a Linear/Jira ticket with the redacted quote and sentiment already attached; routefeature_requestitems to your roadmap tool instead. - Close the loop with users — strongly negative items (check
sentimentScore) can trigger a personal follow-up from support before the user churns; praise can trigger a review-request email while goodwill is high. - Watch a decision — on
question.verdict_changed, get told the day “what do users think of our pricing?” flips from mixed to negative after a pricing change — instead of discovering it in a quarterly review. - Keep systems in sync — stream every processed item into your data warehouse or CRM so feedback sits next to revenue and usage data.
Mechanics: register up to 10 HTTPS endpoints per project. Every delivery is HMAC-signed (verify it — see below), retried 5× with increasing delays if your server is down, and an endpoint that keeps failing for 15 minutes straight is switched off automatically so a dead URL can't pile up retries — a burst of failures alone won't do it, so an endpoint that is rate-limiting us rather than broken stays enabled. GET /webhooks lists your endpoints with delivery health, including failingSince; PATCH /webhooks/{id} pauses or resumes one; and DELETE /webhooks/{id} removes it for good.
| Parameter | In | Description |
|---|---|---|
feedback.processed | event | An item finished AI analysis — the payload carries the full analysis. |
cluster.spiking | event | A theme crossed its spike threshold: 4+ reports in 24h at 3× the prior week's daily rate. |
cluster.regressed | event | A theme you marked resolved received new feedback. Your 'it's back' alarm. |
cluster.resolved | event | You marked a theme resolved — payload includes the requester user ids, so you can tell them it shipped. |
user.health_changed | event | An identified user just entered the at-risk band (health score below 40). Fires on the way in, not on recovery. |
question.verdict_changed | event | A tracked Ask question's verdict flipped (e.g. mixed → negative). |
curl -X POST https://app.sentriment.com/api/v1/webhooks \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "url": "https://api.example.com/sentriment", "events": ["cluster.regressed"] }'
# → { "id": "01KX…", "secret": "whsec_…", … } ← store the secret; shown oncecurl -X PATCH https://app.sentriment.com/api/v1/webhooks/01KX… \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "active": false }'
# Resuming also clears the consecutive-failure count, which is how you
# revive an endpoint that was auto-disabled after an outage.
curl -X PATCH https://app.sentriment.com/api/v1/webhooks/01KX… \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "active": true }'{
"id": "evt_…",
"event": "cluster.regressed",
"occurredAt": "2026-07-12T14:07:44.000Z",
"data": { "clusterId": "01KX…", "label": "Search · Slow query response", "triggeredByFeedbackId": "01KX…" }
}Verify signatures
Anyone who discovers your webhook URL could send it fake events — the signature is how you know a delivery genuinely came from Sentriment and wasn't tampered with in transit. Verification is ~10 lines and should reject anything that fails. Every delivery includes X-Sentriment-Signature: t=<unix>,v1=<hex> where v1 = HMAC-SHA256(secret, `${t}.${rawBody}`). Verify against the raw request body and reject timestamps older than 5 minutes:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const { t, v1 } = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2)),
);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // stale
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}