# Changelog

What changed in the partner API and in notifications, by date, with the exact action to take when an entry breaks a call that used to work.

Source: https://docs.sealtrust.io/en/journal-des-changements/

---

By the end of this page, you will know what changed in the partner API and in
notifications, on what date, and what you have to do on your side when a
change rejects a call that used to work. You will also know why the version
number you call does not move while these entries pile up.

Read this page in full the first time. After that, come back to it before
every change to your integration and stop at the first date you already know.

## How this page is built

Entries run from the most recent to the oldest. The date on an entry is the
date the change went live.

An entry starts with **Breaking change** when a call that used to work is
rejected afterward. That is the only case where you have work to do. The other
entries describe behavior that is added or a response that becomes more
precise, and your integration keeps working untouched.

Each entry says three things: what changes, the exact response you now
receive, and the action to take. No entry sends you elsewhere for the action
to take.

This page covers every endpoint this site documents: the key-authenticated
partner API, the public endpoints that need no key, authenticated passport
reads, the notifications sent to your addresses, and the partner portal. It
does not cover the console, whose screens change without any contract to keep
toward your code.

## Why the version number does not change

The `GET /v1/version` endpoint always answers the same thing.

```json
{
  "version": "1.0.0",
  "api_version": "v1"
}
```

Every entry below fits inside version 1. The day a change cannot fit there, it
will be added under `/v2`, and version 1 will keep its behavior.

The business endpoints exist at two addresses that call the same code: with
the `/v1` prefix, and with no prefix at all. Call `GET /v1/version` when you
want the contract version. It is the only version endpoint this site
documents.

Both addresses call the same code, so they receive every behavior change
listed here at the same time. One entry is the exception, the one dated
August 16, 2026, which concerned only the addresses without a prefix.

> [!ATTENTION] A breaking change concerns you even if you change nothing
> Your integration does not update itself, and the API does not roll back for
> it either. An entry marked as a breaking change applies to your code as it
> is deployed today. The two actions that come up most often are removing a
> field from a submission and adding a scope to a key.

## August 26, 2026

Three changes. The first is a breaking change, and it concerns every
accredited partner.

### Breaking change. The `value_chain` access tier no longer exists

The `value_chain` tier was granted to any partner holding **any**
accreditation on the brand, and it returned the union of what the repairer,
the recycler and the upstream supplier see. An accredited recycler could
therefore read manufacturing data and the supply chain, which the `recycler`
tier denies. That door is closed.

Six tiers remain: `public`, `end_user`, `repairer`, `recycler`, `upstream` and
`authority`. The three trade tiers are distinct audiences: holding one
accreditation opens none of the others.

**The exact response.** `?access_tier=value_chain` now answers **422**, on
`GET /v1/passport/{identifier}`, on `GET /v1/passport/01/{gtin}` and on the
three verifiable credential endpoints. `detail` is the usual validation list,
each entry carrying `loc`, `type` and `msg`, and `loc` names `access_tier`.

**What you do.** Ask for your own trade's tier, `repairer` or `recycler`. You
will receive fewer fields than before, and they are precisely the fields that
are not part of your trade.

### The partner portal announces the trade it served

In `GET /v1/partner-portal/products/{identifier}`, the
`passport.access_tier` field always read `value_chain`. It now carries the
trades actually served, joined by a `+` and sorted alphabetically:
`recycler`, or `repairer+recycler` for an account that holds both
accreditations on that brand. It reads `public` when the brand has recognized
none of them, and the passport is then filtered at the public tier.

**What you do.** If your code compares this value to `value_chain`, revisit
the comparison. And expect narrower content than before: it is now filtered on
your accreditations alone.

### A unit's public image falls back to its model

In `GET /v1/products/{uid_hash}/public`, the `image_url` field only looked at
media attached directly to the unit, and read `null` as soon as it had none.
It now follows the same fallback chain as the rest of the site: an image of
the unit, then its model's main image, then the model's media.

**What you do.** Nothing. A field that used to read `null` may now carry an
address.

## August 22, 2026

Three changes shipped together. None is a breaking change.

### The public certificate names the issuing brand

The `issuer_name` field carries the name of the brand that issued the
certificate. The server computes it on read, and it reads `null` when the
certificate is attached to no brand.

Three surfaces are affected: `GET /v1/certificate/{identifier}`, the PDF
returned by `GET /v1/certificate/{identifier}/download`, and the `certificate`
block of `GET /v1/resolve/{identifier}`.

