# Recevoir les événements par webhook

En quittant cette page, vous saurez créer un abonnement, vérifier la signature de chaque livraison avec du code copiable, lire la politique de réessai et rattraper les événements perdus quand votre serveur était indisponible.

Source : https://docs.sealtrust.io/webhooks/

---

Un webhook est un appel que notre serveur fait vers le vôtre quand quelque
chose arrive à l'un de vos produits. Vous n'avez rien à interroger en boucle.
En quittant cette page, vous saurez créer un abonnement, vérifier la signature
de chaque livraison avec du code que vous pouvez copier, lire la politique de
réessai et savoir quoi faire quand votre serveur était indisponible.

Le parcours tient en quatre étapes : ouvrir un point d'entrée HTTPS chez vous,
déclarer cet abonnement, vérifier la signature à chaque réception, surveiller
la santé de l'abonnement.

## Ce que vous recevez

Chaque livraison est une requête `POST` vers l'adresse que vous avez déclarée,
avec l'en-tête `Content-Type: application/json`. Le corps est le contenu de
l'événement, et rien d'autre. Le type de l'événement est dans un en-tête,
hors du corps.

Quatre en-têtes accompagnent chaque livraison.

| En-tête | Contenu |
| --- | --- |
| `X-Webhook-Event` | Le nom de l'événement, par exemple `product.scanned`. |
| `X-Webhook-Id` | Un identifiant calculé à partir du corps. Il ne change pas entre la première tentative et les réessais du même événement. Servez-vous-en, avec le nom de l'événement, pour ignorer un doublon. |
| `X-Webhook-Timestamp` | La date d'envoi de cette tentative, en secondes depuis le 1er janvier 1970. Elle change à chaque réessai. |
| `X-Webhook-Signature` | La signature du corps, au format `t=<horodatage>,v1=<empreinte>`. Présente uniquement si vous avez déclaré un secret. |

> [!ATTENTION] `X-Webhook-Event` n'est pas couvert par la signature
> La signature protège l'horodatage et le corps. Elle ne protège pas le nom de
> l'événement. Si votre traitement doit agir différemment selon le type
> d'événement et que cette décision engage quelque chose d'important, déclarez
> une adresse distincte par type d'événement. Chaque adresse ne reçoit alors
> qu'un seul type d'événement, sans dépendre d'un en-tête non signé.

Votre serveur dispose de dix secondes pour répondre. Toute réponse dont le code
HTTP se situe entre 200 et 299 vaut succès. Tout le reste vaut échec, y compris
un code de redirection en 300, un 404 et un 500. Répondez d'abord, traitez
ensuite.

## Mettre en place un abonnement

Il vous faut deux choses. Une clef d'API de votre marque portant le droit
`webhooks:write`, créée depuis la console de votre marque, dans Réglages puis
Développeurs. Et une offre qui comprend les notifications par webhook. Sans
cette offre, nous refusons la création en 403 avec le code
`FEATURE_NOT_AVAILABLE`.

Vous déclarez l'abonnement avec trois valeurs.

| Champ | Type | Obligatoire | Description |
| --- | --- | --- | --- |
| `url` | `string` | oui | Adresse de votre point d'entrée. Elle doit commencer par `https://`, faire entre 10 et 2048 caractères, et désigner une adresse publiquement routable. Une adresse privée, une boucle locale ou une adresse de lien local ne reçoit aucune livraison. |
| `events` | `string[]` | non | Les types d'événements que vous voulez recevoir. Chaque nom doit appartenir à la liste plus bas. |
| `secret` | `string` | non | Le secret partagé qui sert à signer les livraisons. 128 caractères au maximum. |

Tout autre champ fait échouer la requête en 422.

> [!DANGER] Déclarez toujours `events` et `secret`
> `events` est facultatif au sens technique. Un abonnement créé sans lui porte
> une liste vide, apparaît dans vos listes, annonce `health: "healthy"` et ne
> reçoit jamais rien. Un abonnement créé sans `secret` reçoit des livraisons
> sans en-tête `X-Webhook-Signature` : vous n'avez alors aucun moyen de savoir
> qui vous écrit.

