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.

Sur cette page

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êteContenu
X-Webhook-EventLe nom de l'événement, par exemple product.scanned.
X-Webhook-IdUn 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-TimestampLa date d'envoi de cette tentative, en secondes depuis le 1er janvier 1970. Elle change à chaque réessai.
X-Webhook-SignatureLa signature du corps, au format t=<horodatage>,v1=<empreinte>. Présente uniquement si vous avez déclaré un secret.

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.

ChampTypeObligatoireDescription
urlstringouiAdresse 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.
eventsstring[]nonLes types d'événements que vous voulez recevoir. Chaque nom doit appartenir à la liste plus bas.
secretstringnonLe secret partagé qui sert à signer les livraisons. 128 caractères au maximum.

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

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"
  }'

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=.

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.

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);

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.

#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énementEnvoyé quandChamps du corps
product.mintedUn 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}.product_id, product_name
product.transferredUn 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.acceptedLe 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énementEnvoyé quandChamps du corps
product.scannedUn 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_marketUn 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.alertNous 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énementEnvoyé quandChamps du corps
return.requestedUn retour est demandé.product_id, stage
return.receivedLe produit retourné vous est parvenu.product_id
return.completedLe retour est soldé.product_id, tx_hash
return.rejectedLe retour est refusé.product_id, reason
return.expiredLa demande de retour a expiré sans suite.product_id
warranty.claimedUne garantie est actionnée.product_id

#Rachat

ÉvénementEnvoyé quandChamps du corps
buyback.offeredVous proposez de racheter un produit.product_id, amount_cents, currency
buyback.acceptedLe détenteur accepte la proposition.product_id
buyback.declinedLe détenteur refuse la proposition.product_id, reason
buyback.completedLe rachat est soldé.product_id, tx_hash
buyback.expiredLa 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}.

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

TentativeDélai après l'échec précédentÉcart cumulé depuis la première tentative
1immédiate0
230 secondes30 secondes
32 minutes2 minutes 30
410 minutes12 minutes 30
51 heure1 heure 12 minutes 30
66 heures7 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.

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.

curl https://api.sealtrust.io/v1/partner/webhooks/1 \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000"

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.

#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érationAppelDroit
CréerPOST /v1/partner/webhookswebhooks:write
ListerGET /v1/partner/webhookswebhooks:read
Lire un abonnementGET /v1/partner/webhooks/{webhook_id}webhooks:read
ModifierPUT /v1/partner/webhooks/{webhook_id}webhooks:write
SupprimerDELETE /v1/partner/webhooks/{webhook_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.

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

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"
  }'

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

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"

La réponse est un 204 sans corps.

#Erreurs

CodeConditionQue faire
400Suppression sans paramètre confirm. Le détail porte le code CONFIRMATION_REQUIRED.Lisez l'abonnement et renvoyez son adresse dans confirm.
400Le 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.
401En-tête Authorization absent ou mal formé.Envoyez Authorization: Bearer <votre clef>. La réponse porte WWW-Authenticate: Bearer.
401Clef 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.
403La 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.
403Droit 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.
403Le 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.
404Webhook subscription not found.L'identifiant n'existe pas, ou il appartient à une autre marque. Les deux cas rendent la même réponse.
422Adresse 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.
429Plafond 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é.
503Le 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.

Votre réponse ouvre un courriel pré-rempli dans votre messagerie, à destination de contact@sealtrust.io. Vous le relisez avant de l'envoyer.

Proposer une correctionSignaler un problème