# API errors

Read any error response from the SealTrust API, recognize the code returned, decide whether to fix the request or replay it, and find one precise call when you write to us.

Source: https://docs.sealtrust.io/en/api-erreurs/

---

By the time you leave this page, you will know how to read any error response
from the SealTrust API. You will recognize the code returned, you will decide
whether to fix your request or replay it as is, and you will find one precise
call in our logs when you write to us.

Read this page in full once, when you are writing your client. After that it
serves as a catalog.

## Every error has the same shape

The body of an error response contains a single field: `detail`. Its value
takes three forms, and you must know how to handle all three.

**A sentence.** This is the most common form. The text is meant for a human
reader.

```json title="401 Unauthorized"
{
  "detail": "Invalid API key"
}
```

**An object that carries a code.** Some refusals tied to your plan and some
deletion confirmations return an object. The `code` field is stable. The
`message` field changes from one version to the next.

```json title="403 Forbidden"
{
  "detail": {
    "code": "QUOTA_EXCEEDED",
    "resource": "products",
    "current": 4820,
    "additional": 200,
    "max": 5000,
    "period": "monthly"
  }
}
```

**A list.** When validation of the body or of the parameters fails, `detail` is
a list. Each entry describes a rejected value.

| Entry key | What it contains |
| --- | --- |
| `loc` | the path of the offending value, as a list, for example `["body", "retailer_code"]` or `["query", "page"]` |
| `type` | the name of the rule that was not met, for example `less_than_equal` |
| `msg` | the readable sentence that explains the refusal |

Depending on the rule that was not met, an entry can carry extra keys. Read
`loc` and `type`. Never compare `msg` character by character.

> [!ATTENTION] Write your client against the HTTP status code
> The status code and, when it exists, `detail.code`, are the only two values
> to branch your logic on. The sentences in `detail` change from one version to
> the next. A client that tests a sentence for equality breaks the day we
> reword it.

## The headers that come with a response

### `X-Request-Id`, the identifier of every call

Every API response carries an `X-Request-Id` header. If your request sends one,
yours is the one used. Otherwise we build one.

Record this value next to every call that fails. When you write to us about a
refusal, give it to us: it takes us straight to the call concerned.

```bash title="Sending your own request identifier"
curl -sS -D - -o /dev/null \
  -X POST https://api.sealtrust.io/v1/partner/sellout \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: exemple-sas-2026-08-20-0001" \
  -d '{"identifier": "DEM000000000", "retailer_code": "BTQ-EXEMPLE-01"}'
```

### The budget headers

A call authenticated by API key carries the rate counters in its response,
including when the endpoint then refuses. A 400, a 403 due to your plan, to
your quota or to a brand that does not match, therefore tell you how much rate
budget you have left.

Three families of refusal carry no counter: the 401s, the 403s of a revoked or
expired key, and the 403s of a scope missing from the key. Test for the
presence of the header before reading its value.

| Header | What it holds |
| --- | --- |
| `X-RateLimit-Limit` | the call ceiling of the current window |
| `X-RateLimit-Remaining` | the number of calls still accepted in this window |
| `X-RateLimit-Reset` | the Unix timestamp, in seconds, of the end of the window |
| `X-RateLimit-Scope` | `key` if the tightest counter is the key's, `brand` if it is the brand's |
| `Retry-After` | present on a rate refusal, the number of seconds to wait, never below 1 |
| `X-Quota-Limit` | the daily quota of the key. Present only on the 429 refusal of the daily quota |
| `X-Quota-Remaining` | what is left of it today. Present only on the 429 refusal of the daily quota |
| `X-Quota-Reset` | the date, in ISO 8601 format, of the last reset of the counter. Present only on the 429 refusal of the daily quota. The next reset happens at midnight Universal Time |
| `WWW-Authenticate` | set to `Bearer` on the 401 refusals caused by a missing or malformed `Authorization` header |

A call that succeeds does not tell you how much daily quota you have left. The
three `X-Quota-*` headers exist only on the 429 quota refusal. To follow this
counter before hitting it, open the API key list in the console: for each key,
it shows the day's consumption against its quota.

