Documentation › Webhooks
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. Un programme qui veut les événements sans exposer d’adresse les relit avec event_feed, une clé read suffit : voir plus bas.
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).
{
"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: 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.threadetmail.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.
- Lisez les en-têtes
webhook-id,webhook-timestampetwebhook-signature. - Refusez un horodatage trop ancien : cinq minutes est une tolérance courante.
- 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.
- Comparez
v1,suivi du résultat en base64 à chaque signature de l’en-tête (séparées par une espace), en temps constant.
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 à sonid, 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 (codespace_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_webhookpour 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, etnext_cursor, toujours rendu : le même queafterquand rien de neuf n’est arrivé. Repassez-le enafterà 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.opposedoumail.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
idune 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.