:::onglets
```bash title="curl"
curl -X POST https://api.sealtrust.io/v1/partner/webhooks \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://exemple-sas.example/webhooks/sealtrust",
    "events": ["product.scanned", "transfer.accepted"],
    "secret": "secret-de-demonstration-a-remplacer"
  }'
```
```typescript
import { SealTrustClient } from "@sealtrust-io/sdk";

const sealtrust = new SealTrustClient({
  apiKey: "st_test_0000000000000000000000000000000000000000000000",
});

const abonnement = await sealtrust.webhooks.create({
  url: "https://exemple-sas.example/webhooks/sealtrust",
  events: ["product.scanned", "transfer.accepted"],
  secret: "secret-de-demonstration-a-remplacer",
});

console.log(abonnement.id, abonnement.health);
```
```python
import requests

reponse = requests.post(
    "https://api.sealtrust.io/v1/partner/webhooks",
    headers={
        "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
    },
    json={
        "url": "https://exemple-sas.example/webhooks/sealtrust",
        "events": ["product.scanned", "transfer.accepted"],
        "secret": "secret-de-demonstration-a-remplacer",
    },
    timeout=30,
)
reponse.raise_for_status()
print(reponse.json())
```
:::

La réponse est un `201` et vous rend l'abonnement enregistré.

```json
{
  "id": 1,
  "brand_id": 1,
  "url": "https://exemple-sas.example/webhooks/sealtrust",
  "events": ["product.scanned", "transfer.accepted"],
  "is_active": true,
  "health": "healthy",
  "created_at": "2026-08-20T09:00:00+00:00",
  "updated_at": "2026-08-20T09:00:00+00:00"
}
```

Le secret ne figure pas dans cette réponse, et aucun point d'entrée ne le rend
ensuite. Conservez-le de votre côté au moment où vous l'inventez.

## Vérifier la signature

La signature vaut `HMAC-SHA256`, calculé avec votre secret, sur la chaîne
formée de l'horodatage, d'un point, puis du corps de la requête. En clair :
`<horodatage>.<corps>`. L'empreinte est écrite en hexadécimal minuscule dans la
partie `v1=` de l'en-tête `X-Webhook-Signature`, et l'horodatage utilisé pour
la calculer est dans la partie `t=`.

> [!DANGER] Signez les octets exacts que vous avez reçus
> Le corps signé est exactement la suite d'octets qui circule sur le réseau.
> Beaucoup de cadriciels analysent le JSON avant de vous le passer. Si vous
> re-sérialisez cet objet pour calculer l'empreinte, l'ordre des clefs et les
> espaces changent, et la vérification échoue toujours. Lisez le corps brut.

Voici un récepteur complet. Il vérifie l'âge de l'horodatage, vérifie la
signature en temps constant, ignore les doublons à partir du couple
`X-Webhook-Event` et `X-Webhook-Id`, répond immédiatement, puis traite.

