# Webhooks

Event catalogue, signature verification, delivery and retry behaviour

Delivery, engagement and lifecycle events are pushed to an HTTPS endpoint you control.
This is how you learn that a campaign finished or a message bounced — do not poll for it.

Manage subscriptions from the dashboard or over `/v1/webhooks` with the `webhooks:write`
scope: subscribe a URL (one subscription per URL), list them, pause or edit one, send it
a test event (`webhook.test`), rotate its secret, and list or resend deliveries.

## Retries

A delivery that does not get a 2xx is retried after 1 minute, 5 minutes, 30 minutes,
2 hours, 6 hours, 12 hours and 24 hours, about 45 hours in all. If the last retry fails
and the endpoint accepted nothing else in that time, the webhook is **disabled** and the
workspace's admins are emailed. Events keep queueing for a paused or disabled webhook
and are sent once it is turned back on.

An endpoint that fails five requests in a row is held: nothing is sent to it for
5 minutes, doubling with each further failure up to an hour, and the next request after
that is a probe. One success releases it. While held, deliveries wait without using up
their retries, and the webhook's `circuitOpenUntil` says until when. Changing the URL or
turning the webhook back on releases it at once.

## Events

| Event | Fires when |
| --- | --- |
| `contact.created` | A contact is created, by any route |
| `contact.unsubscribed` | A recipient opted out through an email's link; carries the `messageId` and `campaignId` when the link names them, and `topicId` when they left only that topic (their email consent is then unchanged) |
| `contact.consent_changed` | Consent on any channel was granted or withdrawn, with its `source` |
| `form.submitted` | A hosted form is submitted |
| `campaign.sent` | A campaign finishes sending |
| `journey.enrolled` | A contact enters a journey |
| `message.sent` | The message was handed to the email provider or push service, for every kind of message (`messageType` says which); `providerMessageId` is the email provider's id, null for push |
| `message.delivered` | The provider confirms delivery |
| `message.bounced` | Delivery failed permanently (a hard bounce), with the provider's `reason` such as `no_email` or `mailbox_full`. A soft bounce is `message.failed` with `failureReason` `transient_bounce` |
| `message.complained` | The recipient marked it as spam |
| `message.opened` | The recipient opened it |
| `message.clicked` | The recipient clicked; `url` is the email link followed, null for a push tap |
| `message.failed` | Sending broke and will not be retried: the provider rejected it, or its outcome is unknown; why is in `failureReason` |
| `journey.action` | A journey reached a webhook action that names this endpoint; sent only there |
| `message.skipped` | A guard stopped it before sending (no consent, a suppressed address, no device, a test address) or it was cancelled; why is in `failureReason` |

Every `message.*` event carries the same fields about its message: `messageId`,
`contactId`, `channel`, `messageType`, and `correlationId` — the reference you sent with a
transactional message, null when there is none — so you can match an outcome to your own
record without storing our message id or reading the message back. `failureReason` is the
same value the message's read returns.

Every event's payload is in the API reference, and `GET /v1/webhooks/events` (any valid key) lists each event with
what fires it and an example delivery body. Subscribe and unsubscribe with
`POST /v1/webhooks` and `DELETE /v1/webhooks/{id}` (scope `webhooks:write`).

A subscription may name specific events or subscribe to **`*`**, which includes events
added later. That is deliberate and safe in a way a wildcard API-key scope is not: a new
event type means more traffic, never more authority.

## The request

```http
POST /your-endpoint
Content-Type: application/json
X-Webhook-Event: message.bounced
X-Webhook-Event-Id: 018f3a…
X-Webhook-Attempt: 1
X-Webhook-Timestamp: 1790418662
X-Webhook-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b…
```

`X-Webhook-Event-Id` is stable across retries of the same event — use it to deduplicate.

## Verifying the signature

**Every SDK must ship a verify helper, and all of them must implement this identically.**
An unverified webhook endpoint accepts anything anyone posts to it.

The signature is `sha256=` followed by the hex HMAC-SHA256 of
**`${X-Webhook-Timestamp}.${raw request body}`**, keyed with the subscription's signing
secret. The timestamp is Unix seconds. Signing it with the body is what stops replays:
a captured request cannot be re-sent later with a fresh timestamp, and a receiver
refuses any timestamp more than five minutes from its clock.

While a rotated secret overlaps with the one it replaces, the header carries both
signatures, comma-separated. Accept the request if any of them verifies.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhookSignature(
  secret: string,
  rawBody: string,
  signatureHeader: string,
  timestamp: string,
  toleranceSeconds = 300,
): boolean {
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const expected = Buffer.from(
    `sha256=${createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')}`,
  );
  return signatureHeader.split(',').some((signature) => {
    const received = Buffer.from(signature.trim());
    return received.length === expected.length && timingSafeEqual(expected, received);
  });
}
```

Three details decide whether this is real protection or decoration:

- **Sign the raw body, exactly as received.** Parsing to JSON and re-serializing changes
  key order and whitespace and the HMAC will not match. Most frameworks parse the body
  before your handler sees it; configure a raw-body capture on the webhook route.
- **Compare in constant time.** `===` leaks how much of the signature matched, one byte at
  a time. Use `timingSafeEqual` or the equivalent, and compare lengths first — it throws on
  mismatched lengths.
- **Check the timestamp.** The signature proves who sent the request; the tolerance
  window proves it was sent just now. Skip it and a leaked request replays forever.

Reject anything that fails verification with 401 and do not process it.

## Delivery and retries

- Respond **2xx** to acknowledge. Any other status, or a timeout, counts as a failure.
- The request times out after **10 seconds**. Acknowledge first, process afterwards —
  doing real work inside the handler is the usual cause of retry storms.
- Failed deliveries are retried on the schedule under [Retries](#retries) — eight
  attempts in all. `X-Webhook-Attempt` tells you which attempt you are seeing.
- Redirects are not followed. Give the exact final URL.

## Writing a handler

Delivery is at-least-once, so **your handler must be idempotent**. Deduplicate on
`X-Webhook-Event-Id`, and do not assume ordering — `message.delivered` and
`message.opened` for the same message can arrive out of order.

The shape of a correct handler:

1. Capture the raw body.
2. Verify the signature. Reject with 401 if it fails.
3. Check whether you have already processed this `X-Webhook-Event-Id`. If so, return 200.
4. Enqueue the work.
5. Return 200.