**What you do.** Nothing in your calls. If you copied the value of
`issuer_name` into your own database, replace it with what the API returns
today.

### Authentication refusals answer in English

The API's front door returned its messages in two languages depending on the
path called. The messages it puts in `detail` are now in English:
`Not authenticated`, `Invalid token`, `Invalid token: missing email`,
`Token has been revoked`, `Invalid JWT token`, `Account disabled`,
`User not found` and `Partner account required (repairer or recycler)`.

Two refusals from `POST /v1/originality/read-sig/verify` follow the same rule
and now read `Invalid uid_hex` and `Invalid signature_hex`.

**What you do.** Nothing, if you key your code on the HTTP status code, as the
errors page recommends. A client that compares these sentences character by
character must revisit those comparisons, or better, remove them.

### The partner portal has a rate limit

The four `/partner-portal` endpoints accept 30 calls per 60 second window,
counted per calling address. All four share that counter, and the
`/v1/partner-portal/...` form shares it with the prefix-free form.

Every accepted response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`
and `X-RateLimit-Reset`. Going over returns 429, with `detail` reading
`Rate limit exceeded: 30 requests per 60s` and a `Retry-After` header reading
`60`.

**What you do.** Space out your calls to the portal and drive your pace from
`X-RateLimit-Remaining`. A human journey, searching for a product then
reporting an intervention, stays well below this limit.

## August 21, 2026

### Sending a batch now creates products

`POST /v1/partner/mint/batch` answered `200`, its processing ran to
completion, and no product was created. Every row was missing two values that
the submission contract does not let you provide, and every row was rejected
for that reason.

The server now sets those two values itself, on every row: the identification
method `qr` and the product's technical identifier.

**What you do.** Nothing in your submissions. A row's contract does not
change, and a row that carried the identification method or the technical
identifier is rejected with `400`, like any field the contract does not name.

Products created this way are identified by QR. For NFC items, go through the
console.

> [!ATTENTION] Batch tracking answers `finished` even when no item was created
> `GET /v1/partner/mint/batch/status/{job_id}` describes the progress of the
> processing first. The batch result is read elsewhere. A batch whose every
> row was rejected runs to the end of its processing and reads
> `status: "finished"` with `is_finished: true`, with no counts at all. Read
> `success_count` to know how many items exist.

## August 20, 2026

Seven changes shipped together. The first four are breaking changes.

### Breaking change. The API rejects the `owner_email` and `contract_address` fields

A batch row that carries either of these two fields fails the whole request
with `400`. The response names the rejected field.

The API used to accept these two fields and never read them. The value you put
in them had no effect.

A batch row now accepts five fields and nothing else: `product_name`,
`brand_id`, `category_id` and `metadata_uri`, which are required, and
`external_ref`, which is optional.

**What you do.** Remove these two columns from your CSV exports and these two
keys from your JSON objects. If you need to tie a row to a reference on your
side, use `external_ref`: it is the contract's only free field. We keep it on
the batch row and we return it in the result read by
`GET /v1/partner/mint/batch/status/{job_id}`. No product field picks it up.

Removing them makes validation pass. Since August 21, 2026, it also makes the
products get created, see the entry for that date.

### Breaking change. Replaying an idempotency key with a different batch answers 409

The `Idempotency-Key` header remains optional and is still remembered for
24 hours.

Replaying the same value with the same batch still returns the first call's
response, without minting anything again. The order of the checks changed the
same day. A replay now consumes a token from your rate limit, and the API
re-reads your request body before returning the cached response. A replay
beyond the limit therefore receives `429`. A replay whose body has become
unreadable receives `400`.

Replaying the same value with a different batch now answers `409`. The API
does not queue your second batch, and the response tells you so.

```json
{
  "detail": "Idempotency-Key 'lot-2026-08-20-a' was already used with a different request body"
}
```

The same `409` code also signals that a call carrying this key is still being
processed. The message is then `A request with this
Idempotency-Key is already being processed`. Read the message before deciding
what to do: the first case asks you to change your key, the second asks you to
wait for the processing under way to finish.

Two batches are compared on their content once read. Row order, column order,
the choice between CSV and JSON, and empty cells make no difference.

**What you do.** Build one `Idempotency-Key` value per distinct batch. If your
code reuses a fixed value, or a value derived from the time to the minute,
change it now.

### Breaking change. Batch tracking requires the `mint:batch` scope

`GET /v1/partner/mint/batch/status/{job_id}` requires the `mint:batch` scope,
the same one as sending the batch. A key that does not carry it receives
`403`, and the message names the missing scope.

**What you do.** Check that the key querying the tracking endpoint carries
`mint:batch`. A key's scopes are set when it is created and no screen changes
them afterward, so you need a new key.

First check your key limit, which counts only active keys. The Essentiel plan
allows only one: revoke the old key, then create the new one, accepting the
outage between the two. The Prestige plan allows five: create the new key,
deploy it, then revoke the old one. Revoking a key frees a slot immediately. A
creation beyond the limit answers `403` with the `QUOTA_EXCEEDED` code.

### Breaking change. Changing a notification subscription requires your plan to include them

`PUT /v1/partner/webhooks/{webhook_id}` now looks at your brand's plan. A call
that worked the day before for a brand whose plan does not include
notifications is rejected with `403`.

```json
{
  "detail": {
    "code": "FEATURE_NOT_AVAILABLE",
    "feature": "webhooks"
  }
}
```

Turning one off is the one exception: sending `is_active` at `false` and
nothing else remains possible without the plan. As soon as another field
accompanies that switch off, the API requires the plan.

Creating a subscription, `POST /v1/partner/webhooks`, answers the same thing
and already required it before this change.

**What you do.** Nothing if your plan includes notifications. If it no longer
includes them, stop changing your subscriptions: turn them off with
`is_active` at `false` alone, or delete them.

### An unreadable JSON body answers 400

A malformed body sent to `POST /v1/partner/mint/batch` now answers `400`, with
a message that recalls the two accepted formats.

```json
{
  "detail": "Corps de requete illisible : le JSON envoye est mal forme. Envoyez une liste d'objets produit en application/json, ou un fichier CSV en multipart/form-data."
}
```

**What you do.** If your code automatically replays responses in the `5xx`
family, this case no longer triggers it. A request rejected with `400` must be
fixed, and replaying it as is will give the same refusal.

### The eight key-authenticated endpoints are rate limited

The rate limit now applies to all eight endpoints of the partner API,
including batch tracking and the five notification subscription endpoints.

Two visible consequences. Beyond the limit, you receive `429` with the
`Retry-After` header, expressed in seconds. When the rate limiting service is
unavailable, you receive `503`, and the call has no effect: a deletion refused
this way deletes nothing.

Responses that go through carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
`X-RateLimit-Reset` and `X-RateLimit-Scope`.

**What you do.** Read `X-RateLimit-Remaining` and slow down before you reach
zero. If you poll a batch's tracking endpoint in a loop, space out your calls:
it is the endpoint most exposed to this limit. Treat the `503` as a temporary
outage and retry after a delay.

### Your notification subscriptions stay readable and deletable whatever your plan

Reading the list of your subscriptions, reading one subscription and deleting
a subscription work whatever your brand's plan. These three calls do not look
at your plan.

Sending notifications follows another rule: a brand whose plan no longer
includes notifications receives no more deliveries, whatever subscriptions are
still recorded.

**What you do.** Nothing. If your plan no longer includes notifications, you
keep control to inventory your subscriptions and to delete them.

## August 16, 2026

### Prefix-free addresses become permanent aliases

The copies without the `/v1` prefix used to answer with two headers announcing
an end of service on September 1, 2026, `Deprecation` and `Sunset`. The API no
longer sends these two headers. The `/v1` addresses never carried them.

No end-of-service date is attached to these addresses. If your integration
already calls them, it keeps working.

**What you do.** Nothing right now. Use the `/v1` form for any new
integration: it is the form documented everywhere on this site.

## August 14, 2026

### A damaged gzip file answers 400

`POST /v1/partner/mint/batch` accepts a CSV compressed with gzip or zlib.

A gzip file the server cannot decompress now answers `400`, with the reason in
the message. It is a request error, and your code can treat it as such.

A file whose decompressed content exceeds the limit answers `413`, and the
message states that limit in MiB.

**What you do.** Nothing if your submissions are valid. If you compress on the
fly, check that the stream is complete before sending it, and split your
batches to stay under the limit. A batch carries at most 500 items.

### Batch tracking returns four more fields when the queue has forgotten your batch

`GET /v1/partner/mint/batch/status/{job_id}` used to answer `unknown` to the
owner of an old batch, once the queue's retention period had elapsed. It now
returns the real status, rebuilt from the reservation recorded when the batch
was sent, and it adds four fields: `batch_status`, `items_count`,
`success_count` and `error_count`.

An identifier that designates no batch of your brand still answers `unknown`,
revealing nothing more.

**What you do.** Nothing, these fields are additions.

## August 13, 2026

### A partner portal intervention carries a proof level

`POST /v1/partner-portal/interventions` now records a proof level with every
intervention, in the `proof_level` field.

The request body accepts an optional `proof_code` field. Without it, the level
reads `declared`. With a valid code, it reads `customer_code` when the code
was read by the customer, or `work_order` when it comes from the brand's work
order. A rejected code answers `422` and records nothing at all: neither the
intervention, nor the consumption of the code.

Interventions recorded before this date return `proof_level` empty. An empty
value does not mean `declared`: the question had not been asked.

**What you do.** If you display the intervention history, treat the empty
value as a separate case and do not display "declared" in its place.

## August 11, 2026

### Breaking change. Deleting a subscription requires retyping its address

`DELETE /v1/partner/webhooks/{webhook_id}` requires a `confirm` query
parameter containing the subscription's exact address.

Without that parameter, a subscription that belongs to you answers `400` with
the `CONFIRMATION_REQUIRED` code. With a value that does not match, the
response is `400` with the `CONFIRMATION_MISMATCH` code, and the API changes
nothing. A successful deletion answers `204`.

An identifier that designates no subscription of your brand answers `404`,
whether you sent `confirm` or not: the API looks for your subscription before
looking at your confirmation.

A call that sent only the identifier is therefore rejected.

**What you do.** Read the address with `GET
/v1/partner/webhooks/{webhook_id}`, then send it back in `confirm`.

```bash
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"
```

> [!DANGER] Deleting destroys the signing secret
> The endpoint stops receiving events and its signing secret is destroyed. A
> new subscription created for the same address receives a different secret,
> and your receiver must be reconfigured with that new secret.

### Every delivery carries the event type

Every delivery now carries `X-Webhook-Event`, which names the event type. The
signature does not cover this header.

**What you do.** Nothing, this header is an addition.

> [!ATTENTION] The `X-Webhook-Event` header is not covered by the signature
> The signature covers the timestamp and the body. The event type carried by
> `X-Webhook-Event` serves as a routing hint and is not authenticated. If your
> processing depends on the type, declare one address per event type. The
> signed body does not carry the type, so `X-Webhook-Event` is the only hint
> you have, and it is not authenticated.

## July 14, 2026

### Two headers are added to every delivery

Every delivery now carries `X-Webhook-Id` and `X-Webhook-Timestamp`, in
addition to `X-Webhook-Signature`.

`X-Webhook-Id` is the hash of the delivery body. Retries of the same delivery
carry the same value, which lets you discard a retry. Two distinct events
whose body is identical also carry the same value.

**What you do.** Deduplicate on the pair formed by the event name, carried by
`X-Webhook-Event` since August 11, 2026, and `X-Webhook-Id`. Never discard a
delivery on the value of `X-Webhook-Timestamp`: that timestamp changes with
every retry, so it would make you process the same delivery twice.

### The signature covers the exact bytes sent to you

`X-Webhook-Signature` has the form `t=<timestamp>,v1=<hash>`. The hash is an
HMAC-SHA256 computed with your subscription's secret, over the string formed
by the timestamp, a period, and the request body.

The signed body is the byte sequence received on the wire. Decoding the JSON
then re-encoding it before verifying breaks the signature, because
re-encoding does not produce the same bytes.

**What you do.** Verify the signature on the raw request body, before any
decoding.

## July 12, 2026

### Subscription endpoints require scopes

Listing your subscriptions and reading one require the `webhooks:read` scope.
Creating, changing and deleting a subscription require the `webhooks:write`
scope.

A key that does not carry the scope receives `403`, and the message names the
missing scope.

**What you do.** Check the scopes of the key that manages your subscriptions.
If it was created with the `mint:batch` scope alone, create a new key that
also carries the notification scopes.

## March 15, 2026

### The `/v1` prefix appears

Every business endpoint becomes reachable at two addresses, with the `/v1`
prefix and with no prefix at all.

The prefix-free addresses additionally answer two headers, `Deprecation: true`
and `Sunset: 2026-09-01`. The `/v1` addresses do not carry them. The
August 16, 2026 entry removes these two headers and no end-of-service date was
ever applied.

**What you do.** Nothing. Both addresses call the same code.

## What this page does not do

It does not announce upcoming changes. An entry lands here when the behavior
described is live.

It does not replace a test. We open no test environment to partners, and no
sandbox address exists. After reading an entry marked as a breaking change,
replay your real call on a product or a subscription of your brand that you
can sacrifice, and compare the response code to the one you expected.
