Menu

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

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.

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 — 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.