# POST /partner/webhooks

Register an HTTPS address that will receive the events of your brand, and choose the event types sent to that address. Scope webhooks:write.

Source: https://docs.sealtrust.io/en/reference/post-partner-webhooks/

---

You register an HTTPS address that will receive the events of your brand,
and you choose the event types sent to that address.
The subscription belongs to the brand of the key that calls. By the end of this
page, you will know which event types actually go out today,
how to verify the signature of a delivery, and which response your
integration receives at every refusal.

Full address:

```http
POST https://api.sealtrust.io/v1/partner/webhooks
```

The same endpoint also answers without the `/v1` prefix, at
`https://api.sealtrust.io/partner/webhooks`. The two addresses call the
same code and both are permanent. Use the `/v1` form for a
new integration.

## Authorization

API key in the `Authorization` header, in the `Bearer` format followed by a space
then the key.

| What is required | Value |
| --- | --- |
| Authentication | API key of your brand |
| Scope carried by the key | `webhooks:write` |
| Feature of your plan | webhook notifications, included from the Prestige plan upward, absent from the Essentiel plan |

The Prestige, Maison and Inside plans carry webhook notifications.
The Essentiel plan does not carry them, and neither does the trial account. If your
plan does not carry them, you receive a 403 on this route.

The key decides the brand. You cannot create a subscription for a
brand other than its own, and no field of the body allows changing it.

A missing scope returns 403 without creating anything.

> [!ATTENTION] The plan also governs the sending of events
> We check the plan a second time at the moment of sending an event.
> A brand that changes plan keeps its subscriptions, goes on reading them and
> deleting them, and receives no delivery any more as long as its plan does not carry
> notifications.

## Rate limit

We measure the rate over a fixed 60-second window. The cap depends
on your plan, and we can raise it for your brand without changing your
subscription. Read the current value in the `X-RateLimit-Limit` header of
every accepted response. When no value is set on your brand or
on your plan, the fallback value is 120 calls per window.

Two counters are layered, with the same cap: one per key, one for the
sum of all the keys of your brand. Creating one more key therefore does not increase
the total rate allowed.

| Header | Content |
| --- | --- |
| `X-RateLimit-Limit` | the cap applied over the window |
| `X-RateLimit-Remaining` | what is left to you in the current window |
| `X-RateLimit-Reset` | the end-of-window timestamp, in seconds |
| `X-RateLimit-Scope` | `key` or `brand`, the more constraining of the two counters |

Exceeding it returns 429 with these four headers, plus `Retry-After`.
`Retry-After` counts the seconds that remain in the current window, and is
at minimum 1.

> [!INFO] This route consumes no daily quota
> The daily quota of the key is consumed per item created and per sale
> declared. Creating a subscription takes no unit of it. Only the
> rate cap applies here.

## Path and query parameters

None. This endpoint reads neither path parameter, nor query
parameter. Everything is in the body.

## Request body

Content-Type `application/json`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | `string` | yes | The address that will receive the events. It must start with `https://` and be between 10 and 2048 characters long. |
| `events` | `string[]` | no | The event types you subscribe to. Each value must appear in the list below. Absent or empty, the subscription never receives anything. |
| `secret` | `string` | no | The shared secret that signs every delivery. 128 characters at most. Absent, the deliveries go out without a signature. |

The body accepts only these three names. Any other field makes the request fail
with a 422.

> [!ATTENTION] A subscription without `events` is mute
> `events` is optional as far as input validation goes. A subscription whose
> list is empty appears in your list of subscriptions, declares itself in good
> health, and receives no event. Always name at least one type.

### The 23 accepted event types

A name absent from these two lists makes the request fail with a 422.

#### The 17 types we deliver today

**Life cycle of an item**

`product.minted`, `product.transferred`, `transfer.accepted`

**Scans and security**

`product.scanned`, `product.gray_market`, `clone.alert`

**Returns and warranty**

`return.requested`, `return.received`, `return.completed`,
`return.rejected`, `return.expired`, `warranty.claimed`

**Buyback**

`buyback.offered`, `buyback.accepted`, `buyback.declined`,
`buyback.completed`, `buyback.expired`

#### The 6 types accepted at subscription and never emitted