## The catalog, code by code

### 400, your request is malformed

The server understood the request and refuses it. Fix the request. Replaying it
as is will give the same result.

| Condition | `detail` returned | What you must do |
| --- | --- | --- |
| The CSV file is empty or has no header row | `{"error": "CSV vide ou sans en-têtes"}` | send a file whose first line names the columns |
| A required column is missing from the CSV header | `{"error": "Colonnes manquantes", "missing": ["category_id"]}` | add the columns named in `missing` |
| Some rows of the CSV are invalid | an object whose `error` is `CSV invalide` and whose `rows` lists each offending row | fix each row reported, see just after this table |
| Some objects of the JSON are invalid | an object whose `error` is `JSON invalide` and whose `rows` lists each offending object | fix each object reported, see just after this table |
| The JSON body is not a list | `"Le JSON doit être une liste d'objets"` | wrap your objects in brackets |
| The JSON body cannot be read | `"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."` | check that you are sending valid JSON, or switch to CSV in multipart |
| The batch exceeds 500 items | `"Batch trop volumineux : 640 items (max 500)"` | split it into several calls of 500 items at most |
| The batch contains no item | `"Batch vide (aucun item)"` | do not send an empty batch |
| A category referenced does not exist | `"Catégories introuvables : [77, 91]"` | fix the `category_id` values named |
| The gzip file sent is damaged | a sentence that starts with `Fichier gzip illisible :` and gives the technical reason | recompress the file, or send it without compression |
| The deletion of a subscription carries no confirmation | an object whose `code` is `CONFIRMATION_REQUIRED`, together with `message` and `what_to_type` | add the `confirm` parameter set to the exact URL of the subscription |
| The deletion confirmation does not match | an object whose `code` is `CONFIRMATION_MISMATCH`, together with `message` and `what_to_type` | copy the URL exactly as reading the subscription returns it. Nothing was deleted |
| A malformed GTIN is passed to a GS1 endpoint | `"Invalid GTIN"` | check that the segment contains only digits and no more than fourteen of them |
| A malformed item hash is passed to the history | `"Invalid UID hash format (must be 0x + 64 hex characters)"` | send `0x` followed by 64 hexadecimal characters |

Row errors are returned to you all at the same time. You fix everything in one
pass.

```json title="400 Bad Request, batch sent as CSV"
{
  "detail": {
    "error": "CSV invalide",
    "rows": [
      {
        "line": 2,
        "external_ref": "EX-0001",
        "error": "brand_id: Input should be a valid integer"
      }
    ]
  }
}
```

The `line` field is the line number in the file. The first data line carries
number 2, since line 1 is the header.

In JSON, the shape changes: the position is called `index` and starts at 1, and
`external_ref` is not carried over.

```json title="400 Bad Request, batch sent as JSON"
{
  "detail": {
    "error": "JSON invalide",
    "rows": [
      {
        "index": 1,
        "error": "metadata_uri: Field required"
      }
    ]
  }
}
```

> [!ATTENTION] An unknown field makes the row fail
> A batch row accepts five fields: `product_name`, `brand_id`,
> `category_id`, `metadata_uri` and `external_ref`. One extra column or key
> makes the row fail with a 400. The
> `owner_email` and `contract_address` fields were removed on August 20, 2026:
> sending them today makes the request fail.

### 401, the API does not know who you are

Your request carries no valid identity. Fix the `Authorization` header. The
first two rows of the table carry the `WWW-Authenticate: Bearer` header.

| Condition | `detail` returned | What you must do |
| --- | --- | --- |
| The `Authorization` header is missing | `"Missing Authorization header"` | add `Authorization: Bearer <your key>` |
| The header does not start with `Bearer ` | `"Invalid Authorization header format (expected 'Bearer <token>')"` | respect the word `Bearer`, a space, then the key |
| The value sent does not have the shape of a key | `"Invalid API key format"` | check that the key was copied in full |
| The key matches no known key | `"Invalid API key"` | the key is wrong or was deleted. Create a new one from your brand's console |
| A professional passport access tier is requested with no signed-in account | `"Professional-tier access requires authentication"` | sign in, or request the public tier |
| The authority access tier is requested with no signed-in account | `"Authority-tier access requires authentication"` | sign in with a market surveillance authority account |

