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. A program that wants the events without exposing an address reads them with event_feed; a read key is enough: see below.
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).
{
"id": "evt_9b2e4c7a1d5f48e3a6c0b8d2f4e6a1c3",
"type": "mail.thread.replied",
"occurred_at": "2026-10-09T14:12:00Z",
"account": "ten_8d41c0b7a2e94f6cb3159e07d2a8c6f1",
"subject": {
"person": { "email": "[email protected]" },
"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.threadandmail.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.
- Read the
webhook-id,webhook-timestampandwebhook-signatureheaders. - Refuse a timestamp that is too old: five minutes is a common tolerance.
- Compute the HMAC-SHA256 of the identifier, a dot, the timestamp, a dot, then the request’s raw body, with the secret’s key.
- Compare
v1,followed by the base64 result with each signature in the header (separated by a space), in constant time.
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 itsid, 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 (codespace_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_webhookto 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, andnext_cursor, always returned: the same asafterwhen nothing new has arrived. Pass it back asafteron 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.opposedormail.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
idonce: an event read through the feed may already have arrived by webhook. - An unreadable cursor is refused with the code
invalid_argument.