Receive Support events
Register a signed endpoint, verify what arrives, and read the delivery log.
Register a webhook to receive Support events on your backend. Choose the events and an HTTPS endpoint; Support sends a signed JSON body to that endpoint when an event is recorded.
Everything below is an operation on the management plane — the support_* tools and the REST routes behind them. The identity that registers an endpoint is the same identity that opens threads: your backend, or a specialist in the Workspace app.
Register an endpoint
An endpoint needs a URL and at least one event type. The URL must be https:// — plain http:// is refused, because a signed payload sent in clear is a signed payload anyone can read. An empty event list is also rejected. A description of up to 1000 characters is optional and is a note to yourself.
support_webhook_create registers it and returns the endpoint together with its signing secret in clear — exactly once. The secret starts with whsec_. Store it at that moment: support_webhook_get and support_webhook_list never return it again, and there is no operation that reads it back. What they do return is secretPrefix, the label plus six characters, which is enough to tell two endpoints apart in a list and worth nothing to anyone who sees it.
If you lose the secret, support_webhook_rotate_secret mints a new one and shows it once, on the same terms. Rotation is immediate and has no grace window: the previous secret stops verifying the moment the call returns, so deploy the new one before you rotate, or accept a gap in which your handler rejects everything.
support_webhook_update edits the registration — url, eventTypes, description, and status, which is active or disabled. An absent field is left alone; an explicit null description clears it. The secret is deliberately not editable here.
A product may register as many endpoints as it needs. Each event is delivered independently to every active endpoint subscribed to it, each signed with its own secret.
Disabling stops delivery; deleting destroys the history
support_webhook_delete removes the endpoint and its whole delivery history with it. Those records are gone; nothing recovers them.
The reversible way to stop delivery is support_webhook_update with status: "disabled". A disabled endpoint receives nothing and keeps its registration, its secret and every delivery record, and re-enabling it is one call. Reach for delete only when the integration is over for good.
The five events
| Event | When it fires |
|---|---|
support.message.created | A support agent posted a public message |
support.thread.status-changed | A thread moved from one status to another |
support.thread.assigned | A thread’s assignee was set, changed or cleared |
support.survey.published | A survey opened for answers, freezing its structure |
support.response.created | Somebody answered a survey |
The list is closed. Subscribing to a name that is not in it is refused rather than silently accepted, because an endpoint subscribed to a typo would hear nothing and report no error.
A thread being opened is not delivered. There is no thread.created event; your backend opened that thread itself, so it already knows.
support.response.created does travel, and the asymmetry with that rule is deliberate rather than an oversight. A thread is opened by your backend, which therefore already knows about it. A survey response arrives through your frontend — the respondent submits it to the public edge — so your backend has seen nothing of it, and this event is the only way it finds out without polling.
Your users’ messages are not echoed back to you
support.message.created fires only when the message is public and an agent wrote it. Both halves are enforced in the code that builds the payload, and neither is a setting: there is no flag on an endpoint, and no argument to any tool, that turns either off.
So an internal note between your specialists stays inside Support, and a message your own end user wrote is never sent to you — it originated on your side, and Support does not hand your own traffic back to you as an event.
What a payload carries
Every body carries the same four fields, whatever it is about:
| Field | Meaning |
|---|---|
event | The event type, exactly as subscribed |
idempotencyKey | The key to deduplicate on — see Idempotency |
occurredAt | ISO-8601 time the event happened |
productId | Your product |
The three thread-scoped events — support.message.created, support.thread.status-changed and support.thread.assigned — add two more:
| Field | Meaning |
|---|---|
threadId | The thread this is about |
externalUserId | The opaque end-user id the thread was opened on behalf of |
The two survey events carry neither. A response has a respondent but no thread, and a published survey has neither, so a consumer must not read threadId off every body it receives — branch on event first.
support.message.created adds:
| Field | Meaning |
|---|---|
messageId | The message that was posted |
authorAgentId | The agent who wrote it — never null on this event |
createdAt | ISO-8601 time the message was created |
support.thread.status-changed adds both ends of the transition:
| Field | Meaning |
|---|---|
status | The status name the thread moved to |
statusCategory | That status’s category |
previousStatus | The status name it moved from |
previousStatusCategory | That status’s category |
support.thread.assigned adds, either of which may be null — a null assigneeAgentId is an unassignment:
| Field | Meaning |
|---|---|
assigneeAgentId | The agent the thread is now assigned to, or null |
previousAssigneeAgentId | Who it was assigned to before, or null |
support.survey.published adds:
| Field | Meaning |
|---|---|
surveyId | The survey that opened for answers |
name | Its name |
questionCount | How many questions it froze with |
support.response.created adds:
| Field | Meaning |
|---|---|
surveyId | The survey that was answered |
responseId | The response, to read back with support_response_get |
externalUserId | The opaque respondent. There is no thread here to name |
score | The first nps or rating answer, or null |
completed | Whether the respondent reached the end |
createdAt | ISO-8601 time the response was recorded |
score travels because it is the one number a receiver routes on, and null travels as null: nobody scored it, which is not a score of zero.
{
"event": "support.message.created",
"idempotencyKey": "message-created:msg-1:2026-08-19T10:00:00.000Z",
"occurredAt": "2026-08-19T10:00:00.000Z",
"productId": "prod-1",
"threadId": "thr-1",
"externalUserId": "ext-9",
"messageId": "msg-1",
"authorAgentId": "agt-1",
"createdAt": "2026-08-19T10:00:00.000Z"
}No message text, and no answers, ever
The payload carries identifiers and metadata and nothing else. There is no subject, no body, no attachment content, no survey answers and no question set, and no setting that adds one.
The consequence is honest and worth planning for: a webhook is a signal, not the content. When you need what was said, read it back under your own authorization — support_message_list for a conversation, the same call your support screen already makes, and support_response_get for the answers behind a support.response.created. If your consumer was down and you are catching up, use the product-wide form of that tool with since and its cursor, documented in Threads and messages; the delivery log below tells you what you missed, and that read tells you what it said.
Verify the signature
Every delivery carries four headers:
| Header | Contents |
|---|---|
X-Support-Signature | t=<unix seconds>,v1=<hex> |
X-Support-Event | The event type |
X-Support-Idempotency-Key | The deduplication key, same as in the body |
X-Support-Delivery-Id | The delivery this attempt belongs to |
The signed string is "<t>.<raw body>" — the timestamp, a dot, and the body exactly as the bytes arrived, before any JSON parsing. Parsing and re-serialising changes the bytes and the signature will not match. Because the timestamp is inside the MAC, a captured request cannot be replayed under a fresh one.
The HMAC key is sha256(secret), not the secret
This is the one place Support differs from what you have implemented elsewhere. Stripe and Svix key the HMAC with the secret itself. Support does not:
The HMAC key is the SHA-256 of your secret, in lowercase hex, used as a string.
The reason is that Support has no raw secret to sign with. The registry stores sha256(secret) in hex and a six-character prefix, and hands the clear value back exactly once — that is what makes “shown once” a property of the storage rather than a promise. The stored hash is therefore what signs, and one line on your side derives the same key.
Deriving it is createHash('sha256').update(secret).digest('hex') — the hex string, not the bytes it spells. Signing with the raw whsec_… value produces a MAC that never matches, and it is the mistake to check for first when nothing verifies.
A verifier you can run
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verify(secret, headers, rawBody) {
const header = headers['x-support-signature'];
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((piece) => piece.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
const received = parts.v1;
if (!Number.isFinite(timestamp) || typeof received !== 'string') return false;
// An old signature is a valid signature. Refuse anything far from your clock.
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
// The key is the hex digest of the secret, as a string — not the secret.
const key = createHash('sha256').update(secret).digest('hex');
const expected = createHmac('sha256', key).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(received, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}Pass rawBody as the string or buffer your framework received. In Express that means mounting express.raw({ type: 'application/json' }) on the webhook route and parsing afterwards; anything that hands you an already-parsed object has thrown away the bytes you need.
You can check your implementation against these fixed values before a single event has been delivered. They are the vector Support’s own test suite is frozen on:
| Input | Value |
|---|---|
| Secret | whsec_ZmFrZS1zZWNyZXQtZm9yLXRoZS10ZXN0LXZlY3Rvcg |
| Derived key | 7194826169f98ee244a1bb51599eafb842990c84a68ec8d772872aaf7fc524de |
t | 1755600000 |
with the body, on one line and with no spaces between its members:
{"event":"support.thread.assigned","idempotencyKey":"thread-assigned:thr-1:2026-08-19T10:00:00.000Z","occurredAt":"2026-08-19T10:00:00.000Z","productId":"prod-1","threadId":"thr-1","externalUserId":"ext-9","assigneeAgentId":"agt-1","previousAssigneeAgentId":null}signing to:
6ea5f20568189c1f13eb1248fe45fbe191b5b6992e11b527b7e230f40a54c67cThat timestamp is long past, so the freshness check above rejects it as a delivery; compare the MAC itself when you test against the vector.
Reject anything that does not verify, and answer nothing else with a 2xx.
Retries and failure
A delivery succeeds when your endpoint answers 2xx. Anything else — another status, a connection error, or no answer within ten seconds — is a failed attempt.
A delivery gets five attempts in total, with waits of 30 seconds, 2 minutes, 10 minutes and 1 hour between them. Redirects are not followed: the signature is for the URL you registered.
A 4xx other than 408 and 429 ends it early. That status is your endpoint saying the request itself is wrong, and the identical request an hour later is still wrong, so it is not retried. 408 and 429 mean “not now” rather than “not ever” and get the full schedule, as does every 5xx and every transport failure.
After the last attempt the delivery is recorded failed — and your endpoint stays active. Support never disables an endpoint for you, however long it has been down: stopping deliveries you did not ask to stop is your decision, not ours.
Answer quickly. The ten seconds cover your whole response, so acknowledge first and do the work afterwards; a handler that processes the event before replying gets retried while it is still working.
Idempotency
Deliveries are at-least-once, and the same event can reach you twice — a retry after your endpoint answered slowly is the ordinary case.
X-Support-Idempotency-Key, also present in the body as idempotencyKey, is stable for a given event. A manual redelivery repeats the same key on purpose, so that a receiver which already processed the original recognises the repeat and ignores it. Key your handler on that value and make it safe to run twice.
Catch up on what was delivered
support_webhook_deliveries_list reads the delivery log, newest first, filtered by endpoint, by state, by event type, and by the time the delivery was opened. Page size is 1 to 200, 50 by default. Each row carries:
| Field | Meaning |
|---|---|
id | The delivery id — the value sent as X-Support-Delivery-Id |
webhookId, eventType | The endpoint and the event |
idempotencyKey | The key the receiver deduplicates on |
payload | The exact JSON body that was sent |
attempt | How many attempts have been made, 0 before the first |
state | pending while attempts remain, then delivered or failed |
responseStatus | The HTTP status of the last attempt, or null if it never got an answer |
error | Why the last attempt failed, or null |
deliveredAt, nextRetryAt, createdAt | When it landed, when the next attempt is due, when it was opened |
This is where to look when events are not arriving. A failed row with a responseStatus of 401 says your handler rejected the signature — start with the derived key. A row with no responseStatus at all says the request never got an answer.
support_webhook_redeliver sends one of those deliveries again. It opens a fresh delivery with a fresh attempt chain, keeps the original payload and the original idempotencyKey, and leaves the original row untouched as history. The endpoint is resolved again at delivery time, so a replay cannot reach an endpoint that has since been disabled, unsubscribed from that event, or deleted — in that case nothing is sent.
Where to go next
- Threads and messages: reading back what a message actually said.
- Statuses: what a status and its category mean.
- Agents: the agent named in a payload.
- Tools, SDK and errors: the tools, their REST routes and their errors.