:::onglets
```typescript title="Node.js, Express"
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.SEALTRUST_WEBHOOK_SECRET ?? "";
const TOLERANCE_SECONDES = 300;

const app = express();
const dejaVus = new Set<string>();

// express.raw laisse le corps sous forme d'octets. C'est ce qui est signé.
app.post(
  "/webhooks/sealtrust",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const entete = req.get("X-Webhook-Signature") ?? "";
    const parties = new Map<string, string>();
    for (const morceau of entete.split(",")) {
      const separateur = morceau.indexOf("=");
      if (separateur > 0) {
        parties.set(
          morceau.slice(0, separateur).trim(),
          morceau.slice(separateur + 1).trim(),
        );
      }
    }

    const horodatage = parties.get("t");
    const recue = parties.get("v1");
    if (!horodatage || !recue) {
      return res.status(400).send("signature absente");
    }

    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(horodatage));
    if (!Number.isFinite(age) || age > TOLERANCE_SECONDES) {
      return res.status(400).send("horodatage hors fenêtre");
    }

    const attendue = crypto
      .createHmac("sha256", SECRET)
      .update(`${horodatage}.`)
      .update(req.body as Buffer)
      .digest("hex");

    const a = Buffer.from(attendue, "hex");
    const b = Buffer.from(recue, "hex");
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(400).send("signature invalide");
    }

    // Répondre avant de traiter : la livraison expire au bout de 10 secondes.
    res.status(200).send("ok");

    // Dédoublonnage sur le couple nom d'événement + identifiant.
    const evenement = req.get("X-Webhook-Event") ?? "";
    const identifiant = req.get("X-Webhook-Id") ?? "";
    const cle = `${evenement}:${identifiant}`;
    if (dejaVus.has(cle)) {
      return;
    }
    dejaVus.add(cle);

    const corps = JSON.parse((req.body as Buffer).toString("utf8"));
    console.log("événement reçu", evenement, corps);
  },
);

app.listen(8080);
```
```python title="Python, Flask"
import hashlib
import hmac
import os
import time

from flask import Flask, request

SECRET = os.environ["SEALTRUST_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDES = 300

app = Flask(__name__)
deja_vus: set[tuple[str, str]] = set()


@app.post("/webhooks/sealtrust")
def recevoir():
    entete = request.headers.get("X-Webhook-Signature", "")
    parties = {}
    for morceau in entete.split(","):
        if "=" in morceau:
            nom, valeur = morceau.split("=", 1)
            parties[nom.strip()] = valeur.strip()

    horodatage = parties.get("t")
    recue = parties.get("v1")
    if not horodatage or not recue:
        return "signature absente", 400

    try:
        age = abs(int(time.time()) - int(horodatage))
    except ValueError:
        return "horodatage illisible", 400
    if age > TOLERANCE_SECONDES:
        return "horodatage hors fenêtre", 400

    # get_data() rend les octets bruts. C'est ce qui est signé.
    corps = request.get_data()
    attendue = hmac.new(
        SECRET,
        horodatage.encode() + b"." + corps,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(attendue, recue):
        return "signature invalide", 400

    # Dédoublonnage sur le couple nom d'événement + identifiant.
    evenement = request.headers.get("X-Webhook-Event", "")
    identifiant = request.headers.get("X-Webhook-Id", "")
    cle = (evenement, identifiant)
    if cle in deja_vus:
        return "", 200
    deja_vus.add(cle)

    charge = request.get_json(force=True)
    print("événement reçu", evenement, charge)
    return "", 200


if __name__ == "__main__":
    app.run(port=8080)
```
:::

Le `Set` et le `set` de ces exemples sont en mémoire. En production, gardez
les identifiants déjà traités dans votre base, avec une durée de conservation
d'au moins une journée.

> [!ATTENTION] Deux événements au corps identique portent le même identifiant
> `X-Webhook-Id` se calcule à partir du corps. Plusieurs événements de cycle de
> vie n'ont qu'un seul champ, `product_id`. Deux événements différents portant
> exactement le même corps reçoivent donc le même identifiant. Dédoublonnez sur
> le couple formé du nom d'événement et de l'identifiant, ou déclarez une
> adresse par type d'événement.

## Les événements

Nous envoyons dix-sept types d'événements aujourd'hui. Le corps de chaque
livraison contient exactement les champs listés ci-dessous. Un champ peut valoir
`null` quand la valeur n'est pas connue.

### Cycle de vie du produit

