# PUT /partner/webhooks/{webhook_id}

Modify a notification subscription: its destination URL, its event list, its signing secret, whether it is active. Scope webhooks:write.

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

---

You modify a notification subscription that your brand already owns, and you
receive the subscription in its new state.

## Authorization

Send your API key in the `Authorization` header, in the form `Bearer` followed
by a space then the key. The key must carry the `webhooks:write` scope. A key
that does not carry this scope receives a 403 whose message names the missing
scope.

| What we require | Value |
| --- | --- |
| Authentication | API key of your brand |
| Scope carried by the key | `webhooks:write` |
| Feature of your plan | webhook notifications |

Two conditions come on top of the scope of the key.

- The subscription must belong to the brand of the key. We look for the
  subscription by its number **and** by your brand. When that lookup finds
  nothing, you receive a 404. A subscription that belongs to another brand
  gives exactly the same response. This endpoint therefore never tells you that
  a number exists elsewhere.
- The plan of your brand must include notifications. Otherwise you receive a
  403 carrying the `FEATURE_NOT_AVAILABLE` code. One exception exists,
  described just below.

> [!INFO] Switching a subscription off stays possible without the plan
> We accept a call whose body contains exactly `{"is_active": false}` and
> nothing else, even when your plan no longer includes notifications. That way
> you keep the means to stop cleanly a subscription you are no longer paying
> for. As soon as another field accompanies this switch-off, or as soon as you
> put `is_active` back to `true`, we require the plan again.

The full URL of this endpoint is
`https://api.sealtrust.io/v1/partner/webhooks/{webhook_id}`. The same endpoint
also answers without the `/v1` prefix, at
`https://api.sealtrust.io/partner/webhooks/{webhook_id}`. Both URLs call the
same code. Use the `/v1` form for a new integration.

## Rate limit

We measure the rate over a fixed 60 second window. The ceiling rises with your
plan, and we can set a value on your brand that wins over the one of your plan.
We therefore read, in this order: the value set on your brand, then the one of
your plan, then a fallback value of 120 calls per window. That fallback value
is used only if your brand and your plan carry none.

Do not guess your ceiling of the moment. You read it on every response that
gets past the rate check.

Two counters overlap, with the same ceiling: one per key, one for the sum of
all the keys of your brand. Creating extra keys therefore does not raise the
total rate allowed.

Four headers describe the more constraining of the two counters.

| Header | Content |
| --- | --- |
| `X-RateLimit-Limit` | the ceiling applied over the window |
| `X-RateLimit-Remaining` | what is left to you in the current window |
| `X-RateLimit-Reset` | the end timestamp of the window, in seconds |
| `X-RateLimit-Scope` | `key` or `brand`, the counter that served as the reference |

> [!ATTENTION] These four headers are missing on some responses
> You receive them on a 200, 403, 404 and 429 response. You do **not** receive
> them on a 401, 422, 500 or 503 response. So read these headers with a
> reading that tolerates their absence, otherwise your code will stop on the
> first authentication error.

A rate refusal returns 429 with `Retry-After`, expressed in seconds remaining
in the current window. That value is never below 1.

This endpoint consumes no daily quota of a key and no monthly quota of a plan.
The rate ceiling is the only counter that applies here.

## Path and query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `webhook_id` | `integer` | yes | The number of the subscription to modify, in the path. A value that is not an integer gives a 422. |

This endpoint has no query parameter.

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | `string` | yes | `Bearer` followed by a space then your API key. |
| `Content-Type` | `string` | yes | `application/json`. |
| `Idempotency-Key` | `string` | no | This endpoint does not declare this header. It neither reads it nor remembers it. |

## Request body

The body is a JSON object. The four fields are optional. Send only the fields
you want to change. A field absent from the body keeps its value.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | `string` | no | The new destination URL, from 10 to 2048 characters. It must start with `https://`. Never send `null` in this field, read the warning below. |
| `events` | `array` of `string` | no | The new list of event types. It replaces the old list entirely. Every value must belong to the list below. |
| `is_active` | `boolean` | no | `true` to receive the events, `false` to stop the deliveries without destroying the subscription. This field accepts `true` or `false`. Omit it if you do not want to change it. |
| `secret` | `string` | no | The new signing secret, up to 128 characters. We use it to compute the `X-Webhook-Signature` header of every delivery. `null` erases the secret, and the following deliveries then go out without a signature. |

The server refuses any field that this table does not name. An unknown field
makes the request fail with a 422, and we modify nothing.

