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

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.

HeaderContent
X-Webhook-EventThe name of the event, for example product.scanned.
X-Webhook-IdAn 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-TimestampThe send date of this attempt, in seconds since January 1, 1970. It changes on every retry.
X-Webhook-SignatureThe 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.

FieldTypeRequiredDescription
urlstringyesAddress 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.
eventsstring[]noThe event types you want to receive. Each name must belong to the list further down.
secretstringnoThe 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"
  }'

The response is a 201 and returns the recorded subscription to you.

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

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

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

EventSent whenBody fields
product.mintedA 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.transferredAn 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.acceptedThe recipient of an escrowed transfer accepts it.acceptance_id, product_id, product_name, token_id, contract_address, tx_hash

#Scans and security

EventSent whenBody fields
product.scannedA 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_marketA scan takes place outside the zone you authorized.product_id, product_name, country, city, authorized_countries, source, nfc_auth_log_id, retailer_id
clone.alertWe 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

EventSent whenBody fields
return.requestedA return is requested.product_id, stage
return.receivedThe returned product has reached you.product_id
return.completedThe return is settled.product_id, tx_hash
return.rejectedThe return is refused.product_id, reason
return.expiredThe return request expired with no follow-up.product_id
warranty.claimedA warranty is invoked.product_id

#Buyback

EventSent whenBody fields
buyback.offeredYou offer to buy a product back.product_id, amount_cents, currency
buyback.acceptedThe holder accepts the offer.product_id
buyback.declinedThe holder refuses the offer.product_id, reason
buyback.completedThe buyback is settled.product_id, tx_hash
buyback.expiredThe 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.

AttemptDelay after the previous failureCumulative gap since the first attempt
1immediate0
230 seconds30 seconds
32 minutes2 minutes 30
410 minutes12 minutes 30
51 hour1 hour 12 minutes 30
66 hours7 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.

#When your server was unavailable

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"

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.

  1. Answer before handling. An immediate 200 followed by handling in a local queue removes the failures caused by a passing slowness on your side.
  2. Deduplicate on the pair X-Webhook-Event and X-Webhook-Id. Handling that tolerates receiving the same event twice lets you replay your own queue with no precaution.
  3. 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.

OperationCallRight
CreatePOST /v1/partner/webhookswebhooks:write
ListGET /v1/partner/webhookswebhooks:read
Read one subscriptionGET /v1/partner/webhooks/{webhook_id}webhooks:read
ModifyPUT /v1/partner/webhooks/{webhook_id}webhooks:write
DeleteDELETE /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"
  }'

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"

The response is a 204 with no body.

#Errors

CodeConditionWhat to do
400Deletion with no confirm parameter. The detail carries the code CONFIRMATION_REQUIRED.Read the subscription and send its address back in confirm.
400The 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.
401Authorization header absent or malformed.Send Authorization: Bearer <your key>. The response carries WWW-Authenticate: Bearer.
401Unknown key, or token shorter than 40 characters.Check the key. This response carries no WWW-Authenticate header.
403The key is revoked or expired. The detail names the state.Create a new key from your brand's console.
403Missing 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.
403The detail carries {"code": "FEATURE_NOT_AVAILABLE", "feature": "webhooks"}.Your plan does not include notifications. Reading, deletion and turning off alone stay open.
404Webhook subscription not found.The identifier does not exist, or it belongs to another brand. Both cases return the same response.
422Address 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.
429Call 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.
503The call limit check is momentarily unavailable.No modification took place, including for a deletion. Try again in a few moments.

Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.

Suggest a correctionReport a problem