`product.burned`, `product.status_changed`, `batch.completed`, `batch.failed`,
`certificate.issued`, `warranty.expiring_soon`

These six names pass input validation and are recorded in your
subscription. No code in the product emits them to this day, so your address
will never receive a delivery carrying one of these names. Build no alert
and no automation on them. We will update this list the day
one of them goes out.

> [!ATTENTION] `product.minted` does not cover creations by the partner API
> We emit `product.minted` when an item is created from the console.
> The batch creation path of the partner API emits no life
> cycle event. A subscription to `product.minted` therefore stays silent while
> you call the partner API.

### What the secret does

When you supply a `secret`, every delivery carries the header
`X-Webhook-Signature`, in the format `t=<horodatage>,v1=<empreinte>`. The hash
is an HMAC-SHA256 computed with your secret over the string made of
the timestamp, a dot, then the body received byte for byte. Verify
the signature on the raw bytes you receive, before decoding the JSON.

Three other headers come with every delivery.

| Header | Content | Covered by the signature |
| --- | --- | --- |
| `X-Webhook-Event` | the event type delivered, for example `product.scanned` | no |
| `X-Webhook-Id` | a stable identifier for a given event, which you use to discard duplicates from resends | no |
| `X-Webhook-Timestamp` | the timestamp used in the signature, in seconds | yes |

`X-Webhook-Event` tells you which event it is when a subscription
covers several types. It stays outside the signature, so it authenticates
nothing. When your receiver must route on an authenticated value,
register one address per event type, and use this header
only to read your logs.

Without a `secret`, the `X-Webhook-Signature` header is absent from your deliveries.

> [!DANGER] The secret can never be read back
> The creation response does not contain the `secret` field, and no read
> route returns it. Keep it at the moment you send it. If you
> lose it, replace it by modifying the subscription, then reconfigure
> your receiver.

## Example request

The three programs do the same thing: they register one address for
two event types that are actually delivered, with a signing secret.

:::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.com/sealtrust/evenements",
    "events": ["product.scanned", "clone.alert"],
    "secret": "secret-partage-a-remplacer"
  }'
```
```typescript title="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.com/sealtrust/evenements",
  events: ["product.scanned", "clone.alert"],
  secret: "secret-partage-a-remplacer",
});

console.log(abonnement.id, abonnement.health);
```
```python title="Python"
import requests

reponse = requests.post(
    "https://api.sealtrust.io/v1/partner/webhooks",
    headers={
        "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
        "Content-Type": "application/json",
    },
    json={
        "url": "https://exemple-sas.example.com/sealtrust/evenements",
        "events": ["product.scanned", "clone.alert"],
        "secret": "secret-partage-a-remplacer",
    },
    timeout=30,
)

reponse.raise_for_status()
abonnement = reponse.json()
print(abonnement["id"], abonnement["health"])
```
:::

## Example response

A successful creation answers **201 Created**.

```json title="201 Created"
{
  "id": 41,
  "brand_id": 12,
  "url": "https://exemple-sas.example.com/sealtrust/evenements",
  "events": ["product.scanned", "clone.alert"],
  "is_active": true,
  "health": "healthy",
  "created_at": "2026-08-20T09:14:03.512841+00:00",
  "updated_at": "2026-08-20T09:14:03.512841+00:00"
}
```

| Field | Type | What it contains |
| --- | --- | --- |
| `id` | `integer` | The identifier of the subscription. It is the one you will pass to the read, modify and delete routes. |
| `brand_id` | `integer` | The owning brand, the one of your key. |
| `url` | `string` | The address registered. It is this exact value that the deletion will ask you to copy back. |
| `events` | `string[]` | The event types subscribed to. |
| `is_active` | `boolean` | `true` at creation. A modification can switch it to `false` to turn the subscription off without deleting it. |
| `health` | `string` | `healthy` at creation. Switches to `degraded` when the series of resends gives up on your address, and comes back to `healthy` at the first successful delivery. |
| `created_at` | `string` | Date and time of creation, in ISO 8601 format. |
| `updated_at` | `string` | Date and time of the last modification, in ISO 8601 format. |

> [!ATTENTION] This call is not idempotent
> This endpoint reads no `Idempotency-Key` header. The TypeScript SDK
> sends one on every POST, and this route ignores it. Two identical calls
> therefore create two distinct subscriptions, and your address will receive every
> event twice. In case of doubt after a timeout, list your
> subscriptions before calling again.

> [!ATTENTION] The address must be publicly reachable
> The check made at creation is about the form: the `https://` prefix and
> the length. At registration we therefore accept an address internal to
> your network, and it will never receive anything. At the moment of delivering, we
> set aside private, local and loopback destinations.