An empty body, written `{}`, is valid. It modifies nothing, it returns the
subscription unchanged, and we require the plan as for any other modification.

> [!DANGER] Never send `"url": null`
> A destination URL is always a string that starts with `https://`. To stop the
> deliveries without destroying the subscription, send `{"is_active": false}`.
> After every modification, read the `url` field of the response again: it is
> the one that says the URL we will call from now on.

> [!DANGER] We refuse the `description` field
> We used to accept this field and then throw it away without recording it
> anywhere. You thought you were describing your subscription, and you were
> describing nothing. Today it makes the request fail with a 422. Remove it
> from your code before your next call.

### The 23 event types

A value of `events` that is not in this list makes the request fail with a 422,
and the message names the rejected values.

| Family | Types |
| --- | --- |
| Product lifecycle | `product.minted`, `product.transferred`, `product.burned`, `product.status_changed` |
| Batches | `batch.completed`, `batch.failed` |
| Certificate | `certificate.issued` |
| Distribution and security | `product.scanned`, `product.gray_market`, `clone.alert`, `transfer.accepted` |
| Returns | `return.requested`, `return.received`, `return.completed`, `return.rejected`, `return.expired` |
| Warranty | `warranty.claimed`, `warranty.expiring_soon` |
| Buyback | `buyback.offered`, `buyback.accepted`, `buyback.declined`, `buyback.completed`, `buyback.expired` |

> [!ATTENTION] An empty list cuts off every delivery
> We accept `"events": []`, and `"events": null` produces the same result: the
> recorded list becomes empty. The subscription stays visible, it stays
> `is_active`, and it never receives anything again. We deliver an event only
> if its type appears in the list of the subscription.

> [!ATTENTION] The signature depends on the secret, and the secret can disappear
> A delivery carries the `X-Webhook-Signature` header only if the subscription
> carries a secret. Three consequences.
>
> - You send a new secret: we sign the following deliveries with that new
>   secret. Update your receiving server before sending this call, otherwise it
>   will reject the deliveries.
> - You send `"secret": null`: we erase the secret, and the following
>   deliveries go out without any signature.
> - The subscription never carried a secret: its deliveries do not carry one
>   either, and this call changes nothing about that as long as you send no
>   secret.
>
> The response never contains the secret. You therefore cannot check through
> this endpoint whether a secret is set.

## Example request

You repoint subscription number `128` to a new URL and you cut its list down to
two event types.

:::onglets
```bash title="curl"
curl -i -X PUT https://api.sealtrust.io/v1/partner/webhooks/128 \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://exemple-sas.example.com/sealtrust/evenements",
    "events": ["product.minted", "batch.completed"]
  }'
```
```typescript
import { SealTrustClient } from "@sealtrust-io/sdk";

const sealtrust = new SealTrustClient({
  apiKey: "st_test_0000000000000000000000000000000000000000000000",
});

const abonnement = await sealtrust.webhooks.update(128, {
  url: "https://exemple-sas.example.com/sealtrust/evenements",
  events: ["product.minted", "batch.completed"],
});

console.log(abonnement.id, abonnement.url, abonnement.events);
```
```python
import requests

response = requests.put(
    "https://api.sealtrust.io/v1/partner/webhooks/128",
    headers={
        "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
    },
    json={
        "url": "https://exemple-sas.example.com/sealtrust/evenements",
        "events": ["product.minted", "batch.completed"],
    },
    timeout=30,
)

print(response.status_code)
print(response.headers.get("X-RateLimit-Remaining"))
print(response.json())
```
:::

## Example response

HTTP code `200`.

```json
{
  "id": 128,
  "brand_id": 12,
  "url": "https://exemple-sas.example.com/sealtrust/evenements",
  "events": ["product.minted", "batch.completed"],
  "is_active": true,
  "health": "healthy",
  "created_at": "2026-08-18T09:12:44.512038+00:00",
  "updated_at": "2026-08-20T14:03:21.884517+00:00"
}
```

The response has eight fields and nothing else. The secret never appears in it.

| Field | Type | Description |
| --- | --- | --- |
| `id` | `integer` | The number of the subscription. |
| `brand_id` | `integer` | The number of your brand. |
| `url` | `string` | The destination URL recorded. Read it again after every modification. |
| `events` | `array` of `string` | The list of event types recorded. |
| `is_active` | `boolean` | `true` if the subscription receives the events. |
| `health` | `string` | `healthy` or `degraded`. It is `degraded` after the definitive failure of a delivery, and comes back to `healthy` on the first successful delivery. |
| `created_at` | `string` | The creation date of the subscription, in Universal Time. |
| `updated_at` | `string` | The date of the last modification, in Universal Time. |