> [!DANGER] The secret of a key is never readable again
> The full secret is shown once, when the key is created. We keep only a hash
> of it. If you have lost it, no endpoint will give it back to you: create a
> new key, update your integration, then revoke the old one.

### 403, the API knows who you are and refuses

Your identity is valid. Your key, your plan or your scope does not cover this
operation. Replaying the request changes nothing.

| Condition | `detail` returned | What you must do |
| --- | --- | --- |
| The key is revoked | `"API key is revoked"` | use an active key |
| The key was marked expired | `"API key is expired"` | create a new key |
| The key reaches its expiry date during this call | `"API key has expired"` | create a new key. The key switches to expired as of this refusal |
| A scope required by the endpoint is missing from the key | `"Missing required scopes: mint:batch"` | create a key that carries the scopes named |
| A scope is missing on a subscription endpoint | `"Missing required scope: webhooks:write"` | create a key that carries this scope |
| A row of the batch carries a brand other than the key's | `"Brand mismatch: tous les items doivent appartenir à brand_id=12. Trouvé 3 items invalides."` | fix the `brand_id` of the offending rows. The whole batch is refused |
| Your plan does not include the requested feature | `{"code": "FEATURE_NOT_AVAILABLE", "feature": "api_access"}` | change plan, or do not call this endpoint |
| The batch would take you past the product quota of your plan | an object whose `code` is `QUOTA_EXCEEDED`, detailed just after this table | wait for the next period, reduce the batch, or change plan |
| The batch identifier being tracked is not recognized as a batch of your brand | `"Access denied"` | check that the batch identifier does come from this key |
| You request a passport access tier that your account does not cover | `"This tier is restricted to the product's brand, partners holding the matching accreditation, and market-surveillance authorities"` | request the tier that matches your accreditation |
| A partner of the portal has no active accreditation | `"Aucune accréditation active"` | ask the brand to accredit you |
| A partner of the portal opens a product of a brand that has not accredited them | `"Ce produit appartient à une marque qui ne vous a pas accrédité"` | this product is not in your scope |
| A non-partner account calls the partner portal | `"Partner account required (repairer or recycler)"` | use an account of the repairer or recycler type |

The `QUOTA_EXCEEDED` object carries what you need to decide without writing to
us:

```json title="403 Forbidden"
{
  "detail": {
    "code": "QUOTA_EXCEEDED",
    "resource": "products",
    "current": 4820,
    "additional": 200,
    "max": 5000,
    "period": "monthly"
  }
}
```

> [!INFO] A refusal from your plan does not consume the day's quota
> The monthly quota of your plan is checked before the daily quota of the key
> is touched. A batch refused by the plan therefore costs you nothing for the
> day.

### 404, the resource does not exist for you

On the public endpoints and on the key-authenticated API, an object that exists
outside your scope answers 404, exactly like a nonexistent object. A retailer
of another brand, a subscription of another brand, a product of another brand:
the answer is the same as for a made-up identifier.

The partner portal is the exception. When the product exists at a brand that
has not accredited you, it answers 403 and says so, instead of 404.

| Condition | `detail` returned | What you must do |
| --- | --- | --- |
| The retailer code is unknown, inactive, or from another brand | `"Retailer 'BTQ-EXEMPLE-01' not found for this brand"` | create the retailer, or activate it, before declaring the sale |
| The product identifier matches nothing | `"Product not found for this identifier"` | check the label hash, the token identifier or the certificate number |
| The subscription requested does not exist or belongs to another brand | `"Webhook subscription not found"` | list your subscriptions to find the right identifier |
| The product requested does not exist | `"Product not found"` | check the identifier |
| The product exists and has no published passport | `"No published passport found for this product"` | publish the passport from the console |
| The GS1 link leads nowhere | `"Unknown GS1 Digital Link"` | check the GTIN and, if there is one, the serial number |
| No certificate was issued for this product | `"No certificate found for this product"` | issue the certificate before requesting it |
| The product belongs to no anchored batch | `"No Merkle anchor for this product"` | the anchoring proof does not exist for this item |
| A partner of the portal opens a product that does not exist | `"Produit introuvable"` | check the identifier read on the product |

