# Webhooks sortants

VaEmail peut notifier une URL applicative à chaque événement sur un email envoyé,
ou à une désinscription. Compatible avec le format Brevo : un intégrateur qui
recevait déjà des webhooks Brevo n'a rien à changer côté réception.

## Créer un webhook

```
curl -X POST https://app.vaemail.fr/v3/webhooks \
  -H "api-key: $VAEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://exemple.fr/hooks/vaemail",
    "events": ["delivered", "click", "hardBounce", "unsubscribe"],
    "type": "transactional"
  }'
```

Réponse, une seule fois :

```json
{ "id": 12, "secret": "5f2c...a91" }
```

`secret` n'est jamais réaffiché : il sert à vérifier la signature de chaque
notification (voir plus bas). Le perdre oblige à recréer le webhook.

`events` omis ou vide : le webhook reçoit tous les événements. `type` vaut
`transactional` ou `marketing`.

`GET /v3/webhooks`, `GET /v3/webhooks/{id}`, `PUT /v3/webhooks/{id}` et
`DELETE /v3/webhooks/{id}` complètent le cycle de vie. Détail des paramètres :
[référence de l'API](api.md#webhooks).

## Événements

| Événement | Sens |
| --- | --- |
| `sent` | Le message est parti vers le fournisseur d'envoi. |
| `delivered` | Le fournisseur du destinataire a accepté le message. |
| `opened` | Ouverture détectée (pixel de suivi). |
| `click` | Clic sur un lien suivi. |
| `hardBounce` | Rejet définitif (adresse invalide, boîte inexistante). |
| `softBounce` | Rejet temporaire (boîte pleine, serveur indisponible). |
| `spam` | Signalement comme indésirable par le destinataire. |
| `unsubscribe` | Désinscription, quel que soit le canal (lien, page de préférences, API). |
| `attribute_changed` | Un attribut du contact a changé de valeur. Voir [Historique](api.md#contacts). |

## Format du payload

Corps JSON, toujours avec `event` et `timestamp` en plus des champs propres à
l'événement :

```json
{
  "event": "delivered",
  "timestamp": "2026-09-28T14:32:05+00:00",
  "email": "client@exemple.fr",
  "message_id": 2841
}
```

Le corps envoyé est exactement le JSON signé (voir ci-dessous) : ne pas le
ré-encoder avant de vérifier la signature, un ré-encodage peut changer l'ordre
des clés ou l'échappement et casser la comparaison.

## Vérifier la signature

Chaque notification porte l'en-tête :

```
X-VaEmail-Signature: t=1758999999,v1=8f3c2a...
```

`t` est l'horodatage Unix de l'envoi, `v1` la signature HMAC-SHA256 calculée
sur `"{t}.{corps brut}"` avec le `secret` reçu à la création du webhook.

Vérification côté récepteur : recalculer le HMAC sur `t` et le corps brut
reçu, comparer en temps constant, et rejeter une notification trop ancienne
(protection rejeu, la fraîcheur maximale est à votre appréciation).

### PHP

```php
function verifier_signature(string $corpsBrut, string $enTete, string $secret, int $toleranceSecondes = 300): bool
{
    if (! preg_match('/^t=(\d+),v1=([0-9a-f]+)$/', $enTete, $m)) {
        return false;
    }
    [$horodatage, $signatureRecue] = [$m[1], $m[2]];

    if (abs(time() - (int) $horodatage) > $toleranceSecondes) {
        return false;
    }

    $attendue = hash_hmac('sha256', $horodatage . '.' . $corpsBrut, $secret);

    return hash_equals($attendue, $signatureRecue);
}
```

### Node.js

```javascript
const crypto = require('crypto');

function verifierSignature(corpsBrut, enTete, secret, toleranceSecondes = 300) {
  const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(enTete);
  if (!match) return false;
  const [, horodatage, signatureRecue] = match;

  if (Math.abs(Date.now() / 1000 - Number(horodatage)) > toleranceSecondes) {
    return false;
  }

  const attendue = crypto
    .createHmac('sha256', secret)
    .update(`${horodatage}.${corpsBrut}`)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(attendue), Buffer.from(signatureRecue));
}
```

Dans les deux cas, utiliser le corps brut de la requête (avant tout parsing
JSON) : `req.rawBody` côté Node/Express avec le bon middleware, `file_get_contents('php://input')`
côté PHP.

## Fiabilité de la livraison

Deux tentatives, avec un court délai entre les deux. Un webhook qui échoue
systématiquement (URL injoignable, 4xx/5xx en boucle) n'est pas désactivé
automatiquement : vérifier son état depuis `GET /v3/webhooks/{id}`.

## Ensuite

- [Référence de l'API](api.md), tous les endpoints
- [Sécurité](security.md), portées et restriction par IP
- [Codes d'erreur](errors.md)