| Événement | Envoyé quand | Champs du corps |
| --- | --- | --- |
| `product.minted` | Un produit est enregistré depuis la console, un par un. Une frappe en lot par l'API partenaire ne déclenche pas cet événement : suivez son avancement avec [`GET /partner/mint/batch/status/{job_id}`](/reference/get-partner-mint-batch-status/). | `product_id`, `product_name` |
| `product.transferred` | Un transfert de propriété effectué par nos soins aboutit sur la chaîne. Un transfert que le détenteur signe lui-même depuis son propre portefeuille ne déclenche pas cet événement. | `product_id`, `from`, `to` |
| `transfer.accepted` | Le destinataire d'un transfert sous séquestre l'accepte. | `acceptance_id`, `product_id`, `product_name`, `token_id`, `contract_address`, `tx_hash` |

### Scans et sécurité

| Événement | Envoyé quand | Champs du corps |
| --- | --- | --- |
| `product.scanned` | Un scan aboutit sur un de vos produits. | `product_id`, `product_name`, `uid_hash`, `token_id`, `country`, `city`, `ctr`, `source`, `sdm_verified`, `scanned_at` |
| `product.gray_market` | Un scan a lieu hors de la zone que vous avez autorisée. | `product_id`, `product_name`, `country`, `city`, `authorized_countries`, `source`, `nfc_auth_log_id`, `retailer_id` |
| `clone.alert` | Nous détectons une duplication sur un identifiant de puce. | `uid_hash`, `severity` |

Dans `product.scanned`, `source` vaut `"qr"` ou `"nfc"`. `sdm_verified` vaut
`true` uniquement quand `source` vaut `"nfc"`. Ce champ rend compte d'une chose
précise : la puce NTAG 424 a signé cette lecture-là avec sa propre clef. Un
scan QR est vérifié lui aussi, par la signature portée par le lien, et il vaut
un scan de plein droit. `ctr` est le compteur de lecture rendu par la puce.

### Retours et garantie

| Événement | Envoyé quand | Champs du corps |
| --- | --- | --- |
| `return.requested` | Un retour est demandé. | `product_id`, `stage` |
| `return.received` | Le produit retourné vous est parvenu. | `product_id` |
| `return.completed` | Le retour est soldé. | `product_id`, `tx_hash` |
| `return.rejected` | Le retour est refusé. | `product_id`, `reason` |
| `return.expired` | La demande de retour a expiré sans suite. | `product_id` |
| `warranty.claimed` | Une garantie est actionnée. | `product_id` |

### Rachat

| Événement | Envoyé quand | Champs du corps |
| --- | --- | --- |
| `buyback.offered` | Vous proposez de racheter un produit. | `product_id`, `amount_cents`, `currency` |
| `buyback.accepted` | Le détenteur accepte la proposition. | `product_id` |
| `buyback.declined` | Le détenteur refuse la proposition. | `product_id`, `reason` |
| `buyback.completed` | Le rachat est soldé. | `product_id`, `tx_hash` |
| `buyback.expired` | La proposition a expiré sans réponse. | `product_id` |

### Six noms acceptés qui ne déclenchent rien

L'abonnement accepte aussi `product.burned`, `product.status_changed`,
`batch.completed`, `batch.failed`, `warranty.expiring_soon` et
`certificate.issued`. Nous n'envoyons aucune de ces six valeurs aujourd'hui.
Vous abonner à l'une d'elles ne produit aucune erreur et ne produit aucune
livraison. Pour suivre l'avancement d'une frappe en lot, interrogez
[`GET /partner/mint/batch/status/{job_id}`](/reference/get-partner-mint-batch-status/).

## La politique de réessai

Nous retentons cinq fois une livraison qui échoue, ce qui fait six tentatives
au total. Les délais sont fixes.

| Tentative | Délai après l'échec précédent | Écart cumulé depuis la première tentative |
| --- | --- | --- |
| 1 | immédiate | 0 |
| 2 | 30 secondes | 30 secondes |
| 3 | 2 minutes | 2 minutes 30 |
| 4 | 10 minutes | 12 minutes 30 |
| 5 | 1 heure | 1 heure 12 minutes 30 |
| 6 | 6 heures | 7 heures 12 minutes 30 |

Une tentative échoue dans trois cas : votre serveur répond un code hors de la
plage 200 à 299, votre serveur ne répond pas dans les dix secondes, ou la
connexion ne s'établit pas.

