# Les webhooks

Un webhook prévient votre propre service quand quelque chose arrive sur un projet : un déploiement sert, échoue, un domaine est vérifié, une variable change. Bastide poste un événement signé à l'adresse que vous déclarez, réessaie en cas d'échec, et garde un journal des livraisons. Cette page explique comment déclarer un point de réception, quels événements existent, et comment vérifier leur signature.

## Les concepts

- Un **point de réception** est une adresse `https://` de votre service, déclarée sur un projet, avec la liste des types d'événements qu'elle reçoit (tous, si la liste est vide). Son identifiant commence par `whk_`.
- Un **événement** est un fait du projet. Son identifiant (`evt_…`) ne change jamais d'un envoi à l'autre.
- Une **livraison** (`lvr_…`) est l'envoi d'un événement à un point. Elle est en attente (`pending`), livrée (`delivered`) ou au rebut (`dead`).
- La livraison est faite **au moins une fois** : un même événement peut arriver deux fois. Dédupliquez sur son `id`.

## Déclarer un point de réception

Il faut le rang Administrateur sur le projet.

1. Ouvrez le projet, puis **Webhooks**.
2. Dans « Déclarer un point de réception », saisissez l'adresse et, si vous voulez, les types d'événements à recevoir.
3. **Déclarer le point**.
4. Copiez le secret affiché (`whsec_…`) et rangez-le dans votre service : il ne sera plus montré.

Par l'API, l'opération est `webhook.endpoint.create` :

```json
{
  "url": "https://exemple.fr/bastide/evenements",
  "types": ["deployment.ready", "deployment.failed"]
}
```

L'écran **Webhooks** montre ensuite chaque point, ses livraisons et leur état. Depuis la carte d'un point : **Faire tourner le secret** en frappe un nouveau, et **Supprimer** retire le point avec ses livraisons en attente.

> **Note.** Déclarer ou supprimer un point, et faire tourner son secret, n'a pas d'outil MCP : un agent ne crée pas de flux sortant et ne reçoit pas de secret. Un agent peut lire les livraisons (`webhook_list`) et en rejouer une (`webhook_replay_create`).

## L'enveloppe

Chaque envoi est un `POST` JSON :

```json
{
  "id": "evt_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "type": "deployment.ready",
  "createdAt": "2026-10-28T09:14:02.120Z",
  "attempt": 1,
  "tenantId": "org_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "payload": { }
}
```

- `attempt` vaut 1 au premier envoi et continue d'un envoi à l'autre, rejeu compris.
- Les en-têtes `Bastide-Event-Id`, `Bastide-Event-Type` et `Bastide-Attempt` répètent ces valeurs, à côté de `Bastide-Signature`.
- `payload.actor` dit qui a fait le geste : `accountId` pour une personne, `serviceTokenId` pour un compte de service, `channel` pour la voie (`console`, `api`, `cli`, `mcp`, ou `forge` et `hook` pour un crochet). Quand `agentSession` n'est pas nul, un agent a agi, et `accountId` est la personne qui l'a mandaté. `actor` est nul quand aucun appel n'est derrière le fait, par exemple un déploiement qui devient sain.
- La valeur d'une variable n'est jamais transmise.

## Les événements

