Skip to content
Docs
Open Escouade
Documentation contents

Docs › Mail

Webhooks

View as .md

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

EventWhenData (data)
mail.message.sentA message has left.message_id
mail.message.deliveredThe recipient’s server confirmed delivery.message_id
mail.message.bouncedA 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.rejectedA sending rule refused a message at the moment of leaving.message_id, code, reason
mail.message.unsubscribedA 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.opposedAn address was blocked by hand, with add_suppression.origine: manuel
mail.thread.repliedThe 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.receivedA new e-mail opened a thread.The same as mail.thread.replied
mail.inbox.suspendedThe circuit breaker suspended an inbox.address, reason
mail.webhook.testA 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
{
  "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.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.
HeaderContent
webhook-idThe event’s identifier, evt_…. The same on every retry, and in event_feed.
webhook-timestampThe time of sending, in Unix seconds.
webhook-signaturev1,<signature>
Content-Typeapplication/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.

ParameterTypeRequiredDescription
urltextyesA 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.

ParameterTypeRequiredDescription
aftertextnoThe next_cursor from the previous call, as it is. Absent: the feed starts from the oldest event kept.
limitintegernoNumber 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.
This page in Markdown: /en/mail/docs/webhooks.md All the docs for an agent: /en/mail/docs/llms.txt Legal notice · Terms · Privacy · Service status