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. UsetimingSafeEqualor 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-Attempttells 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:
- Capture the raw body.
- Verify the signature. Reject with 401 if it fails.
- Check whether you have already processed this
X-Webhook-Event-Id. If so, return 200. - Enqueue the work.
- Return 200.