> [!INFO] The subscription list does not return the same shape
> `GET /v1/partner/webhooks` names the list of event types `event_types`. This
> endpoint, like the creation and the single read, names it `events`. Both
> carry the same content. Plan for both names if you read the two endpoints
> with the same code.

## Errors

The body of an error response carries a `detail` field. Depending on the case,
that field contains a sentence, a list or an object.

| Code | Condition | What to do |
| --- | --- | --- |
| 401 | The `Authorization` header is missing. `detail` is `Missing Authorization header`. The response also carries `WWW-Authenticate: Bearer`. | Add the header. |
| 401 | The `Authorization` header does not start with `Bearer` followed by a space. The response also carries `WWW-Authenticate: Bearer`. | Fix the form of the header. |
| 401 | The key sent is empty, or shorter than 40 characters. `detail` is `Invalid API key format`. | Send the whole key. A key truncated on screen gives this response. |
| 401 | The key sent is unknown. `detail` is `Invalid API key`. | Check that you are sending the complete key, with no space and no line break. |
| 403 | The key is no longer active, because it was revoked. `detail` names its state. | Create a new key in the console. |
| 403 | The key has reached its expiry date. `detail` is `API key has expired`. | Create a new key. The old one will never become valid again. |
| 403 | The key does not carry the `webhooks:write` scope. `detail` is `Missing required scope: webhooks:write`. | Create a key carrying this scope. The scopes of an existing key cannot be modified. |
| 403 | The plan of your brand does not include notifications. `detail` is `{"code": "FEATURE_NOT_AVAILABLE", "feature": "webhooks"}`. | Change plan from the console, or contact us. You can still switch the subscription off with a body reduced to `{"is_active": false}`. |
| 404 | No subscription carries this number for your brand. `detail` is `Webhook subscription not found`. | Check the number with `GET /v1/partner/webhooks`. You receive the same response if the subscription belongs to another brand. |
| 422 | The `webhook_id` of the path is not an integer. | Send the number returned by `GET /v1/partner/webhooks`. |
| 422 | The `webhook_id` of the path is an integer too large to be a subscription number. The message speaks of a query parameter, whereas the offending value is in the path. | Send the number returned by `GET /v1/partner/webhooks`. |
| 422 | The body contains a field that this endpoint does not know. | Remove that field. We accept only `url`, `events`, `is_active` and `secret`. |
| 422 | `url` is a string that does not start with `https://`. | Fix the URL so that it starts with `https://`. |
| 422 | `url` is a string of fewer than 10 or more than 2048 characters. | Fix the length of the URL. |
| 422 | `events` contains at least one unknown type. The message names the rejected values. | Take the values from the table of the 23 event types. |
| 422 | `secret` exceeds 128 characters. | Shorten the secret. |
| 422 | A field does not carry the announced type, for example `url` receives a number or `is_active` receives a string. | Fix the type. The message names the offending field. |
| 422 | The body is not a JSON object, or the body is missing. | Send a JSON object, even an empty one, with `Content-Type: application/json`. |
| 429 | The rate ceiling of the key is reached. The `Retry-After` header and the `X-RateLimit-*` family accompany the response, with `X-RateLimit-Scope: key`. | Wait the number of seconds given by `Retry-After`, then try again. |
| 429 | The rate ceiling of the brand is reached, across all keys. `X-RateLimit-Scope` is `brand`. | Wait the number of seconds given by `Retry-After`. Creating an extra key does not raise this ceiling. |
| 500 | An unexpected error occurred. `detail` is `Internal Server Error`. | Try again. If the error persists, contact support giving the `X-Request-Id` header of the response. |
| 503 | The call rate limiting service is momentarily unavailable. We refuse the call before any read. | Try again in a few moments. The subscription was not modified. |

> [!INFO] A refusal never modifies the subscription halfway
> We record the modification only once every check has passed: the key, the
> body of the request, the `webhooks:write` scope, the existence of the
> subscription within your brand and the plan of your brand. A 401, a 403, a
> 404, a 422, a 429 or a 503 therefore leaves the subscription in exactly the
> state it was in.

## See also

- [`GET /partner/webhooks/{webhook_id}`](/en/reference/get-partner-webhooks-id/),
  read a subscription and its delivery state.
- [`GET /partner/webhooks`](/en/reference/get-partner-webhooks/),
  list your notification subscriptions, page by page.
- [`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 lost events.
