Receive events by webhook
By the end of this page, you will know how to create a subscription, verify the signature of every delivery with code you can copy, read the retry policy and catch up on events lost while your server was unavailable.
On this page
- What you receive
- Set up a subscription
- Verify the signature
- The events
- Product lifecycle
- Scans and security
- Returns and warranty
- Buyback
- Six accepted names that trigger nothing
- The retry policy
- When your server was unavailable
- Manage your subscriptions
- Change the secret or the events
- Turn off a subscription
- Delete a subscription
- Errors
A webhook is a call our server makes to yours when something happens to one of your products. You have nothing to poll in a loop. By the end of this page, you will know how to create a subscription, verify the signature of every delivery with code you can copy, read the retry policy and know what to do when your server was unavailable.
The path has four steps: open an HTTPS endpoint on your side, declare that subscription, verify the signature on every reception, monitor the health of the subscription.
#What you receive
Every delivery is a POST request to the address you declared, with the header
Content-Type: application/json. The body is the content of the event, and
nothing else. The type of the event is in a header, outside the body.
Four headers accompany every delivery.
| Header | Content |
|---|---|
X-Webhook-Event | The name of the event, for example product.scanned. |
X-Webhook-Id | An identifier computed from the body. It does not change between the first attempt and the retries of the same event. Use it, together with the name of the event, to ignore a duplicate. |
X-Webhook-Timestamp | The send date of this attempt, in seconds since January 1, 1970. It changes on every retry. |
X-Webhook-Signature | The signature of the body, in the format t=<timestamp>,v1=<hash>. Present only if you declared a secret. |
Your server has ten seconds to answer. Any response whose HTTP code sits between 200 and 299 counts as a success. Everything else counts as a failure, including a redirection code in the 300 range, a 404 and a 500. Answer first, handle afterwards.
#Set up a subscription
You need two things. An API key of your brand carrying the webhooks:write
right, created from your brand's console, under Settings then Developers. And a
plan that includes webhook notifications. Without that plan, we refuse the
creation with a 403 and the code FEATURE_NOT_AVAILABLE.
You declare the subscription with three values.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Address of your endpoint. It must start with https://, be between 10 and 2048 characters long, and designate a publicly routable address. A private address, a local loopback or a link local address receives no delivery. |
events | string[] | no | The event types you want to receive. Each name must belong to the list further down. |
secret | string | no | The shared secret used to sign deliveries. 128 characters at most. |
Any other field makes the request fail with a 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())The response is a 201 and returns the recorded subscription to you.
{
"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"
}The secret does not appear in that response, and no endpoint returns it afterwards. Keep it on your side at the moment you invent it.
#Verify the signature
The signature is HMAC-SHA256, computed with your secret, over the string
formed by the timestamp, a period, then the body of the request. Plainly:
<timestamp>.<body>. The hash is written in lowercase hexadecimal in the v1=
part of the X-Webhook-Signature header, and the timestamp used to compute it
is in the t= part.
Here is a complete receiver. It checks the age of the timestamp, verifies the
signature in constant time, ignores duplicates based on the pair
X-Webhook-Event and X-Webhook-Id, answers immediately, then handles.
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 leaves the body in the form of bytes. That is what is signed.
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");
}
// Answer before handling: the delivery expires after 10 seconds.
res.status(200).send("ok");
// Deduplication on the pair event name + identifier.
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() returns the raw bytes. That is what is signed.
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
# Deduplication on the pair event name + identifier.
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)The Set and the set in these examples are in memory. In production, keep the
identifiers already handled in your database, with a retention of at least one
day.
#The events
We send seventeen event types today. The body of each delivery contains exactly
the fields listed below. A field can be null when the value is not known.
#Product lifecycle
| Event | Sent when | Body fields |
|---|---|---|
product.minted | A product is recorded from the console, one by one. A batch mint through the partner API does not trigger this event: follow its progress with GET /partner/mint/batch/status/{job_id}. | product_id, product_name |
product.transferred | An ownership transfer carried out by us lands on the chain. A transfer that the holder signs themselves from their own wallet does not trigger this event. | product_id, from, to |
transfer.accepted | The recipient of an escrowed transfer accepts it. | acceptance_id, product_id, product_name, token_id, contract_address, tx_hash |
#Scans and security
| Event | Sent when | Body fields |
|---|---|---|
product.scanned | A scan succeeds on one of your products. | product_id, product_name, uid_hash, token_id, country, city, ctr, source, sdm_verified, scanned_at |
product.gray_market | A scan takes place outside the zone you authorized. | product_id, product_name, country, city, authorized_countries, source, nfc_auth_log_id, retailer_id |
clone.alert | We detect a duplication on a chip identifier. | uid_hash, severity |
In product.scanned, source is "qr" or "nfc". sdm_verified is true
only when source is "nfc". That field accounts for one precise thing: the
NTAG 424 chip signed that particular read with its own key. A QR scan is
verified too, by the signature carried by the link, and it counts as a scan in
its own right. ctr is the read counter returned by the chip.
#Returns and warranty
| Event | Sent when | Body fields |
|---|---|---|
return.requested | A return is requested. | product_id, stage |
return.received | The returned product has reached you. | product_id |
return.completed | The return is settled. | product_id, tx_hash |
return.rejected | The return is refused. | product_id, reason |
return.expired | The return request expired with no follow-up. | product_id |
warranty.claimed | A warranty is invoked. | product_id |
#Buyback
| Event | Sent when | Body fields |
|---|---|---|
buyback.offered | You offer to buy a product back. | product_id, amount_cents, currency |
buyback.accepted | The holder accepts the offer. | product_id |
buyback.declined | The holder refuses the offer. | product_id, reason |
buyback.completed | The buyback is settled. | product_id, tx_hash |
buyback.expired | The offer expired with no answer. | product_id |
#Six accepted names that trigger nothing
The subscription also accepts product.burned, product.status_changed,
batch.completed, batch.failed, warranty.expiring_soon and
certificate.issued. We send none of these six values today. Subscribing to one
of them produces no error and produces no delivery. To follow the progress of a
batch mint, query
GET /partner/mint/batch/status/{job_id}.
#The retry policy
We retry a failed delivery five times, which makes six attempts in total. The delays are fixed.
| Attempt | Delay after the previous failure | Cumulative gap since the first attempt |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 30 seconds | 30 seconds |
| 3 | 2 minutes | 2 minutes 30 |
| 4 | 10 minutes | 12 minutes 30 |
| 5 | 1 hour | 1 hour 12 minutes 30 |
| 6 | 6 hours | 7 hours 12 minutes 30 |
An attempt fails in three cases: your server answers a code outside the 200 to 299 range, your server does not answer within ten seconds, or the connection does not get established.
Every retry carries a new X-Webhook-Timestamp header and a signature
recomputed on that new timestamp. The X-Webhook-Id header stays the same. That
is what lets you recognize a resend.
When the sixth attempt fails, the subscription moves to degraded health. It
stays active: the following events keep being sent. The health goes back to
healthy on the first successful delivery.
#
Start by looking at the duration of the outage.
Less than seven hours. The retries cover the period. The sixth and last attempt of an event happens at least seven hours and twelve minutes after its first failure. We still resend any event whose first failure is more recent than that. Check the health of the subscription, then wait.
More than seven hours. We no longer resend the events whose six attempts are exhausted. There is no bulk replay from the API. You have two recourses: the delivery by delivery resend from the console, described further down, and catching the state back up through a read.
To know the state of a subscription, read it.
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"])A degraded health tells you that at least one event has exhausted its six
attempts. It does not tell you which ones. Your brand's console, under Settings
then Developers, displays the delivery log of your subscriptions, with, for each
attempt, its date, its event type, its state, the response code received, the
attempt number and the duration. That log gives you the exact list of the events
to catch up on.
Every line of the log carries a resend button. It reschedules that precise delivery. That resend is not immediate. It takes back the delay of the next attempt: 30 seconds after a failed first attempt, and up to 6 hours after a fifth. The log displays the new attempt as soon as you click, its result arrives at the end of that delay. The button refuses a delivery that already succeeded. It refuses a delivery that has already exhausted its six attempts. It also refuses an old delivery whose content was not kept: trigger a new event in that case.
Three habits reduce the cost of an outage.
- Answer before handling. An immediate
200followed by handling in a local queue removes the failures caused by a passing slowness on your side. - Deduplicate on the pair
X-Webhook-EventandX-Webhook-Id. Handling that tolerates receiving the same event twice lets you replay your own queue with no precaution. - Monitor the health. A daily read of your subscriptions is enough to detect an endpoint that no longer answers.
#Manage your subscriptions
The five operations are under /v1/partner/webhooks. Reading requires the
webhooks:read right, writing requires webhooks:write. None of these five
routes consumes the daily quota of your key.
| Operation | Call | Right |
|---|---|---|
| Create | POST /v1/partner/webhooks | webhooks:write |
| List | GET /v1/partner/webhooks | webhooks:read |
| Read one subscription | GET /v1/partner/webhooks/{webhook_id} | webhooks:read |
| Modify | PUT /v1/partner/webhooks/{webhook_id} | webhooks:write |
| Delete | DELETE /v1/partner/webhooks/{webhook_id} | webhooks:write |
The list is paginated with skip, starting from 0, and limit, between 1 and
100, 20 by default. It returns total and items.
#Change the secret or the events
The modification takes the same fields as the creation, plus is_active. All
are optional, and only the fields sent are modified.
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"])The new secret signs the following events. Have your receiver accept both secrets during the switch.
#Turn off a subscription
Send {"is_active": false} and nothing else. That turning off stays accepted
even if your plan no longer includes notifications. As soon as another field
accompanies is_active, the call becomes a modification and the plan is
required again.
#Delete a subscription
Deletion requires a confirm query parameter that contains the exact address of
the subscription, as the single read returns it to you. Read the subscription,
then send its address back. An identifier alone is refused.
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()The response is a 204 with no body.
#Errors
| Code | Condition | What to do |
|---|---|---|
400 | Deletion with no confirm parameter. The detail carries the code CONFIRMATION_REQUIRED. | Read the subscription and send its address back in confirm. |
400 | The confirm sent does not match the recorded address. The detail carries the code CONFIRMATION_MISMATCH. | Nothing was deleted. Check that the identifier really designates the subscription you think it does. |
401 | Authorization header absent or malformed. | Send Authorization: Bearer <your key>. The response carries WWW-Authenticate: Bearer. |
401 | Unknown key, or token shorter than 40 characters. | Check the key. This response carries no WWW-Authenticate header. |
403 | The key is revoked or expired. The detail names the state. | Create a new key from your brand's console. |
403 | Missing right. The detail is Missing required scope: webhooks:read or Missing required scope: webhooks:write. | Use a key carrying that right. The rights of a key are chosen when it is created. |
403 | The detail carries {"code": "FEATURE_NOT_AVAILABLE", "feature": "webhooks"}. | Your plan does not include notifications. Reading, deletion and turning off alone stay open. |
404 | Webhook subscription not found. | The identifier does not exist, or it belongs to another brand. Both cases return the same response. |
422 | Address that does not start with https://, event name outside the list, unknown field in the body, or limit greater than 100. | The body of the response details each faulty field. |
429 | Call limit reached. | The response carries Retry-After in seconds, as well as X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and X-RateLimit-Scope. Wait for the indicated delay. |
503 | The call limit check is momentarily unavailable. | No modification took place, including for a deletion. Try again in a few moments. |
Was this page helpful?
Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.