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
- Ce que vous recevez
- Mettre en place un abonnement
- Vérifier la signature
- Les événements
- Cycle de vie du produit
- Scans et sécurité
- Retours et garantie
- Rachat
- Six noms acceptés qui ne déclenchent rien
- La politique de réessai
- Quand votre serveur était indisponible
- Gérer vos abonnements
- Changer le secret ou les événements
- Éteindre un abonnement
- Supprimer un abonnement
- Erreurs
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. |
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.
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"
}'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);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é.
{
"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);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.
#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}. | 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}.
#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.
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"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);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.
- Répondez avant de traiter. Un
200immédiat suivi d'un traitement en file locale supprime les échecs dus à une lenteur passagère chez vous. - Dédoublonnez sur le couple
X-Webhook-EventetX-Webhook-Id. Un traitement qui supporte de recevoir deux fois le même événement vous autorise à rejouer votre propre file sans précaution. - 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ération | Appel | Droit |
|---|---|---|
| Créer | POST /v1/partner/webhooks | webhooks:write |
| Lister | GET /v1/partner/webhooks | webhooks:read |
| Lire un abonnement | GET /v1/partner/webhooks/{webhook_id} | webhooks:read |
| Modifier | PUT /v1/partner/webhooks/{webhook_id} | webhooks:write |
| Supprimer | DELETE /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"
}'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);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é.
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"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);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.
#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. |
Cette page vous a-t-elle été utile ?
Votre réponse ouvre un courriel pré-rempli dans votre messagerie, à destination de contact@sealtrust.io. Vous le relisez avant de l'envoyer.