Webhooks
Receive signed event notifications at your own endpoint.
Webhooks let your application react to events as they happen — update a record on delivery, or sync a contact. Register an endpoint under Developers → Webhooks (or via the API), choose events, and we POST a signed JSON payload to your URL.
Endpoint URLs must be public HTTPS
We only deliver to https:// URLs that resolve to a public address. http:// endpoints and URLs that resolve to private, loopback, or link-local ranges (localhost, 127.0.0.1, 10.0.0.0/8, 169.254.0.0/16, etc.) are rejected when you save the endpoint — a deliberate SSRF guard. Develop against a tunnel (ngrok, Cloudflare Tunnel) that gives you a public HTTPS URL.
Event types
Events are namespaced by category as category.event. Subscribe to individual events, or to a whole category with a wildcard like email.* — handy when you want everything in a category without re-editing the endpoint as we add events.
- email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked, email.failed, email.scheduled, email.suppressed
- domain.created, domain.updated, domain.deleted
- contact.created, contact.updated, contact.deleted
- segment.joined, segment.left
- topic.subscribed, topic.unsubscribed
Payload
Every delivery is the same JSON envelope: a type (the dotted event name), an ISO created_at timestamp, and a data object. The envelope and signing are identical for every event — only data varies by event. We send three headers you use to verify it (the scheme is compatible with Svix's, so existing Svix libraries work): svix-id, svix-timestamp, and svix-signature.
POST /your-endpoint HTTP/1.1
svix-id: msg_2abc...
svix-timestamp: 1735689600
svix-signature: v1,g0hM9SsE...
content-type: application/json
{
"type": "email.delivered",
"created_at": "2026-01-01T00:00:00.000Z",
"data": { "email_id": "email_2Wxyz123Example", "to": "delivered@example.com" }
}Field conventions
Field names are snake_case. Email events identify the message as email_id; domain and contact events use the resource's own id. The type is only on the envelope — it is never duplicated inside data. Optional fields are always present (as null) rather than omitted, so you can rely on a stable shape.
Email event data
email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked and email.failed share the same shape: the message id and its recipient(s). email.scheduled and email.suppressed add a few fields (below).
// email.sent | email.delivered | email.bounced | email.opened | ...
{
"type": "email.delivered",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"email_id": "email_2Wxyz123Example",
"to": "delivered@example.com"
}
}
// email.scheduled — adds the deferred-send context
{
"type": "email.scheduled",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"email_id": "email_2Wxyz123Example",
"to": "marie@example.com",
"from": "hello@example.com",
"subject": "Your weekly digest",
"scheduled_at": "2026-01-01T09:00:00.000Z",
"batch_id": null
}
}
// email.suppressed — who was skipped and why
{
"type": "email.suppressed",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"email_id": "email_2Wxyz123Example",
"to": "bounced@example.com",
"suppressed": ["bounced@example.com"],
"reasons": [{ "email": "bounced@example.com", "reason": "BOUNCE" }],
"full": true
}
}Domain event data
domain.created, domain.updated and domain.deleted carry the domain id, name and status. previous_status is populated only on domain.updated (a status transition, e.g. PENDING → VERIFIED); it's null otherwise.
{
"type": "domain.updated",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"id": "dom_2Wxyz123Example",
"name": "example.com",
"status": "VERIFIED",
"previous_status": "PENDING"
}
}Contact event data
contact.created, contact.updated and contact.deleted carry the contact id, email, name fields (null when unset) and the marketing unsubscribed flag.
{
"type": "contact.updated",
"created_at": "2026-01-01T00:00:00.000Z",
"data": {
"id": "con_2Wxyz123Example",
"email": "marie@example.com",
"first_name": "Marie",
"last_name": "Curie",
"unsubscribed": false
}
}Verifying signatures
Every webhook is signed with a per-endpoint secret (whsec_...) shown once when you create the endpoint or rotate its secret. Verify the signature before trusting any payload. The signature is an HMAC-SHA256 over `${svix-id}.${svix-timestamp}.${raw_body}`, base64-encoded. Always use the RAW request body — re-serializing the parsed JSON changes the bytes and breaks the signature.
import { createHmac, timingSafeEqual } from "node:crypto";
// secret = "whsec_..." from the dashboard (shown once)
export function verify(rawBody, headers, secret) {
const id = headers["svix-id"];
const ts = headers["svix-timestamp"];
const sigHeader = headers["svix-signature"]; // "v1,<base64> ..."
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64url");
const expected = createHmac("sha256", key)
.update(`${id}.${ts}.${rawBody}`)
.digest("base64");
return sigHeader.split(" ").some((part) => {
const sig = part.split(",", 2)[1];
if (!sig || sig.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
}Reject stale timestamps
The svix-timestamp is part of the signed content. After verifying the signature, reject deliveries whose timestamp is outside a tolerance window (e.g. ±5 minutes) to defeat replay attacks.
Test before you ship
Use the Send test action on any endpoint (dashboard or API) to fire a sample signed event to confirm your verification works end to end. The test payload uses the real event shape.
Rotating the signing secret
The signing secret is shown once. If you lose it, rotate the secret from the ⋯ menu to generate a fresh one. Rotation takes effect immediately.
Retries & failures
If your endpoint doesn't return a 2xx, we retry with backoff up to 5 attempts. Acknowledge with a 2xx quickly, then do slower work asynchronously. Permanent errors (4xx) are not retried. Transient failures (5xx, timeouts) are retried.
Each endpoint shows a live status: Enabled, Failing, or Disabled. After sustained failures we disable an endpoint to stop sending to a dead URL. Re-enable it to resume delivery.
Event retention
Delivery attempts and their payloads are retained for 30 days, then pruned. Persist anything you need to keep when you receive it.
Permissions
Viewing webhooks requires the webhooks:read capability; creating, updating, and deleting require webhooks:manage. API keys can hold these like any other capability.