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#

EventFired when
message.receivedMail arrives and passes checks. Default.
message.sentThe provider accepted an outbound message. Default.
message.deliveredThe recipient's server accepted it. Default.
message.bouncedIt bounced; the address is suppressed. Default.
message.complainedThe recipient marked it as spam. Default.
message.rejectedThe send was refused. Default.
message.openedThe first open of a message sent with track_opens. Default. See open tracking.
domain.verifiedA custom domain passed verification. Default.
message.received.spam, message.received.blocked, message.received.unauthenticatedOpt-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.