# Receive Support events

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`](/reference/mcp-tools/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`](/reference/mcp-tools/support_webhook_get) and [`support_webhook_list`](/reference/mcp-tools/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`](/reference/mcp-tools/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`](/reference/mcp-tools/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`](/reference/mcp-tools/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](#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.

```json
{
  "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`](/reference/mcp-tools/support_message_list) for a conversation, the same call your support screen already makes, and [`support_response_get`](/reference/mcp-tools/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](/ship/support/threads-and-messages#catching-up-across-the-whole-product); 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

```js

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:

```json
{"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:

```text
6ea5f20568189c1f13eb1248fe45fbe191b5b6992e11b527b7e230f40a54c67c
```

That 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`](/reference/mcp-tools/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`](/reference/mcp-tools/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](/ship/support/threads-and-messages): reading back what a message actually said.
- [Statuses](/ship/support/statuses): what a status and its category mean.
- [Agents](/ship/support/agents): the agent named in a payload.
- [Tools, SDK and errors](/ship/support/tools-and-errors): the tools, their REST routes and their errors.