## Errors

The body of an error response contains a single field, `detail`.

| Code | Condition | What you must do |
| --- | --- | --- |
| 401 | The `Authorization` header is missing. `detail` is `"Missing Authorization header"`, and the response carries `WWW-Authenticate: Bearer`. | Add the `Authorization: Bearer <votre clef>` header. |
| 401 | The header does not start with `Bearer` followed by a space. `detail` is `"Invalid Authorization header format (expected 'Bearer <token>')"`, and the response carries `WWW-Authenticate: Bearer`. | Respect the word `Bearer`, a space, then the key. |
| 401 | The value sent is empty or is shorter than 40 characters. `detail` is `"Invalid API key format"`. | Check that the key was copied in full. |
| 401 | The key matches no known key. `detail` is `"Invalid API key"`. | The key is wrong or was deleted. Create one from the console of your brand. |
| 403 | The key is no longer active. `detail` repeats its state, for example `"API key is revoked"`. | Use an active key. |
| 403 | The key has passed its expiration date. `detail` is `"API key has expired"`. | Create a new key. The key switches to expired as of this refusal. |
| 403 | The key does not carry the required scope. `detail` is `"Missing required scope: webhooks:write"`. | Create a key that carries this scope. |
| 403 | Your plan does not include notifications. `detail` is an object whose `code` is `FEATURE_NOT_AVAILABLE` and whose `feature` is `webhooks`. | Move to a plan that carries them. Replaying the request changes nothing. |
| 422 | Validation refuses the body when: `url` is missing, `url` does not start with `https://`, `url` falls outside the bounds of 10 to 2048 characters, an event type is unknown, `secret` exceeds 128 characters, or an unknown field is present. `detail` is a list, and each entry carries `loc`, `type` and `msg`. | Read `loc` to know which value is refused, correct it, call again. |
| 429 | Your key exceeds its rate cap. `detail` starts with `Rate limit exceeded`, and `X-RateLimit-Scope` is `key`. | Wait the number of seconds given by `Retry-After`, then call again. |
| 429 | The sum of the keys of your brand exceeds the cap. `detail` starts with `Brand rate limit exceeded`, and `X-RateLimit-Scope` is `brand`. | Wait `Retry-After`. Creating one more key does not increase this cap. |
| 503 | The service that keeps the rate counters is momentarily unavailable. `detail` is `"Rate limiting temporarily unavailable, please retry shortly"`. | We created nothing. Try again later. |
| 500 | A failure on our side. `detail` is `"Internal Server Error"`, or `"Internal server error"` when the failure occurs while reading your key. | Try again. If the refusal persists, send us the `X-Request-Id` header of the response. |

> [!INFO] The order of the checks
> We check in this order: the authentication of the key, including its active
> state and its expiration date; the validation of the body; the rate cap;
> the `webhooks:write` scope; the presence of notifications in your plan. A
> refusal at one of these steps creates nothing.
>
> This order governs your backoff. The two 403s that concern the
> `webhooks:write` scope and your plan arrive after the rate cap. Their
> response therefore carries the `X-RateLimit-*` headers of the budget already used, and
> you can read in them what is left to you before calling again.

## See also

- [`GET /partner/webhooks`](/en/reference/get-partner-webhooks/),
  list your notification subscriptions, page by page.
- [`GET /partner/webhooks/{webhook_id}`](/en/reference/get-partner-webhooks-id/),
  read a subscription and its delivery state.
- [`PUT /partner/webhooks/{webhook_id}`](/en/reference/put-partner-webhooks-id/),
  modify the address, the events or the secret of a subscription.
- [`DELETE /partner/webhooks/{webhook_id}`](/en/reference/delete-partner-webhooks-id/),
  delete a subscription and the signing secret attached to it.
- [Receiving events by webhook](/en/webhooks/),
  create a subscription, verify a signature, catch up on the events
  that were lost.
