# 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é.

## État

Les webhooks existent sur le serveur, pour les intégrations machine. Aujourd'hui, l'adresse de réception se règle par un administrateur de Subagent, pour une marque entière. Aucun outil ne permet encore à un espace de déclarer la sienne : c'est [à venir].

D'ici là, un agent n'a pas besoin de webhook : il lit ses réponses avec `list_threads` (`status` = `replied`), puis `get_thread`. Voir [Fils](https://subagent.fr/docs/fils.md).

## Les événements

| Événement | Quand | Données (`data`) |
|---|---|---|
| `message.sent` | Un message est parti. | `message_id`, `thread_id`, `inbox_id`, `to`, `subject`, `metadata`, `dry_run` |
| `message.delivered` | Le serveur du destinataire a confirmé la remise. | `message_id`, `thread_id`, `inbox_id`, `to`, `subject`, `metadata` |
| `message.bounced` | Un message est revenu : adresse en échec. | Les mêmes, et `bounce` : `type`, `status`, `diagnostic` |
| `message.rejected` | Une règle d'envoi a refusé un message au moment de partir. | `message_id`, `thread_id`, `inbox_id`, `to`, `code`, `reason` |
| `message.unsubscribed` | Une personne s'est désinscrite, ou a signalé un message. | `address`, `via`, `reason`, `message_id` |
| `thread.replied` | Le correspondant a répondu dans un fil. | `thread_id`, `message_id`, `inbox_id`, `from`, `subject`, `preview`, `auto_reply` |
| `thread.received` | Un nouvel e-mail a ouvert un fil. | Les mêmes que `thread.replied` |
| `inbox.suspended` | Le coupe-circuit a suspendu une boîte. | `inbox_id`, `address`, `reason` |

Une réponse automatique ne déclenche pas `thread.replied`.

## La forme d'un appel

Corps de la requête :

```json
{
  "id": "whd_9b2e4c7a1d5f48e3a6c0b8d2f4e6a1c3",
  "type": "thread.replied",
  "created_at": "2026-10-09T16:12:00+02:00",
  "tenant_id": "ten_8d41c0b7a2e94f6cb3159e07d2a8c6f1",
  "data": {
    "thread_id": "thr_b81f4c6a2d9e47035c1a8e6f0b4d2c97",
    "message_id": "msg_e93b7d1a5c0f42b8d6e2a9c4f7b1d056",
    "inbox_id": "inb_3f9c2a7e5b1d4c8e9a0f6b2d7c4e1a95",
    "from": "nadia.morel@exemple.com",
    "subject": "Re: Rendez-vous de jeudi",
    "preview": "Bonjour, vendredi à la même heure me convient.",
    "auto_reply": false
  }
}
```

| En-tête | Contenu |
|---|---|
| `Subagent-Signature` | `t=<horodatage>,v1=<signature>` |
| `Subagent-Event` | Le type de l'événement, par exemple `thread.replied`. |
| `Subagent-Delivery` | L'identifiant de la remise, `whd_…`. Le même à chaque réessai. |
| `Content-Type` | `application/json` |

## Vérifier la signature

La signature `v1` est le HMAC-SHA256, en hexadécimal, de la chaîne `<horodatage>.<corps brut>`, calculé avec le secret du webhook (`whsec_…`, affiché une seule fois à sa création).

1. Lisez `t` et `v1` dans l'en-tête `Subagent-Signature`.
2. Refusez un horodatage trop ancien : cinq minutes est une tolérance courante.
3. Calculez le HMAC-SHA256 de `t`, un point, puis le corps brut de la requête, avec votre secret.
4. Comparez le résultat à `v1`, en temps constant.

```python
import hashlib, hmac, time

def signature_valide(secret: str, entete: str, corps: bytes, tolerance: int = 300) -> bool:
    champs = dict(p.strip().split("=", 1) for p in entete.split(","))
    horodatage = int(champs["t"])
    if abs(time.time() - horodatage) > tolerance:
        return False
    attendu = hmac.new(secret.encode(), f"{horodatage}.".encode() + corps, hashlib.sha256).hexdigest()
    return hmac.compare_digest(attendu, champs["v1"])
```

## 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.
- Un même événement peut donc arriver deux fois : reconnaissez-le à son `id`.