Chaque réessai porte un en-tête `X-Webhook-Timestamp` nouveau et une signature
recalculée sur ce nouvel horodatage. L'en-tête `X-Webhook-Id` reste le même.
C'est ce qui vous permet de reconnaître un renvoi.

> [!ATTENTION] Un réessai déjà programmé garde l'adresse et le secret d'origine
> Si vous changez l'adresse ou le secret de l'abonnement pendant qu'un réessai
> attend son tour, ce réessai part vers l'ancienne adresse et reste signé avec
> l'ancien secret. Les événements suivants, eux, utilisent la nouvelle
> configuration. Prévoyez une période où votre récepteur accepte les deux
> secrets.

Quand la sixième tentative échoue, l'abonnement passe en santé `degraded`. Il
reste actif : les événements suivants continuent d'être envoyés. La santé
revient à `healthy` dès la première livraison réussie.

## Quand votre serveur était indisponible

Commencez par regarder la durée de la panne.

**Moins de sept heures.** Les réessais couvrent la période. La sixième et
dernière tentative d'un événement survient au moins sept heures et douze
minutes après son premier échec. Nous renvoyons encore tout événement dont le
premier échec est plus récent que cela. Vérifiez la santé de l'abonnement, puis
attendez.

**Plus de sept heures.** Nous ne renvoyons plus les événements dont les six
tentatives sont épuisées. Il n'existe pas de rejeu en masse depuis l'API. Vous
avez deux recours : le renvoi livraison par livraison depuis la console, décrit
plus bas, et le rattrapage de l'état par une lecture.

Pour connaître l'état d'un abonnement, lisez-le.

:::onglets
```bash title="curl"
curl https://api.sealtrust.io/v1/partner/webhooks/1 \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000"
```
```typescript
import { SealTrustClient } from "@sealtrust-io/sdk";

const sealtrust = new SealTrustClient({
  apiKey: "st_test_0000000000000000000000000000000000000000000000",
});

const abonnement = await sealtrust.webhooks.get(1);
console.log(abonnement.health, abonnement.is_active);
```
```python
import requests

reponse = requests.get(
    "https://api.sealtrust.io/v1/partner/webhooks/1",
    headers={
        "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
    },
    timeout=30,
)
reponse.raise_for_status()
print(reponse.json()["health"], reponse.json()["is_active"])
```
:::

Une santé `degraded` vous dit qu'au moins un événement a épuisé ses six
tentatives. Elle ne vous dit pas lesquels. La console de votre marque, dans
Réglages puis Développeurs, affiche le journal des livraisons de vos abonnements,
avec pour chaque tentative sa date, son type d'événement, son état, le code de
réponse reçu, le numéro de tentative et la durée. Ce journal vous donne la
liste exacte des événements à rattraper.

Chaque ligne du journal porte un bouton de renvoi. Il reprogramme cette
livraison précise. Ce renvoi n'est pas immédiat. Il reprend le délai de la
tentative suivante : 30 secondes après une première tentative échouée, et
jusqu'à 6 heures après une cinquième. Le journal affiche la nouvelle tentative
dès le clic, son résultat arrive au terme de ce délai. Le bouton refuse une
livraison déjà réussie. Il refuse une livraison qui a déjà épuisé ses six
tentatives. Il refuse aussi une livraison ancienne dont le contenu n'a pas été
conservé : déclenchez alors un nouvel événement.

Trois habitudes réduisent le coût d'une panne.

1. **Répondez avant de traiter.** Un `200` immédiat suivi d'un traitement en
   file locale supprime les échecs dus à une lenteur passagère chez vous.
2. **Dédoublonnez sur le couple `X-Webhook-Event` et `X-Webhook-Id`.** Un
   traitement qui supporte de recevoir deux fois le même événement vous
   autorise à rejouer votre propre file sans précaution.
3. **Surveillez la santé.** Une lecture quotidienne de vos abonnements suffit à
   détecter un point d'entrée qui ne répond plus.