> [!INFO] An unknown batch identifier does not return 404
> Batch tracking answers 200 with `"status": "unknown"` when the identifier
> designates no batch of your brand. Treat `unknown` as an absence.

### 409, the current state forbids this operation

The request is correct. It conflicts with what already exists.

| Condition | `detail` returned | What you must do |
| --- | --- | --- |
| The same idempotency key has already been used for a different batch | `"Idempotency-Key 'exemple-lot-001' was already used with a different request body"` | change the idempotency key for this new batch |
| A call carrying the same idempotency key is being processed | `"A request with this Idempotency-Key is already being processed"` | wait a few seconds, then read the result of the first call |
| An NFC seal is read on an item whose mint has been submitted and is not confirmed | `"MINT_PENDING: mint submitted, waiting for on-chain confirmation."` | try again in a few minutes, the situation resolves on its own |
| An NFC seal is read on an item that was never minted | `"MINT_NOT_SUBMITTED: this product has not been minted yet."` | the mint did not happen. Retrying changes nothing, go back to creating the item |
| The anchoring proof of a batch cannot be served as things stand | a sentence saying that the batch must be anchored again | replaying does not resolve this refusal. The proof can only be served after a new anchoring of the batch, which we alone trigger. Note the `X-Request-Id` and report it to us |

Both NFC seal messages start with a code in capitals followed by a colon. Test
that prefix. The sentence that follows can be reworded.

> [!ATTENTION] Two different batches under the same key
> Replaying an idempotency key with an identical batch returns the response of
> the first call, without minting again. Replaying the same key with a
> different batch returns 409. Equality is on the normalized content: the order
> of the rows, the order of the columns, the choice between CSV and JSON, and
> empty cells change nothing.

### 413, the upload is too large

| Condition | `detail` returned | What you must do |
| --- | --- | --- |
| A compressed CSV exceeds about 20 MiB once decompressed | `"Fichier compresse trop volumineux une fois decompresse (plafond 20 Mio)"` | split the file. A batch does not exceed 500 items anyway |

### 422, the API refuses a value you sent

This is the code for the parameters and the request bodies that the server can
read and refuses. The `detail` field is then a list, unless the table says
otherwise.

| Condition | What you must do |
| --- | --- |
| A required field is missing from the body of a JSON request | add the field named in `loc` |
| A field outside the contract is sent to the sell-out declaration or to a subscription | remove the field. These bodies refuse any unexpected field |
| The URL of a subscription does not start with `https://`, or falls outside the bounds of 10 to 2048 characters | fix the URL |
| An unknown event type is requested on the subscription | use a name from the list of subscribable events |
| A pagination parameter falls outside its bounds | bring `skip` back to 0 or more, and `limit` between 1 and 100 |
| A filter or pagination value falls outside the domain the endpoint accepts. `detail` is then a sentence | bring the value back within the documented bounds of the endpoint |
| A malformed chip hash is sent to the originality check. `detail` is then a sentence, for example `"uid_hex doit faire 7 ou 10 octets"` | fix the value named in the message |
| A partner of the portal declares an intervention type that its accreditations do not cover. `detail` is then a sentence that lists the allowed types | declare a type that appears in the list returned |
| A custody code presented by a partner is refused. `detail` is then a sentence | the intervention was not recorded and the code was not consumed. Ask for a valid code again |

> [!INFO] 400 on batches, 422 elsewhere
> Batch minting validates its rows itself and refuses with a 400, giving the
> number of the offending row. The sell-out declaration and the subscriptions
> go through the general validation and refuse with a 422, giving the path of
> the offending field. Plan for both forms in your client.

