Webhooks
Receiving webhooks
Subscribe to message lifecycle events so your application stays in sync.
Webhooks let ZevSend push lifecycle events to your application in real time. Every state transition — sent, delivered, bounced, read, failed — fires an event you can subscribe to.
How it works
- Create an endpoint in the dashboard with the URL you want events POSTed to.
- Pick which events the endpoint subscribes to.
- We sign every delivery with a shared secret. Your app
verifies the signature and returns
2xxquickly.
We retry failed deliveries with exponential backoff for up to 24 hours. After that, the event is logged as undeliverable and surfaced in the dashboard.
Setting up an endpoint
Open Settings → Webhooks → Add endpoint in the dashboard. Paste the public HTTPS URL of your handler and pick the events you want.
We’ll show your signing secret once. Copy it immediately — it’s the only secret that proves a delivery is genuinely from us.
whsec_a1b2c3d4e5f6...
Endpoints must be HTTPS in live mode. HTTP is allowed in
sandbox so you can point at localhost via a tunnel like
ngrok during development.
Event delivery
Every event arrives as a POST with this body shape:
{
"id": "whe_01HXYZ...",
"type": "email.delivered",
"created_at": "2025-05-24T12:34:56.789Z",
"data": {
"message": {
"public_id": "eml_01HXYZ...",
"from_address": "noreply@acme.com",
"to_address": "ada@example.com",
"subject": "Your receipt",
"status": "delivered",
"sandbox": false,
"sent_at": "2025-05-24T12:34:55.000Z"
}
}
}
| Header | Value |
|---|---|
Content-Type | application/json |
X-Zev-Signature | t=<unix-ms>,v1=<hmac-sha256-hex> (see Signatures) |
X-Zev-Event | The event type, e.g. email.delivered. |
X-Zev-Event-Id | The unique event id (whe_…). |
X-Zev-Delivery-Id | The unique id of this delivery attempt. |
What to do in your handler
Three rules:
- Verify the signature before doing anything with the payload. An unsigned payload is just internet noise.
- Return
2xxquickly. Do the actual work async if it takes more than a couple of seconds. We retry anything non-2xx, which can amplify a slow handler. - Be idempotent. We retry on failure, and at-least-once
delivery means you may see the same event twice. Use the
X-Zev-Event-Idheader as your dedupe key.
app.post('/webhooks/zevsend', async (req, res) => {
if (!verifyZevSignature(req)) return res.sendStatus(400);
const { id, type, data } = req.body;
// Idempotent — skip if we've seen this event before.
if (await alreadyProcessed(id)) return res.sendStatus(200);
// Queue the real work. Return fast.
await queue.add('zevsend.event', { type, data });
await markProcessed(id);
res.sendStatus(200);
});
Retries
If your endpoint doesn’t return 2xx within 10 seconds we
treat the delivery as failed and retry on this schedule:
+30s, +2m, +10m, +1h, +6h, +24h
After the final attempt the event is parked. The dashboard shows the last-attempted timestamp and the response body so you can debug. You can replay a parked event from the dashboard once your endpoint is back.
Sandbox vs live
Endpoints created with a sandbox key only receive sandbox: true
events. Endpoints created with a live key only receive
sandbox: false events. Same URL? Create one endpoint in each
mode.
Testing without a public URL
The dashboard has a Send test event button per endpoint.
It dispatches a synthetic email.delivered event with a
recognisable eml_test_… id. Useful for proving signature
verification works before you wire production traffic.
For local development, use a tunneling service (ngrok, cloudflared, etc.) and point the endpoint at the tunnel URL.
Updated at, Thursday, October 1, 2026