# Receive delivery events

Sending an email tells you the message was accepted. What the receiving mail server did with your invitation or your reset link arrives later, and a webhook is how you hear about it without polling. You register an HTTPS endpoint, choose the events you care about, and Lessly Mail posts a signed JSON body to that endpoint as each event is recorded.

## Register an endpoint

An agent calls [`mail_webhook_create`](/reference/mcp-tools/mail_webhook_create); over REST it is `POST /mail/webhooks` on the [Mail API](/reference/openapi/mail).

An endpoint needs a URL and the list of events to send to it. The URL must be `https://` — plain HTTP is rejected. You may also give it a description, up to 1000 characters, purely as a label for yourself.

> Registering returns the endpoint together with its **signing secret**. The secret starts with `whsec_` and is shown once, at creation. Listing endpoints, or fetching one by id, never returns it again, and there is no way to ask for it a second time or to replace it on an existing endpoint: an endpoint whose secret you have lost has to be deleted and registered again.

An endpoint can be edited afterwards — its URL, its event list, its description — and it can be disabled and re-enabled without losing it, with [`mail_webhook_update`](/reference/mcp-tools/mail_webhook_update). A disabled endpoint receives nothing. [`mail_webhook_delete`](/reference/mcp-tools/mail_webhook_delete) removes it for good. To read endpoints back: [`mail_webhook_get`](/reference/mcp-tools/mail_webhook_get) and [`mail_webhook_list`](/reference/mcp-tools/mail_webhook_list).

A product can have as many endpoints as it needs. Each event is delivered independently to every enabled endpoint that subscribes to it, each with its own signature.

## Choose the events

| Event | When it fires |
|---|---|
| `email.delivered` | The receiving mail server accepted the message |
| `email.bounced` | The receiving mail server rejected it |
| `email.complained` | The recipient marked the message as spam |
| `email.suppressed` | A recipient was skipped because the address is on the suppression list |
| `email.opened` | The recipient opened the message |
| `email.clicked` | The recipient followed a link in the message |

`email.bounced` covers every rejection, not just permanent ones. The payload's `bounceKind` field tells you which kind it was: `hard` for a permanent rejection, `soft` for a temporary one, `undetermined` when the receiving server did not say. Only a hard bounce changes the message's status and suppresses the address — a soft bounce is reported to you and nothing else.

`email.suppressed` fires once per skipped recipient, so a message addressed to three suppressed people produces three events. It is emitted when the message is accepted, and again at send time if the address became suppressed in between — which matters most for scheduled messages, where that gap can be long.

`email.opened` is recorded once per message, on the first open. `email.clicked` is recorded once per message per distinct link. Both are only produced when open and click tracking is switched on for the sending domain; see [set up a sending domain](/ship/mail/domains).

## The payload

Every delivery is a `POST` with a JSON body of the same shape:

```json
{
  "type": "email.bounced",
  "timestamp": "2026-08-02T09:14:03.000Z",
  "data": {
    "eventId": "8f2a1c4e-6d3b-4a71-9f0e-2b5c7d1a4e88",
    "emailId": "c1d9e7b2-3a4f-4c85-8b60-9e2d5a7f1c33",
    "type": "bounced",
    "recipient": "someone@example.com",
    "bounceKind": "hard",
    "reason": "smtp; 550 5.1.1 user unknown",
    "providerMessageId": "0100019200ab34cd-...",
    "occurredAt": "2026-08-02T09:14:03.000Z"
  }
}
```

- `type` at the top level is the subscribed event name; `data.type` is the same event without the `email.` prefix.
- `timestamp` and `data.occurredAt` are both the moment the event happened.
- `data.eventId` identifies the event itself.
- `data.emailId` is the message the event belongs to, or `null` when the notification could not be matched to a message you sent.
- `data.recipient` is the one address this event is about, even if the message had several.
- `data.bounceKind` is `hard`, `soft` or `undetermined` on a bounce, and `null` on every other event.
- `data.reason` carries what the receiving server said, when it said anything; otherwise `null`.
- `data.providerMessageId` identifies the message as it was handed to the receiving side. It is `null` for events on a message that was never handed over, such as a suppressed recipient.

## Verify the signature

Three headers accompany every delivery:

| Header | Contents |
|---|---|
| `webhook-id` | The id of this delivery |
| `webhook-timestamp` | The time it was signed, in seconds since the epoch |
| `webhook-signature` | `v1,` followed by the base64 signature |

The signature is an HMAC-SHA256 over the three parts joined with dots — `<webhook-id>.<webhook-timestamp>.<raw body>` — keyed by your endpoint secret with its `whsec_` prefix stripped and the remainder base64-decoded. Requests also carry `content-type: application/json` and a `user-agent` of `lessly-mail-webhooks/1`.

Sign the body exactly as it arrived, before any JSON parsing — re-serialising the object changes the bytes and the signature will not match. Compare in constant time.

```js

function verify(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const received = headers['webhook-signature'];

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected =
    'v1,' + createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64');

  const a = Buffer.from(received);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Reject anything that does not verify, and reject a delivery whose `webhook-timestamp` is far from your own clock — that is what stops an old, genuine request from being replayed at you later.

## Handle retries

A delivery counts as successful when your endpoint answers with a `2xx` status. Anything else — another status, a connection error, or no answer within ten seconds — is a failure, and the delivery is retried with an increasing delay between attempts. Delivery is given five attempts in total; after the fifth the delivery is marked failed and is not tried again.

Two things end a delivery early: if the endpoint was deleted or disabled between the event being recorded and the attempt being made, the delivery is marked failed without a request being sent.

Deliveries are at-least-once. The same event can reach you more than once — a retry after your endpoint answered slowly, for instance — and every attempt for a given event and endpoint carries the same `webhook-id`. Treat that id as the deduplication key and make your handler idempotent. For system email that is not a nicety: a handler that marks an invitation accepted will be run twice sooner or later.

Answer quickly. The ten-second timeout applies to your whole response, so do the work after you have replied: acknowledge with a `2xx`, then process the event on your own time. A handler that does its work first and answers afterwards will be retried while it is still working.

## Find out why events are not arriving

Every attempt at an endpoint is recorded and can be listed back with [`mail_webhook_deliveries_list`](/reference/mcp-tools/mail_webhook_deliveries_list), or `GET /mail/webhooks/:id/deliveries`. Records come newest first, between 1 and 100 at a time, 50 by default.

| Field | Meaning |
|---|---|
| `id` | The delivery id — the same value sent as `webhook-id` |
| `event_id`, `event_type` | The event that was delivered |
| `status` | `pending` while attempts remain, then `succeeded` or `failed` |
| `attempts` | How many attempts have been made |
| `last_attempt_at` | When the most recent attempt was made |
| `response_status` | The HTTP status your endpoint answered with, or `null` if it never answered |
| `last_error` | Why the last attempt failed, or `null` after a success |

This is the place to look when events are not arriving: a `failed` row with a `response_status` of 401 says your handler rejected a signature, and one with no `response_status` at all says the request never got an answer.

## Next steps

- [Handle bounces and protect your sending](/ship/mail/deliverability): what a bounce or a complaint does once you have been told about it.
- [Send a message](/ship/mail/sending): the statuses these events correspond to.
- [Set up a sending domain](/ship/mail/domains): switch on the tracking that produces open and click events.
- [How system email works](/ship/mail): where events sit in the wider flow.
