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

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

---

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

> [!ATTENTION] `X-Webhook-Event` is not covered by the signature
> The signature protects the timestamp and the body. It does not protect the
> name of the event. If your handling has to act differently depending on the
> event type and that decision commits something important, declare a separate
> address per event type. Each address then receives only one type of event,
> without depending on an unsigned header.

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.

> [!DANGER] Always declare `events` and `secret`
> `events` is optional in the technical sense. A subscription created without it
> carries an empty list, appears in your listings, announces `health: "healthy"`
> and never receives anything. A subscription created without a `secret`
> receives deliveries with no `X-Webhook-Signature` header: you then have no way
> of knowing who is writing to you.

:::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())
```
:::

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.

> [!DANGER] Sign the exact bytes you received
> The signed body is exactly the sequence of bytes that travels over the
> network. Many frameworks parse the JSON before passing it to you. If you
> re-serialize that object to compute the hash, the order of the keys and the
> whitespace change, and verification always fails. Read the raw body.

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.

:::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 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);
```
```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() 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.

> [!ATTENTION] Two events with an identical body carry the same identifier
> `X-Webhook-Id` is computed from the body. Several lifecycle events have only
> one field, `product_id`. Two different events carrying exactly the same body
> therefore receive the same identifier. Deduplicate on the pair formed by the
> event name and the identifier, or declare one address per event type.

## 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}`](/en/reference/get-partner-mint-batch-status/). | `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}`](/en/reference/get-partner-mint-batch-status/).

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

> [!ATTENTION] A retry already scheduled keeps the original address and secret
> If you change the address or the secret of the subscription while a retry is
> waiting its turn, that retry goes to the old address and stays signed with the
> old secret. The following events, for their part, use the new configuration.
> Plan for a period where your receiver accepts both secrets.

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.

:::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"])
```
:::

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.

> [!ATTENTION] Nothing is delivered during an interruption of your plan
> If your brand loses the plan that includes notifications, we no longer send
> any event, even if your subscriptions still exist and are active. They stay
> readable and deletable. No event that occurred during that period is caught up
> afterwards.

## 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`](/en/reference/post-partner-webhooks/) | `webhooks:write` |
| List | [`GET /v1/partner/webhooks`](/en/reference/get-partner-webhooks/) | `webhooks:read` |
| Read one subscription | [`GET /v1/partner/webhooks/{webhook_id}`](/en/reference/get-partner-webhooks-id/) | `webhooks:read` |
| Modify | [`PUT /v1/partner/webhooks/{webhook_id}`](/en/reference/put-partner-webhooks-id/) | `webhooks:write` |
| Delete | [`DELETE /v1/partner/webhooks/{webhook_id}`](/en/reference/delete-partner-webhooks-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`.

> [!ATTENTION] The list and the single read do not name this field the same way
> In `GET /v1/partner/webhooks`, each element carries the list of events under
> the name `event_types`. In the creation, the single read and the modification,
> the same list is called `events`. Handle both names in your code.

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

:::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"])
```
:::

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.

:::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()
```
:::

The response is a `204` with no body.

> [!DANGER] Deletion destroys the signing secret
> The subscription stops receiving events and its secret is destroyed. A new
> subscription declared for the same address receives a different secret. Your
> receiver must then be reconfigured. No undo is possible. To stop deliveries
> temporarily, prefer the turning off described above.

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

> [!INFO] The call limit also applies to your whole brands
> Two counters stack: one per key, one for the sum of your brand's keys, with
> the same ceiling. Creating additional keys therefore does not increase the
> total allowed throughput. `X-RateLimit-Scope` tells you which of the two
> counters spoke.