### 429, you are calling too often

Four different counters return this code. The headers tell you which one
refused.

| Counter | How to recognize it | What you must do |
| --- | --- | --- |
| The rate of your key | `X-RateLimit-Scope: key` and `Retry-After` | wait the number of seconds announced, then replay the request unchanged |
| The rate of your brand, across all keys | `X-RateLimit-Scope: brand` and `Retry-After` | slow down all of your integrations. Creating extra keys does not raise this ceiling |
| The daily quota of your key | the `X-Quota-Limit`, `X-Quota-Remaining` and `X-Quota-Reset` headers, and the absence of `Retry-After` | wait for the reset, at midnight Universal Time. The daily quota is set when the key is created and never changes afterwards. If the need is a regular one, create a new key with a higher quota, switch your integration over to it, then revoke the old one |
| The per-address ceiling on the public verification endpoints | `"Rate limit exceeded: 30 requests per 60s"`. The number announced is the one of the path you called | space out your reads. The `/passport`, `/certificate` and `/resolve` paths accept 60 calls per minute and per address. The `/qr`, `/verify`, `/timeline` and `/sdm` paths accept 30. Each family of paths has its own counter, and the `/v1` prefix does not create a second budget |

A public ceiling refusal carries `Retry-After`, in seconds. The `/timeline`
refusal is the exception and does not carry this header. Its window is fixed
and lasts 60 seconds: wait that long before calling again.

The rate refusal of an API key names the ceiling that refused and its window:

```json title="429 Too Many Requests"
{
  "detail": "Rate limit exceeded: 120 requests per 60 seconds"
}
```

When it is the ceiling of the brand that refuses, the sentence says so, it
gives the ceiling of the brand, and it states that this key stays under its own
ceiling. The `X-RateLimit-Scope` header is then `brand`.

Going past the daily quota takes another form:

```json title="429 Too Many Requests"
{
  "detail": "Quota exceeded. Remaining today: 0/1000"
}
```

> [!ATTENTION] The daily quota is consumed per item
> A batch of 100 items consumes 100 quota units. A sell-out declaration
> consumes 1. The subscription endpoints consume none. A batch replayed under
> the same idempotency key consumes neither the day's quota nor that of your
> plan. It does consume one call of your rate ceiling.

### 500, the failure is on our side

There is nothing wrong with your request. Do not fix it. Note the
`X-Request-Id` of the response, replay once after a few seconds, and report
this identifier to us if the refusal persists.

The body of a 500 has no guaranteed shape. Write no logic against its content.
Branch on the 500 status and on nothing else.

```json title="500 Internal Server Error"
{
  "detail": "Internal Server Error"
}
```

On an operation that writes, such as minting a batch, treat the result as
unknown. Replay with the same `Idempotency-Key` value as the first call: either
the first call had gone through and you receive its response, or it had created
nothing and the batch goes out.

### 503, the API refuses the call without changing anything

| Condition | `detail` returned | What you must do |
| --- | --- | --- |
| Call counting is momentarily unavailable | `"Rate limiting temporarily unavailable, please retry shortly"` | wait a few seconds and replay. Nothing was read or changed |
| The on-chain read failed during a verification | `"Error during blockchain verification"` | try again later. The product is not declared fake for all that |

> [!DANGER] A 503 on a deletion deletes nothing
> When call counting is unavailable, the eight endpoints of the partner API
> answer 503 before any action. The deletion of a subscription included: the
> subscription is still there. Replay the deletion after a few seconds, with
> its `confirm` parameter.

## A 200 response can mean no

Six endpoints answer 200 in a case you must treat as a refusal. A client that
only looks at the HTTP status misses all six of them. For each one, the field
to read is named below.

**The public check of a QR.** `GET /qr/verify` answers 200 with
`"valid": false` in five situations. The `message` field says which one
applies. Always read `valid`.