> [!ATTENTION] Rien n'est livré pendant une interruption de votre offre
> Si votre marque perd l'offre qui comprend les notifications, nous n'envoyons
> plus aucun événement, même si vos abonnements existent toujours et sont
> actifs. Ils restent lisibles et supprimables. Aucun événement survenu pendant
> cette période n'est rattrapé ensuite.

## Gérer vos abonnements

Les cinq opérations sont sous `/v1/partner/webhooks`. La lecture demande le
droit `webhooks:read`, l'écriture demande `webhooks:write`. Aucune de ces cinq
routes ne consomme le quota journalier de votre clef.

| Opération | Appel | Droit |
| --- | --- | --- |
| Créer | [`POST /v1/partner/webhooks`](/reference/post-partner-webhooks/) | `webhooks:write` |
| Lister | [`GET /v1/partner/webhooks`](/reference/get-partner-webhooks/) | `webhooks:read` |
| Lire un abonnement | [`GET /v1/partner/webhooks/{webhook_id}`](/reference/get-partner-webhooks-id/) | `webhooks:read` |
| Modifier | [`PUT /v1/partner/webhooks/{webhook_id}`](/reference/put-partner-webhooks-id/) | `webhooks:write` |
| Supprimer | [`DELETE /v1/partner/webhooks/{webhook_id}`](/reference/delete-partner-webhooks-id/) | `webhooks:write` |

La liste se pagine avec `skip`, à partir de 0, et `limit`, entre 1 et 100, 20
par défaut. Elle rend `total` et `items`.

> [!ATTENTION] La liste et la lecture unitaire ne nomment pas ce champ pareil
> Dans `GET /v1/partner/webhooks`, chaque élément porte la liste des
> événements sous le nom `event_types`. Dans la création, la lecture unitaire
> et la modification, la même liste s'appelle `events`. Traitez les deux noms
> dans votre code.

### Changer le secret ou les événements

La modification prend les mêmes champs que la création, plus `is_active`. Tous
sont facultatifs, et seuls les champs envoyés sont modifiés.

:::onglets
```bash title="curl"
curl -X PUT https://api.sealtrust.io/v1/partner/webhooks/1 \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["product.scanned", "transfer.accepted", "clone.alert"],
    "secret": "nouveau-secret-de-demonstration"
  }'
```
```typescript
import { SealTrustClient } from "@sealtrust-io/sdk";

const sealtrust = new SealTrustClient({
  apiKey: "st_test_0000000000000000000000000000000000000000000000",
});

const abonnement = await sealtrust.webhooks.update(1, {
  events: ["product.scanned", "transfer.accepted", "clone.alert"],
  secret: "nouveau-secret-de-demonstration",
});

console.log(abonnement.events);
```
```python
import requests

reponse = requests.put(
    "https://api.sealtrust.io/v1/partner/webhooks/1",
    headers={
        "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
    },
    json={
        "events": ["product.scanned", "transfer.accepted", "clone.alert"],
        "secret": "nouveau-secret-de-demonstration",
    },
    timeout=30,
)
reponse.raise_for_status()
print(reponse.json()["events"])
```
:::

Le nouveau secret signe les événements suivants. Faites accepter les deux
secrets par votre récepteur pendant la bascule.

### Éteindre un abonnement

Envoyez `{"is_active": false}` et rien d'autre. Cette extinction reste
acceptée même si votre offre ne comprend plus les notifications. Dès qu'un
autre champ accompagne `is_active`, l'appel devient une modification et l'offre
est de nouveau exigée.

### Supprimer un abonnement

La suppression exige un paramètre de requête `confirm` qui contient l'adresse
exacte de l'abonnement, telle que la lecture unitaire vous la rend. Lisez
l'abonnement, puis renvoyez son adresse. Un identifiant seul est refusé.

