---
title: Receiving events
description: How webhooks are delivered, what to do with them, and what happens when your endpoint is down.
---

We POST a JSON payload to your endpoint when something happens:
somebody subscribed, somebody left, a campaign finished, a message
bounced.

For most integrations these matter more than anything you can poll
for, because they are how your own systems learn that a person's
consent changed.

## Setting one up

**Settings → Webhooks** in the dashboard. Give us an HTTPS URL, pick
the events you want, and copy the signing secret. You will need it to
[verify signatures](/webhooks/signatures).

## The payload

Every event shares an envelope.

```json
{
  "id": "whe_3k9m2x7q",
  "type": "contact.unsubscribed",
  "created_at": "2026-03-14T09:30:00.000Z",
  "data": {
    "contact_id": "ct_8vq2xk4m",
    "email": "ada@example.com",
    "scope": "topic",
    "topic_id": "tp_7xk2m9qv4w",
    "topic_name": "Weekly newsletter",
    "brand_id": "br_3nf8q1",
    "campaign_id": "cmp_5t8w2k",
    "occurred_at": "2026-03-14T09:29:58.000Z"
  }
}
```

- **`id`** — stable across retries. Dedupe on it.
- **`type`** — which event. See the [catalogue](/webhooks/events).
- **`created_at`** — when we built the payload.
- **`data`** — the event's own fields.
- **`data.occurred_at`** — when it actually happened, which can be
  earlier than `created_at` for delivery events that reach us from
  the mail provider.

## Respond fast, work later

Return a `2xx` within **10 seconds**. Anything else counts as a
failure.

Do the quick thing first, then the slow thing:

```js
app.post('/webhooks/zevcampaign', async (req, res) => {
  if (!verifySignature(req)) return res.sendStatus(401);

  // Acknowledge, then work. A slow CRM call inside the handler is
  // the usual reason an endpoint starts timing out and gets
  // disabled.
  res.sendStatus(200);
  await queue.add('zevcampaign-event', req.body);
});
```

## Delivery is at-least-once

If your endpoint does not answer `2xx`, we retry with exponential
backoff: roughly 1, 2, 4, 8 and 16 minutes, about half an hour of
attempts.

That means you can receive the same event twice — most often when you
did the work and then timed out before answering. Dedupe on the
envelope `id` and make your handler safe to run twice.

## Repeated failures disable the endpoint

If an endpoint fails too many times in a row, we stop sending to it
and email whoever on your team manages webhooks.

Nothing else is affected. Campaigns keep sending and every event is
still recorded, so your reports stay complete.

**Events from while it was off are not replayed.** Fix the endpoint,
turn it back on, and backfill anything you need from your reports.
Replaying a backlog into a system that has moved on usually causes a
second incident.

## Events fire once each

Some are deliberately once-only, and it is worth knowing which:

- **`email.opened`** fires on a recipient's *first* open. Pixels are
  prefetched by Gmail, scanned by security gateways and refetched on
  every scroll, so an event per load would be mostly machine traffic.
- **`email.clicked`** fires on a recipient's first click of a given
  link. Later clicks still count in your reports.
- **`contact.unsubscribed`** fires on the first opt-out. Re-clicking
  an old unsubscribe link does not fire it again.

Your campaign reports always hold the full counts. Webhooks are the
once-per-person signal.

## Test and live

A **test** key never fires webhooks, deliberately. A sandbox call that
reached your production endpoint would have your CRM record a
subscriber who does not exist.

To test your handler, use a live key and a verified recipient address
of your own.

## Local development

Your endpoint has to be reachable from the public internet, so point
a tunnel at it:

```bash
ngrok http 3000
# then register https://<id>.ngrok.io/webhooks/zevcampaign
```

The dashboard shows recent deliveries with the request, the response
and the timing, which is usually enough to see what went wrong
without reproducing it.