Webhooks
Create a webhook#
curl -X POST "$BASE_URL/v0/webhooks" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/mail", "event_types": ["message.received", "message.bounced"]}'
The response includes a secret beginning whsec_. Store it. Scope a webhook with inbox_ids or pod_ids (up to 10 each), or create it under /v0/pods/:pod_id/webhooks or /v0/inboxes/:inbox_id/webhooks. Add custom headers with headers; their values are write-only and GET .../headers returns only the names. Your plan limits how many webhooks you can have (402 plan_limit_exceeded). Webhook URLs must be public https addresses (400 invalid_webhook_url). The dashboard creates, enables, disables and deletes webhooks and shows recent deliveries; scopes and custom headers are set through the API.
Events#
| Event | Fired when |
|---|---|
message.received | Mail arrives and passes checks. Default. |
message.sent | The provider accepted an outbound message. Default. |
message.delivered | The recipient's server accepted it. Default. |
message.bounced | It bounced; the address is suppressed. Default. |
message.complained | The recipient marked it as spam. Default. |
message.rejected | The send was refused. Default. |
message.opened | The first open of a message sent with track_opens. Default. See open tracking. |
domain.verified | A custom domain passed verification. Default. |
message.received.spam, message.received.blocked, message.received.unauthenticated | Opt-in. Need the matching label_*_read permission on scoped keys. |
message.opened fires once per message, on the first recorded open. The payload has message (without bodies), thread and open (inbox_id, thread_id, message_id, timestamp, open_count, via_proxy). It is signed and filtered by event type like every other event.
A delivery is JSON: type: "event", event_type, event_id, plus a payload. For message.received that is message (the same object as GET message) and thread. A message.received payload over about 1 MB is sent without the body fields (text, html, extracted_*); fetch the message by id.
{
"type": "event",
"event_type": "message.received",
"event_id": "evt_...",
"message": { "message_id": "msg_...", "inbox_id": "inbox_...", "from": "Ann <ann@example.com>", "subject": "Re: Hello", "extracted_text": "Sounds good." },
"thread": { "thread_id": "thr_...", "message_count": 2 }
}
Verify the signature#
Deliveries are signed with a widely used webhook scheme, so many existing verification libraries already work. Headers: svix-id, svix-timestamp (seconds), svix-signature (v1,<base64>). The signature is HMAC-SHA256 over {svix-id}.{svix-timestamp}.{raw body}, keyed with the base64-decoded part of the secret after whsec_. Verify against the raw bytes, not re-serialised JSON, and reject timestamps more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, h: Record<string, string>, secret: string): boolean {
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const age = Math.abs(Date.now() / 1000 - Number(h["svix-timestamp"]));
if (!(age <= 300)) return false;
const expected = createHmac("sha256", key).update(`${h["svix-id"]}.${h["svix-timestamp"]}.${rawBody}`).digest();
return h["svix-signature"].split(" ").some((s) => {
const [v, sig] = s.split(",");
const got = Buffer.from(sig ?? "", "base64");
return v === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
});
}
The TypeScript SDK ships the same check as verifyWebhook(rawBody, headers, secret), which also parses the event, and the Python SDK ships verify_webhook.
A valid signature can be replayed inside the five-minute window, so dedupe on svix-id (retries reuse it) and keep seen ids for at least that long:
const event = verifyWebhook(rawBody, req.headers, secret);
const id = String(req.headers["svix-id"]);
if (!(await redis.set(`wh:${id}`, "1", { NX: true, EX: 600 }))) return res.status(200).end(); // already handled
await handle(event);
Retries#
Any 2xx response within 10 seconds counts as delivered. Otherwise the delivery is retried with growing delays after each of the first seven failures: 5 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, then 2 hours. The eighth failed attempt marks the delivery failed and it is not retried. Use svix-id to deduplicate: retries reuse it. A disabled webhook (enabled: false) is not called.
GET /v0/events (needs message_read) lists recent events if you need to catch up after downtime, and GET /v0/webhooks/:webhook_id/deliveries shows each delivery attempt's status.