> Pour un agent : la carte de la documentation d'Escouade Mail est sur https://escouade.io/mail/docs/llms.txt ; chaque page existe en Markdown, à la même adresse suffixée `.md`.

# Webhooks

> Le serveur signale par webhook ce qui arrive à un message ou à un fil : un départ, une remise, un rebond, une réponse, une désinscription. Chaque appel est un `POST` JSON signé au format Standard Webhooks. Les mêmes événements se relisent par curseur avec `event_feed`.

## État

Un espace déclare l'adresse qui reçoit ses événements avec `set_webhook`, et la retire avec `remove_webhook`. Ce sont des gestes de l'humain de l'espace, depuis son assistant : permission `approve`. `get_settings` dit si un webhook est déclaré.

Un agent n'a pas besoin de webhook : il relève son courrier avec `list_threads` (`unanswered` = `true`), puis `get_thread`. Voir [Recevoir du courrier](https://escouade.io/mail/docs/recevoir.md#relever). Un programme qui veut les événements sans exposer d'adresse les relit avec `event_feed`, une clé `read` suffit : voir [plus bas](#event_feed).

## Les événements

| Événement | Quand | Données (`data`) |
|---|---|---|
| `mail.message.sent` | Un message est parti. | `message_id` |
| `mail.message.delivered` | Le serveur du destinataire a confirmé la remise. | `message_id` |
| `mail.message.bounced` | Un message est revenu, ou le serveur distant l'a refusé. | `message_id`, `permanent` (`true` : adresse morte ou refus définitif ; `false` : refus de politique, l'adresse n'est pas tenue pour morte), `code` (le code étendu, `5.0.0` si l'avis n'en donne pas), `diagnostic` (200 caractères au plus) |
| `mail.message.rejected` | Une règle d'envoi a refusé un message au moment de partir. | `message_id`, `code`, `reason` |
| `mail.message.unsubscribed` | Une personne s'est désinscrite, ou a signalé un message comme indésirable. | `message_id` (ou `null`), `origine` : `lien` (le lien de désinscription), `stop` (un « STOP » dans une réponse) ou `plainte` |
| `mail.person.opposed` | Une adresse a été bloquée à la main, avec `add_suppression`. | `origine` : `manuel` |
| `mail.thread.replied` | Le correspondant a répondu dans un fil. | `message_id`, `extrait` (le début du texte, 200 caractères au plus, sur une ligne), `automatique` (`true` pour une réponse automatique) |
| `mail.message.received` | Un nouvel e-mail a ouvert un fil. | Les mêmes que `mail.thread.replied` |
| `mail.inbox.suspended` | Le coupe-circuit a suspendu une boîte. | `address`, `reason` |
| `mail.webhook.test` | Un webhook vient d'être déclaré : c'est un essai, pour son seul webhook. | `message` |

Un type que vous ne connaissez pas s'ignore : la liste peut s'allonger. Le blocage d'un domaine entier ne nomme personne : il n'émet rien.

## La forme d'un appel

Le corps est l'enveloppe de l'événement, commune à la famille Escouade : son identifiant (`evt_…`), son type, sa date en UTC, l'espace (`account`), ce dont il parle (`subject`), ses données (`data`) et la version de l'enveloppe (`v`).

Corps de la requête :

```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`** : le correspondant (le destinataire d'un envoi, l'expéditeur d'une réponse, la personne désinscrite ou bloquée), en minuscules.
- **`subject.refs`** : les objets concernés, `mail.message`, `mail.thread` et `mail.inbox` ; `mail.campaign` (`cmp_…`) quand le message part d'une campagne ou y répond ; les références de Prescriptio (`prescriptio.organisation`, `prescriptio.campagne`, `prescriptio.contact`, `prescriptio.envoi`) quand le fil a été ouvert par Prescriptio. Une référence inconnue s'ignore.

| En-tête | Contenu |
|---|---|
| `webhook-id` | L'identifiant de l'événement, `evt_…`. Le même à chaque réessai, et dans `event_feed`. |
| `webhook-timestamp` | L'heure de l'envoi, en secondes Unix. |
| `webhook-signature` | `v1,<signature>` |
| `Content-Type` | `application/json` |

## Vérifier la signature

La signature `v1` est le HMAC-SHA256, en base64, de la chaîne `<webhook-id>.<webhook-timestamp>.<corps brut>`. Sa clé est le secret du webhook (`whsec_…`, affiché une seule fois à sa déclaration) sans son préfixe `whsec_`, décodé du base64. C'est le format Standard Webhooks : ses bibliothèques le vérifient telles quelles.

1. Lisez les en-têtes `webhook-id`, `webhook-timestamp` et `webhook-signature`.
2. Refusez un horodatage trop ancien : cinq minutes est une tolérance courante.
3. Calculez le HMAC-SHA256 de l'identifiant, un point, l'horodatage, un point, puis le corps brut de la requête, avec la clé du secret.
4. Comparez `v1,` suivi du résultat en base64 à chaque signature de l'en-tête (séparées par une espace), en temps constant.

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

def signature_valide(secret: str, entetes: dict, corps: bytes, tolerance: int = 300) -> bool:
    identifiant = entetes["webhook-id"]
    horodatage = entetes["webhook-timestamp"]
    if abs(time.time() - int(horodatage)) > tolerance:
        return False
    cle = base64.b64decode(secret.removeprefix("whsec_"))
    calcul = hmac.new(cle, f"{identifiant}.{horodatage}.".encode() + corps, hashlib.sha256).digest()
    attendu = "v1," + base64.b64encode(calcul).decode()
    return any(hmac.compare_digest(attendu, s) for s in entetes["webhook-signature"].split())
```

## Réessais

- Une remise réussit quand votre serveur répond par un code 2xx, en moins de 10 secondes.
- Sinon elle est réessayée, avec un délai qui s'allonge : 30 secondes, 2 minutes, 8 minutes, 32 minutes, un peu plus de 2 heures, puis 6 heures.
- Après 8 tentatives, la remise est abandonnée. L'événement reste lisible avec `event_feed`.
- Un même événement peut donc arriver deux fois, et aussi par `event_feed` : reconnaissez-le à son `id`, et traitez-le une seule fois.
- Une remise close, réussie ou abandonnée, est effacée après 30 jours.

## `set_webhook`

Déclare l'adresse qui reçoit les événements de l'espace. Permission : `approve`. L'espace doit être vérifié.

| Paramètre | Type | Obligatoire | Description |
|---|---|---|---|
| `url` | texte | oui | Une adresse publique en `https`, 500 caractères au plus, sans identifiant ni mot de passe. |

- **Le secret de signature (`whsec_…`) est rendu une seule fois**, dans la réponse (`secret`). Gardez-le comme une clé.
- **Un événement d'essai, `mail.webhook.test`, part dans la minute** : s'il n'arrive pas, vérifiez l'adresse.
- Une adresse déjà déclarée est remplacée, avec un secret neuf ; ses remises en attente sont abandonnées. Une déclaration par minute au plus (code `webhook_too_soon`), et dix en 24 heures (code `space_rate_limited`).
- Une adresse privée, de bouclage ou de lien local est refusée, et les redirections ne sont pas suivies.
- L'humain de l'espace reçoit un e-mail à chaque webhook déclaré ou retiré. Il ne contient pas le secret.
- **Une adresse qui ne répond plus finit retirée** : après 50 échecs de suite, le serveur retire le webhook et prévient l'humain par e-mail. Les événements non remis ne sont pas renvoyés ; ils restent lisibles avec `event_feed`.
- **Un secret d'avant le format Standard Webhooks ne signe plus** : ses remises échouent. Redéclarez l'adresse avec `set_webhook` pour recevoir un secret neuf.
- L'adresse ne porte pas de clé (code `secret_in_message`).
- Un espace a un seul webhook : il reçoit tous les événements de l'espace.

## `remove_webhook`

Retire le webhook de l'espace : son adresse ne reçoit plus rien, et les remises en attente sont abandonnées. Permission : `approve`. Sans paramètre.

## `event_feed`

Relit par curseur les événements de l'espace, dans l'ordre où ils ont été émis : les mêmes que ses webhooks, avec la même enveloppe et le même `id`. Permission : `read`. L'espace peut être vérifié ou non, et aucun webhook n'a besoin d'être déclaré.

| Paramètre | Type | Obligatoire | Description |
|---|---|---|---|
| `after` | texte | non | Le curseur `next_cursor` de l'appel précédent, tel quel. Absent : le flux part du plus ancien événement gardé. |
| `limit` | entier | non | Nombre d'événements rendus, de 1 à 500 (100 par défaut). |

- **La réponse** porte `events`, les enveloppes dans l'ordre d'émission, et `next_cursor`, toujours rendu : le même que `after` quand rien de neuf n'est arrivé. Repassez-le en `after` à l'appel suivant.
- **Les événements sont gardés trente-cinq jours** ; au-delà, ils quittent le flux.
- **Une clé bornée à des boîtes** ne voit que les événements de ses boîtes : ni ceux d'une autre boîte, ni ceux de l'espace qui ne nomment aucune boîte, comme `mail.person.opposed` ou `mail.webhook.test`.
- **Un événement peut paraître dans le flux un peu après son webhook** : le flux attend que les écritures plus anciennes soient closes, pour n'en laisser aucune derrière un curseur déjà rendu. Un curseur que le serveur ne reconnaît plus (après une restauration de sa base) fait repartir le flux du début : des doublons, jamais de perte.
- **Traitez chaque `id` une seule fois** : un événement relu par le flux a pu arriver déjà par webhook.
- Un curseur illisible est refusé avec le code `invalid_argument`.
