> For an agent: the map of this documentation is at https://escouade.io/en/mail/docs/llms.txt; every page exists in Markdown, at the same address with `.md` appended.

# Webhooks

> The server reports through webhooks what happens to a message or a thread: a departure, a delivery, a bounce, a reply, an unsubscribe. Each call is a signed JSON `POST` in the Standard Webhooks format. The same events can be read again by cursor with `event_feed`.

## Status

A space declares the address that receives its events with `set_webhook`, and removes it with `remove_webhook`. These are gestures for the space's human, from their assistant: permission `approve`. `get_settings` says whether a webhook is declared.

An agent does not need a webhook: it collects its mail with `list_threads` (`unanswered` = `true`), then `get_thread`. See [Receiving mail](https://escouade.io/en/mail/docs/recevoir.md#relever). A program that wants the events without exposing an address reads them with `event_feed`; a `read` key is enough: see [below](#event_feed).

## The events

| Event | When | Data (`data`) |
|---|---|---|
| `mail.message.sent` | A message has left. | `message_id` |
| `mail.message.delivered` | The recipient's server confirmed delivery. | `message_id` |
| `mail.message.bounced` | A message came back, or the remote server refused it. | `message_id`, `permanent` (`true`: dead address or permanent refusal; `false`: policy refusal, the address is not considered dead), `code` (the extended code, `5.0.0` if the notice gives none), `diagnostic` (200 characters at most) |
| `mail.message.rejected` | A sending rule refused a message at the moment of leaving. | `message_id`, `code`, `reason` |
| `mail.message.unsubscribed` | A person unsubscribed, or reported a message as spam. | `message_id` (or `null`), `origine`: `lien` (the unsubscribe link), `stop` (a “STOP” in a reply) or `plainte` (a complaint) |
| `mail.person.opposed` | An address was blocked by hand, with `add_suppression`. | `origine`: `manuel` |
| `mail.thread.replied` | The correspondent replied in a thread. | `message_id`, `extrait` (the start of the text, 200 characters at most, on one line), `automatique` (`true` for an automatic reply) |
| `mail.message.received` | A new e-mail opened a thread. | The same as `mail.thread.replied` |
| `mail.inbox.suspended` | The circuit breaker suspended an inbox. | `address`, `reason` |
| `mail.webhook.test` | A webhook has just been declared: this is a test, for that webhook only. | `message` |

The data field names (`extrait`, `automatique`, `origine`) and their values (`lien`, `plainte`, `manuel`) are written in French, as the server sends them. Ignore a type you do not know: the list may grow. Blocking a whole domain names no one: it emits nothing.

## The shape of a call

The body is the event's envelope, shared across the Escouade family: its identifier (`evt_…`), its type, its date in UTC, the space (`account`), what it is about (`subject`), its data (`data`) and the envelope's version (`v`).

Request body:

```json
{
  "id": "evt_9b2e4c7a1d5f48e3a6c0b8d2f4e6a1c3",
  "type": "mail.thread.replied",
  "occurred_at": "2026-10-09T14:12:00Z",
  "account": "ten_8d41c0b7a2e94f6cb3159e07d2a8c6f1",
  "subject": {
    "person": { "email": "nadia.morel@exemple.com" },
    "refs": {
      "mail.inbox": "inb_3f9c2a7e5b1d4c8e9a0f6b2d7c4e1a95",
      "mail.message": "msg_e93b7d1a5c0f42b8d6e2a9c4f7b1d056",
      "mail.thread": "thr_b81f4c6a2d9e47035c1a8e6f0b4d2c97"
    }
  },
  "data": {
    "message_id": "msg_e93b7d1a5c0f42b8d6e2a9c4f7b1d056",
    "extrait": "Bonjour, vendredi à la même heure me convient.",
    "automatique": false
  },
  "v": 1
}
```

- **`subject.person.email`**: the correspondent (the recipient of a send, the sender of a reply, the person unsubscribed or blocked), in lowercase.
- **`subject.refs`**: the objects concerned, `mail.message`, `mail.thread` and `mail.inbox`; `mail.campaign` (`cmp_…`) when the message leaves from a campaign or replies to one; the Prescriptio references (`prescriptio.organisation`, `prescriptio.campagne`, `prescriptio.contact`, `prescriptio.envoi`) when the thread was opened by Prescriptio. Ignore an unknown reference.

| Header | Content |
|---|---|
| `webhook-id` | The event's identifier, `evt_…`. The same on every retry, and in `event_feed`. |
| `webhook-timestamp` | The time of sending, in Unix seconds. |
| `webhook-signature` | `v1,<signature>` |
| `Content-Type` | `application/json` |

## Verifying the signature

The `v1` signature is the HMAC-SHA256, in base64, of the string `<webhook-id>.<webhook-timestamp>.<raw body>`. Its key is the webhook's secret (`whsec_…`, shown once only when it is declared) without its `whsec_` prefix, decoded from base64. This is the Standard Webhooks format: its libraries verify it as it is.

1. Read the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers.
2. Refuse a timestamp that is too old: five minutes is a common tolerance.
3. Compute the HMAC-SHA256 of the identifier, a dot, the timestamp, a dot, then the request's raw body, with the secret's key.
4. Compare `v1,` followed by the base64 result with each signature in the header (separated by a space), in constant time.

```python
import base64, hashlib, hmac, time

def signature_is_valid(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
    identifier = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    digest = hmac.new(key, f"{identifier}.{timestamp}.".encode() + body, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(digest).decode()
    return any(hmac.compare_digest(expected, s) for s in headers["webhook-signature"].split())
```

## Retries

- A delivery succeeds when your server answers with a 2xx code, in less than 10 seconds.
- Otherwise it is retried, with a growing delay: 30 seconds, 2 minutes, 8 minutes, 32 minutes, a little over 2 hours, then 6 hours.
- After 8 attempts, the delivery is abandoned. The event remains readable with `event_feed`.
- The same event can therefore arrive twice, and also through `event_feed`: recognise it by its `id`, and process it once.
- A closed delivery, successful or abandoned, is erased after 30 days.

## `set_webhook`

Declares the address that receives the space's events. Permission: `approve`. The space must be verified.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `url` | text | yes | A public `https` address, 500 characters at most, with no username or password. |

- **The signing secret (`whsec_…`) is returned once only**, in the response (`secret`). Keep it like a key.
- **A test event, `mail.webhook.test`, leaves within the minute**: if it does not arrive, check the address.
- An address already declared is replaced, with a new secret; its pending deliveries are dropped. One declaration per minute at most (code `webhook_too_soon`), and ten in 24 hours (code `space_rate_limited`).
- A private, loopback or link-local address is refused, and redirects are not followed.
- The space's human receives an e-mail for every webhook declared or removed. It does not contain the secret.
- **An address that stops answering ends up removed**: after 50 failures in a row, the server removes the webhook and tells the human by e-mail. Undelivered events are not sent again; they remain readable with `event_feed`.
- **A secret from before the Standard Webhooks format no longer signs**: its deliveries fail. Declare the address again with `set_webhook` to receive a new secret.
- The address cannot carry a key (code `secret_in_message`).
- A space has a single webhook: it receives all the space's events.

## `remove_webhook`

Removes the space's webhook: its address receives nothing more, and pending deliveries are dropped. Permission: `approve`. No parameters.

## `event_feed`

Reads the space's events again by cursor, in the order they were emitted: the same ones as its webhooks, with the same envelope and the same `id`. Permission: `read`. The space may be verified or not, and no webhook needs to be declared.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `after` | text | no | The `next_cursor` from the previous call, as it is. Absent: the feed starts from the oldest event kept. |
| `limit` | integer | no | Number of events returned, from 1 to 500 (100 by default). |

- **The response** carries `events`, the envelopes in the order they were emitted, and `next_cursor`, always returned: the same as `after` when nothing new has arrived. Pass it back as `after` on the next call.
- **Events are kept for thirty-five days**; after that, they leave the feed.
- **A key limited to some inboxes** only sees its inboxes' events: neither those of another inbox, nor the space's events that name no inbox, such as `mail.person.opposed` or `mail.webhook.test`.
- **An event can appear in the feed a little after its webhook**: the feed waits for older writes to be closed, so as to leave none behind a cursor already returned. A cursor the server no longer recognises (after its database is restored) makes the feed start again from the beginning: duplicates, never a loss.
- **Process each `id` once**: an event read through the feed may already have arrived by webhook.
- An unreadable cursor is refused with the code `invalid_argument`.