:::onglets
```bash title="curl"
curl -X DELETE "https://api.sealtrust.io/v1/partner/webhooks/1?confirm=https%3A%2F%2Fexemple-sas.example%2Fwebhooks%2Fsealtrust" \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000"
```
```typescript
import { SealTrustClient } from "@sealtrust-io/sdk";

const sealtrust = new SealTrustClient({
  apiKey: "st_test_0000000000000000000000000000000000000000000000",
});

const abonnement = await sealtrust.webhooks.get(1);
await sealtrust.webhooks.delete(abonnement.id, abonnement.url);
```
```python
import requests

entetes = {
    "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
}

lecture = requests.get(
    "https://api.sealtrust.io/v1/partner/webhooks/1",
    headers=entetes,
    timeout=30,
)
lecture.raise_for_status()

suppression = requests.delete(
    "https://api.sealtrust.io/v1/partner/webhooks/1",
    headers=entetes,
    params={"confirm": lecture.json()["url"]},
    timeout=30,
)
suppression.raise_for_status()
```
:::

La réponse est un `204` sans corps.

> [!DANGER] La suppression détruit le secret de signature
> L'abonnement cesse de recevoir des événements et son secret est détruit. Un
> nouvel abonnement déclaré pour la même adresse reçoit un secret différent.
> Votre récepteur doit alors être reconfiguré. Aucune annulation n'est
> possible. Pour arrêter temporairement les livraisons, préférez l'extinction
> décrite plus haut.

## Erreurs

| Code | Condition | Que faire |
| --- | --- | --- |
| `400` | Suppression sans paramètre `confirm`. Le détail porte le code `CONFIRMATION_REQUIRED`. | Lisez l'abonnement et renvoyez son adresse dans `confirm`. |
| `400` | Le `confirm` envoyé ne correspond pas à l'adresse enregistrée. Le détail porte le code `CONFIRMATION_MISMATCH`. | Rien n'a été supprimé. Vérifiez que l'identifiant désigne bien l'abonnement que vous croyez. |
| `401` | En-tête `Authorization` absent ou mal formé. | Envoyez `Authorization: Bearer <votre clef>`. La réponse porte `WWW-Authenticate: Bearer`. |
| `401` | Clef inconnue, ou jeton de moins de 40 caractères. | Vérifiez la clef. Cette réponse ne porte pas d'en-tête `WWW-Authenticate`. |
| `403` | La clef est révoquée ou expirée. Le détail nomme l'état. | Créez une nouvelle clef depuis la console de votre marque. |
| `403` | Droit manquant. Le détail vaut `Missing required scope: webhooks:read` ou `Missing required scope: webhooks:write`. | Utilisez une clef portant ce droit. Les droits d'une clef se choisissent à sa création. |
| `403` | Le détail porte `{"code": "FEATURE_NOT_AVAILABLE", "feature": "webhooks"}`. | Votre offre ne comprend pas les notifications. La lecture, la suppression et l'extinction seule restent ouvertes. |
| `404` | `Webhook subscription not found`. | L'identifiant n'existe pas, ou il appartient à une autre marque. Les deux cas rendent la même réponse. |
| `422` | Adresse qui ne commence pas par `https://`, nom d'événement hors de la liste, champ inconnu dans le corps, ou `limit` supérieur à 100. | Le corps de la réponse détaille chaque champ fautif. |
| `429` | Plafond d'appels atteint. | La réponse porte `Retry-After` en secondes, ainsi que `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` et `X-RateLimit-Scope`. Attendez le délai indiqué. |
| `503` | Le contrôle du plafond d'appels est momentanément indisponible. | Aucune modification n'a eu lieu, y compris pour une suppression. Réessayez dans quelques instants. |

> [!INFO] Le plafond d'appels s'applique aussi à vos marques entières
> Deux compteurs se superposent : un par clef, un pour la somme des clefs de
> votre marque, avec le même plafond. Créer des clefs supplémentaires
> n'augmente donc pas le débit total autorisé. `X-RateLimit-Scope` vous dit
> lequel des deux compteurs a parlé.