| Value of `message` | What it means |
| --- | --- |
| `Invalid product: blockchain verification failed.` | the on-chain check failed. It is the only one of the five cases that declares the product not authentic |
| `Invalid QR code signature. This QR code may be tampered with.` | the signature of the code is wrong. The code was modified, or it does not come from us |
| `QR code has expired. Please request a new QR code.` | the code is more than 30 days old. Have a new QR produced |
| `Mint submitted, waiting for on-chain confirmation.` | the mint of the item has been submitted and is not confirmed yet. Try again in a few minutes |
| `This product has not been minted yet.` | the item was never minted. Retrying will change nothing |

```json title="200 OK, and refused all the same"
{
  "valid": false,
  "message": "Invalid QR code signature. This QR code may be tampered with.",
  "uid_hash": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "token_id": null,
  "product_name": null,
  "brand_name": null,
  "image_url": null,
  "contract_address": null,
  "scan_area": null
}
```

**The check of an originality signature.**
`POST /originality/read-sig/verify` answers 200 with `"valid": false` when the
signature read on the chip does not verify against the public key used. The
`error` field is then `signature invalide`. Read `valid`.

**The integrity check of a passport.**
`GET /passport/{identifier}/verify` answers 200 even when a comparison fails.
Three fields carry the result.

`db_hash_match` and `ipfs_match` are `true`, `false` or `null`. `false` means
that the comparison failed. `null` means that it could not be made, which is
different from a failure.

`seal.chain_link_match` is `true` or `false`, and it is absent from the `seal`
block when the passport version was not sealed, or when it was sealed before
the chaining existed. Read `seal.sealed` and `seal.linked` first: when both are
`true`, `seal.chain_link_match` is present. It is the field that says whether a
sealed version was modified after publication.

**The check of the verifiable credential.**
`GET /passport/{identifier}/vc/verify` answers 200 with `"verified": false`
when the signature of the credential does not verify against the key of the
brand. The `error` field is then `verification_failed` and
`credential_subject` is `null`. Read `verified`.

**Batch tracking.** `GET /v1/partner/mint/batch/status/{job_id}` answers 200
with `"status": "unknown"` when the batch cannot be found, and 200 with
`"status": "failed"` when it failed. The values you will see are `queued`,
`started`, `finished`, `failed` and `unknown`. Loop on `status`, and stop as
soon as it leaves `queued` and `started`. Do not loop on `is_finished`: the
`unknown` response carries only `job_id` and `status`, and a loop waiting for
`is_finished` would never stop.

**The sell-out declaration.** `POST /v1/partner/sellout` answers 200 with
`"status": "already_activated"` when this item had already been declared sold.
Nothing was created a second time.

## What to replay, what not to replay

| Code | Replay unchanged? | Why |
| --- | --- | --- |
| 400 | no | the request is at fault, and it will stay so |
| 401 | no | the authorization header must be repaired first |
| 403 | no | the key, the scope or the plan must be changed first |
| 404 | no | the object does not exist in your scope |
| 409 on an idempotency key | no | change the key, or read the result of the first call |
| 409 `MINT_PENDING` | yes, after a few minutes | the on-chain confirmation is on its way |
| 409 `MINT_NOT_SUBMITTED` | no | the mint never happened |
| 409 on the anchoring proof of a batch | no | the batch must be anchored again first, and that does not depend on you |
| 413 | no | the upload must be reduced |
| 422 | no | the API refuses a value you sent |
| 429 of rate | yes, after `Retry-After` | the rate window frees up |
| 429 of daily quota | no before the midnight Universal Time reset | this refusal carries no `Retry-After`. The counter frees up only at the change of day |
| 500 | once, with the same idempotency key | the result of the call is unknown |
| 503 | yes, after a few seconds | nothing was changed |

An automatic replay uses a delay that grows, and a bounded number of attempts.
Replay on 503, on 500, and on the 429s that carry a `Retry-After`. On a 429 of
daily quota, stop calling until the change of day. On the other 4xx codes, fix
the request before any new call.

## Complete error handling, in three languages

These three programs do the same thing: they declare a sale, they tell the
families of error apart, and they keep the request identifier.