| Type | Quand |
| --- | --- |
| `deployment.created` | Un déploiement est créé et attend sa construction. |
| `deployment.built` | La construction a produit une image signée. |
| `deployment.ready` | Le déploiement sert du trafic. |
| `deployment.failed` | Le déploiement a échoué ; la cause se lit par `deployment.read`. |
| `deployment.canceled` | Le déploiement a été annulé avant de servir. |
| `deployment.restored` | Un déploiement antérieur est remis en service. |
| `project.created` | Un projet est créé. |
| `project.renamed` | Un projet change de nom. |
| `project.deleted` | Un projet est supprimé, au terme de sa grâce. |
| `project.anomaly.detected` | Un environnement entre en anomalie (taux d'erreur, latence, saturation, redémarrages en boucle). |
| `project.egress.threshold` | Le trafic sortant approche le plafond soutenu du palier. |
| `project.images.threshold` | Les transformations d'images franchissent 80 % du plafond mensuel du palier. |
| `domain.added` | Un domaine est ajouté. |
| `domain.verified` | La propriété d'un domaine est vérifiée. |
| `domain.misconfigured` | Le domaine ne pointe plus vers la plateforme ; il sera retiré dans sept jours s'il n'y pointe pas de nouveau. |
| `domain.deleted` | Un domaine cesse d'être servi. |
| `variable.created`, `variable.updated`, `variable.deleted` | Une variable d'environnement est créée, change de valeur ou est supprimée. |
| `database.branch.created`, `database.branch.destroyed` | Une branche de base est créée ou détruite. |
| `database.cron.failed` | Une tâche planifiée en base a échoué, ou n'a pas démarré dans sa fenêtre. |
| `database.schema.drifted` | La base réelle s'écarte du schéma que déclarent les migrations du dépôt. |

D'autres types figurent déjà au catalogue mais ne sont pas encore émis : la promotion d'un déploiement, la suppression d'un déploiement seul, les certificats, les sauvegardes, les seuils de dépense et les mouvements de membres. Vous pouvez les cocher ; ils arriveront quand la fonction sera servie. Le catalogue complet est dans [la page de référence de webhook.endpoint.create](../reference/operations/webhook.endpoint.create.md).

## Vérifier la signature

Chaque envoi porte :

```
Bastide-Signature: t=<secondes Unix>,v1=<hex de HMAC-SHA256(secret, "<t>.<corps>")>
```

1. Recalculez `HMAC-SHA256(secret, t + "." + corps)` sur le corps brut, avant tout analyseur JSON. La clé est le secret entier, préfixe `whsec_` compris.
2. Acceptez si l'une des valeurs `v1` est égale, par une comparaison à temps constant.
3. Refusez un `t` à plus de 300 secondes de votre horloge : c'est la protection contre le rejeu.

Après une rotation, l'ancien secret signe encore 24 heures : l'en-tête porte alors deux `v1`, et votre service peut passer au nouveau secret sans rien perdre.

Un exemple en Node, sans dépendance :

```js
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.BASTIDE_WEBHOOK_SECRET; // whsec_…
const FENETRE_S = 300;

function signatureValide(entete, corps) {
  if (typeof entete !== "string") return false;
  const champs = entete.split(",").map((p) => p.trim().split("="));
  const t = champs.find(([cle]) => cle === "t")?.[1];
  const signatures = champs.filter(([cle]) => cle === "v1").map(([, valeur]) => valeur);
  if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > FENETRE_S) return false;
  const attendue = createHmac("sha256", SECRET).update(`${t}.`).update(corps).digest();
  return signatures.some((s) => {
    const recue = Buffer.from(s, "hex");
    return recue.length === attendue.length && timingSafeEqual(recue, attendue);
  });
}

const dejaVus = new Set(); // en production : une table, avec l'identifiant en clé unique

createServer((req, res) => {
  const morceaux = [];
  req.on("data", (m) => morceaux.push(m));
  req.on("end", () => {
    const corps = Buffer.concat(morceaux);
    if (!signatureValide(req.headers["bastide-signature"], corps)) {
      res.writeHead(401).end();
      return;
    }
    const evenement = JSON.parse(corps.toString("utf8"));
    res.writeHead(204).end(); // répondre vite, traiter ensuite
    if (dejaVus.has(evenement.id)) return;
    dejaVus.add(evenement.id);
    console.log(evenement.type, evenement.projectId);
  });
}).listen(8080);
```

## Les nouvelles tentatives

- Une livraison réussit sur une réponse 2xx, reçue en entier en moins de 10 secondes. Une redirection n'est pas suivie : c'est un échec.
- En échec, Bastide réessaie 8 fois en 24 heures, en recul exponentiel :

| Tentative | Instant, depuis la première |
| --- | --- |
| 1 | à l'émission |
| 2 | +1 min |
| 3 | +5 min |
| 4 | +17 min |
| 5 | +53 min |
| 6 | +2 h 40 min |
| 7 | +8 h |
| 8 | +24 h |

- Après la 8e tentative en échec, la livraison passe au rebut. Rejouez-la depuis l'écran **Webhooks** (**Rejouer**) ou par `webhook_replay_create` : une tentative immédiate, puis le rebut si elle échoue encore. Une livraison livrée se rejoue aussi.
- Le journal des livraisons garde le code de réponse, le corps de réponse tronqué à 4 096 octets et la durée, pendant 30 jours, comme le rebut.

## Les limites

Elles ne dépendent pas du palier.

- 25 points de réception au plus par organisation (`quota_de_points_atteint`).
- `https://` seulement, sans identifiant ni mot de passe dans l'adresse.
- Une adresse interne (boucle locale, réseau privé, lien local, métadonnées d'hébergeur) est refusée, et vérifiée à chaque envoi sur l'adresse vers laquelle le nom résout.

## Les crochets de déploiement

Le sens inverse existe aussi : un crochet de déploiement est une adresse que votre outil appelle pour déclencher un déploiement d'une branche dans un environnement, sans compte ni jeton, chaque appel étant signé de la même façon. Il se crée sur le même écran **Webhooks**, au rang Mainteneur ; son secret (`dhsec_…`) n'est montré qu'une fois, et un projet en porte au plus 20. Voir [Déployer](deploy.md).

## Erreurs fréquentes

| Symptôme | Cause | Remède |
| --- | --- | --- |
| Toutes les livraisons échouent, signature refusée par votre service | Le corps a été analysé puis resérialisé avant le calcul. | Calculez sur le corps brut, tel que reçu. |
| Signature refusée par intermittence | L'horloge de votre serveur dérive de plus de cinq minutes. | Synchronisez l'horloge (NTP). |
| Livraisons en échec sur une réponse 301 ou 302 | Les redirections ne sont pas suivies. | Déclarez l'adresse finale. |
| Événement traité deux fois | La livraison est au moins une fois. | Dédupliquez sur `id`. |
| Échecs après un délai | Votre service met plus de 10 secondes à répondre. | Répondez tout de suite, traitez ensuite. |

## Voir aussi

- [Déployer](deploy.md)
- [L'API HTTP](api.md)
- [Notifications](notifications.md)
- [Journal d'audit](audit.md)
