---
title: Receiving webhooks
description: 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

1. Create an **endpoint** in the dashboard with the URL you
   want events POSTed to.
2. Pick which **events** the endpoint subscribes to.
3. We sign every delivery with a shared secret. Your app
   verifies the signature and returns `2xx` quickly.

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.

```text
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:

```json
{
  "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](/webhooks/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:

1. **Verify the signature** before doing anything with the
   payload. An unsigned payload is just internet noise.
2. **Return `2xx` quickly.** 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.
3. **Be idempotent.** We retry on failure, and at-least-once
   delivery means you may see the same event twice. Use the
   `X-Zev-Event-Id` header as your dedupe key.

```ts
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:

```text
+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.