Run these three programs on your server. The partner API does not accept a call
coming from a browser, and an API key has no place in code sent to the browser.
From a browser, reading `X-Request-Id` and reading `Retry-After` would return
`null`: these two headers are not exposed to page code.

:::onglets
```bash title="curl"
#!/usr/bin/env bash
set -u

entetes=$(mktemp)
trap 'rm -f "$entetes"' EXIT

reponse=$(curl -sS -w '\n%{http_code}' -D "$entetes" \
  -X POST https://api.sealtrust.io/v1/partner/sellout \
  -H "Authorization: Bearer st_test_0000000000000000000000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{"identifier": "DEM000000000", "retailer_code": "BTQ-EXEMPLE-01"}')

code=$(printf '%s' "$reponse" | tail -n 1)
corps=$(printf '%s' "$reponse" | sed '$d')
requete=$(grep -i '^x-request-id:' "$entetes" | tr -d '\r' | cut -d' ' -f2)

echo "code=$code request_id=$requete"
echo "$corps"

case "$code" in
  200) echo "vente enregistrée" ;;
  429|503) echo "réessayez plus tard" ;;
  5*) echo "panne serveur, signalez $requete" ;;
  *) echo "requête à corriger" ;;
esac
```
```typescript title="TypeScript (Node)"
const reponse = await fetch("https://api.sealtrust.io/v1/partner/sellout", {
  method: "POST",
  headers: {
    Authorization:
      "Bearer st_test_0000000000000000000000000000000000000000000000",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    identifier: "DEM000000000",
    retailer_code: "BTQ-EXEMPLE-01",
  }),
});

const requestId = reponse.headers.get("X-Request-Id");
const corps = await reponse.json();

if (reponse.ok) {
  console.log("vente enregistrée", corps.status, requestId);
} else if (reponse.status === 429 || reponse.status === 503) {
  const attente = Number(reponse.headers.get("Retry-After") ?? 5);
  console.log("réessayez dans", attente, "secondes", requestId);
} else if (reponse.status >= 500) {
  console.log("panne serveur, signalez", requestId);
} else if (Array.isArray(corps.detail)) {
  for (const erreur of corps.detail) {
    console.log("champ refusé", erreur.loc.join("."), erreur.type);
  }
} else if (corps.detail && typeof corps.detail === "object") {
  console.log("refus code", corps.detail.code, requestId);
} else {
  console.log("refus", corps.detail, requestId);
}
```
```python title="Python"
import requests

reponse = requests.post(
    "https://api.sealtrust.io/v1/partner/sellout",
    headers={
        "Authorization": "Bearer st_test_0000000000000000000000000000000000000000000000",
        "Content-Type": "application/json",
    },
    json={"identifier": "DEM000000000", "retailer_code": "BTQ-EXEMPLE-01"},
    timeout=30,
)

request_id = reponse.headers.get("X-Request-Id")
corps = reponse.json()

if reponse.ok:
    print("vente enregistrée", corps["status"], request_id)
elif reponse.status_code in (429, 503):
    print("réessayez dans", reponse.headers.get("Retry-After", "5"), "secondes", request_id)
elif reponse.status_code >= 500:
    print("panne serveur, signalez", request_id)
else:
    detail = corps.get("detail")
    if isinstance(detail, list):
        for erreur in detail:
            print("champ refusé", ".".join(str(p) for p in erreur["loc"]), erreur["type"])
    elif isinstance(detail, dict):
        print("refus code", detail.get("code"), request_id)
    else:
        print("refus", detail, request_id)
```
:::

## What to remember

- The error body always carries `detail`, in three forms: a sentence, an
  object with a `code`, or a list of rejected values.
- Branch your code on the HTTP status and on `detail.code`. Never compare the
  sentences.
- Keep the `X-Request-Id` of every failure. That is what we will ask you for.
- Replay on 503, on 500, and on the 429s that carry a `Retry-After`. Fix
  everything else.
- On a write, replay with the same `Idempotency-Key` value as the call whose
  outcome you do not know.
- Read `valid`, `verified`, `status` and the match fields: six endpoints
  describe a refusal inside a 200 response.
