# Erreurs de l'API

Lire n'importe quelle réponse d'erreur de l'API SealTrust, reconnaître le code renvoyé, décider s'il faut corriger la requête ou la rejouer, et retrouver un appel précis quand vous nous écrivez.

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

---

En quittant cette page, vous saurez lire n'importe quelle réponse d'erreur de
l'API SealTrust. Vous reconnaîtrez le code renvoyé, vous déciderez s'il faut
corriger votre requête ou la rejouer telle quelle, et vous retrouverez un appel
précis dans nos journaux quand vous nous écrivez.

Cette page se lit en entier une fois, au moment où vous écrivez votre client.
Elle sert ensuite de catalogue.

## Toutes les erreurs ont la même forme

Le corps d'une réponse d'erreur contient un seul champ : `detail`. Sa valeur
prend trois formes, et vous devez savoir gérer les trois.

**Une phrase.** C'est la forme la plus courante. Le texte s'adresse à un
lecteur humain.

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

**Un objet qui porte un code.** Certains refus liés à votre offre et certaines
confirmations de suppression renvoient un objet. Le champ `code` est stable. Le champ
`message` change au fil des versions.

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

**Une liste.** Quand la validation du corps ou des paramètres échoue, `detail`
est une liste. Chaque entrée décrit une valeur refusée.

| Clef de l'entrée | Ce qu'elle contient |
| --- | --- |
| `loc` | le chemin de la valeur fautive, sous forme de liste, par exemple `["body", "retailer_code"]` ou `["query", "page"]` |
| `type` | le nom de la règle non respectée, par exemple `less_than_equal` |
| `msg` | la phrase lisible qui explique le refus |

Selon la règle non respectée, une entrée peut porter des clefs supplémentaires.
Lisez `loc` et `type`. Ne comparez jamais `msg` caractère par caractère.

> [!ATTENTION] Écrivez votre client contre le code HTTP
> Le code de statut et, quand il existe, `detail.code`, sont les deux seules
> valeurs sur lesquelles brancher votre logique. Les phrases de `detail`
> changent au fil des versions. Un client qui teste l'égalité d'une phrase
> casse le jour où nous la reformulons.

## Les en-têtes qui accompagnent une réponse

### `X-Request-Id`, l'identifiant de chaque appel

Chaque réponse de l'API porte un en-tête `X-Request-Id`. Si votre requête en
envoie un, c'est le vôtre qui est repris. Sinon nous en fabriquons un.

Enregistrez cette valeur à côté de chaque appel qui échoue. Quand vous nous
écrivez au sujet d'un refus, donnez-la : elle nous mène directement à l'appel
concerné.

```bash title="Envoyer votre propre identifiant de requête"
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"}'
```

### Les en-têtes de budget

Un appel authentifié par clef d'API porte les compteurs de débit dans sa
réponse, y compris quand la route refuse ensuite. Un 400, un 403 dû à votre
offre, à votre quota ou à une marque qui ne correspond pas, vous disent donc
quel budget de débit il vous reste.

Trois familles de refus ne portent aucun compteur : les 401, les 403 de clef
révoquée ou expirée, et les 403 de droit manquant sur la clef. Testez la
présence de l'en-tête avant de lire sa valeur.

| En-tête | Ce qu'il vaut |
| --- | --- |
| `X-RateLimit-Limit` | le plafond d'appels de la fenêtre en cours |
| `X-RateLimit-Remaining` | le nombre d'appels encore acceptés dans cette fenêtre |
| `X-RateLimit-Reset` | l'horodatage Unix, en secondes, de la fin de la fenêtre |
| `X-RateLimit-Scope` | `key` si le compteur le plus serré est celui de la clef, `brand` si c'est celui de la marque |
| `Retry-After` | présent sur un refus de débit, le nombre de secondes à attendre, jamais inférieur à 1 |
| `X-Quota-Limit` | le quota journalier de la clef. Présent uniquement sur le refus 429 du quota journalier |
| `X-Quota-Remaining` | ce qu'il en reste aujourd'hui. Présent uniquement sur le refus 429 du quota journalier |
| `X-Quota-Reset` | la date, au format ISO 8601, de la dernière remise à zéro du compteur. Présent uniquement sur le refus 429 du quota journalier. La remise à zéro suivante a lieu au passage de minuit en temps universel |
| `WWW-Authenticate` | vaut `Bearer` sur les refus 401 dus à un en-tête `Authorization` absent ou mal formé |

Un appel qui réussit ne vous dit pas combien de quota journalier il vous reste.
Les trois en-têtes `X-Quota-*` n'existent que sur le refus 429 du quota. Pour
suivre ce compteur avant de le heurter, ouvrez la liste des clefs d'API dans la
console : elle affiche, pour chaque clef, la consommation du jour sur son quota.

## Le catalogue, code par code

### 400, votre requête est mal formée

Le serveur a compris la requête et la refuse. Corrigez la requête. La rejouer
telle quelle donnera le même résultat.

| Condition | `detail` renvoyé | Ce que vous devez faire |
| --- | --- | --- |
| Le fichier CSV est vide ou n'a pas de ligne d'en-tête | `{"error": "CSV vide ou sans en-têtes"}` | envoyez un fichier dont la première ligne nomme les colonnes |
| Une colonne obligatoire manque à l'en-tête CSV | `{"error": "Colonnes manquantes", "missing": ["category_id"]}` | ajoutez les colonnes nommées dans `missing` |
| Des lignes du CSV sont invalides | un objet dont `error` vaut `CSV invalide` et dont `rows` liste chaque ligne fautive | corrigez chaque ligne signalée, voir juste après ce tableau |
| Des objets du JSON sont invalides | un objet dont `error` vaut `JSON invalide` et dont `rows` liste chaque objet fautif | corrigez chaque objet signalé, voir juste après ce tableau |
| Le corps JSON n'est pas une liste | `"Le JSON doit être une liste d'objets"` | encadrez vos objets par des crochets |
| Le corps JSON est illisible | `"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."` | vérifiez que vous envoyez du JSON valide, ou passez au CSV en multipart |
| Le lot dépasse 500 articles | `"Batch trop volumineux : 640 items (max 500)"` | découpez en plusieurs appels de 500 articles au maximum |
| Le lot ne contient aucun article | `"Batch vide (aucun item)"` | n'envoyez pas de lot vide |
| Une catégorie citée n'existe pas | `"Catégories introuvables : [77, 91]"` | corrigez les `category_id` nommés |
| Le fichier gzip envoyé est abîmé | une phrase qui commence par `Fichier gzip illisible :` et donne le motif technique | recompressez le fichier, ou envoyez-le sans compression |
| La suppression d'un abonnement n'apporte pas de confirmation | un objet dont `code` vaut `CONFIRMATION_REQUIRED`, accompagné de `message` et de `what_to_type` | ajoutez le paramètre `confirm` égal à l'adresse exacte de l'abonnement |
| La confirmation de suppression ne correspond pas | un objet dont `code` vaut `CONFIRMATION_MISMATCH`, accompagné de `message` et de `what_to_type` | recopiez l'adresse telle que la lecture de l'abonnement la renvoie. Rien n'a été supprimé |
| Un GTIN mal formé est passé à une route GS1 | `"Invalid GTIN"` | vérifiez que le segment ne contient que des chiffres et n'en dépasse pas quatorze |
| Une empreinte d'article mal formée est passée à l'historique | `"Invalid UID hash format (must be 0x + 64 hex characters)"` | envoyez `0x` suivi de 64 caractères hexadécimaux |

Les erreurs de lignes vous sont rendues toutes en même temps. Vous corrigez
tout en un passage.

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

Le champ `line` est le numéro de ligne dans le fichier. La première ligne de
données porte le numéro 2, puisque la ligne 1 est l'en-tête.

En JSON, la forme change : la position s'appelle `index` et commence à 1, et
`external_ref` n'est pas repris.

```json title="400 Bad Request, lot envoyé en JSON"
{
  "detail": {
    "error": "JSON invalide",
    "rows": [
      {
        "index": 1,
        "error": "metadata_uri: Field required"
      }
    ]
  }
}
```

> [!ATTENTION] Un champ inconnu fait échouer la ligne
> Une ligne de lot accepte cinq champs : `product_name`, `brand_id`,
> `category_id`, `metadata_uri` et `external_ref`. Une colonne ou une clef en
> plus fait échouer la ligne en 400. Les champs
> `owner_email` et `contract_address` ont été retirés le 20 août 2026 :
> les envoyer aujourd'hui fait échouer la requête.

### 401, l'API ne sait pas qui vous êtes

Votre requête n'a pas d'identité valide. Corrigez l'en-tête `Authorization`.
Les deux premières lignes du tableau portent l'en-tête
`WWW-Authenticate: Bearer`.

| Condition | `detail` renvoyé | Ce que vous devez faire |
| --- | --- | --- |
| L'en-tête `Authorization` est absent | `"Missing Authorization header"` | ajoutez `Authorization: Bearer <votre clef>` |
| L'en-tête ne commence pas par `Bearer ` | `"Invalid Authorization header format (expected 'Bearer <token>')"` | respectez le mot `Bearer`, un espace, puis la clef |
| La valeur envoyée n'a pas la forme d'une clef | `"Invalid API key format"` | vérifiez que la clef a été copiée en entier |
| La clef ne correspond à aucune clef connue | `"Invalid API key"` | la clef est fausse ou a été supprimée. Créez-en une nouvelle depuis la console de votre marque |
| Un niveau d'accès professionnel au passeport est demandé sans compte connecté | `"Professional-tier access requires authentication"` | connectez-vous, ou demandez le niveau public |
| Le niveau d'accès autorité est demandé sans compte connecté | `"Authority-tier access requires authentication"` | connectez-vous avec un compte d'autorité de surveillance |

> [!DANGER] Le secret d'une clef ne se relit jamais
> Le secret complet est affiché une seule fois, au moment de la création de la
> clef. Nous n'en conservons qu'une empreinte. Si vous l'avez perdu, aucune
> route ne vous le rendra : créez une nouvelle clef, mettez à jour votre
> intégration, puis révoquez l'ancienne.

### 403, l'API sait qui vous êtes et refuse

Votre identité est valide. Votre clef, votre offre ou votre périmètre ne
couvrent pas cette opération. Rejouer la requête ne change rien.

| Condition | `detail` renvoyé | Ce que vous devez faire |
| --- | --- | --- |
| La clef est révoquée | `"API key is revoked"` | utilisez une clef active |
| La clef a été marquée expirée | `"API key is expired"` | créez une nouvelle clef |
| La clef atteint sa date d'expiration pendant cet appel | `"API key has expired"` | créez une nouvelle clef. La clef bascule en expirée dès ce refus |
| Un droit exigé par la route manque à la clef | `"Missing required scopes: mint:batch"` | créez une clef qui porte les droits nommés |
| Un droit manque sur une route d'abonnement | `"Missing required scope: webhooks:write"` | créez une clef qui porte ce droit |
| Une ligne du lot porte une autre marque que celle de la clef | `"Brand mismatch: tous les items doivent appartenir à brand_id=12. Trouvé 3 items invalides."` | corrigez le `brand_id` des lignes fautives. Le lot entier est refusé |
| Votre offre ne comprend pas la fonction demandée | `{"code": "FEATURE_NOT_AVAILABLE", "feature": "api_access"}` | changez d'offre, ou n'appelez pas cette route |
| Le lot ferait dépasser le quota de produits de votre offre | un objet dont `code` vaut `QUOTA_EXCEEDED`, détaillé juste après ce tableau | attendez la période suivante, réduisez le lot, ou changez d'offre |
| L'identifiant de lot suivi n'est pas reconnu comme un lot de votre marque | `"Access denied"` | vérifiez que l'identifiant de lot vient bien de cette clef |
| Vous demandez un niveau d'accès au passeport que votre compte ne couvre pas | `"This tier is restricted to the product's brand, partners holding the matching accreditation, and market-surveillance authorities"` | demandez le niveau qui correspond à votre accréditation |
| Un partenaire du portail n'a aucune accréditation active | `"Aucune accréditation active"` | demandez à la marque de vous accréditer |
| Un partenaire du portail ouvre un produit d'une marque qui ne l'a pas accrédité | `"Ce produit appartient à une marque qui ne vous a pas accrédité"` | ce produit n'est pas dans votre périmètre |
| Un compte non partenaire appelle le portail partenaire | `"Partner account required (repairer or recycler)"` | utilisez un compte de type réparateur ou recycleur |

L'objet `QUOTA_EXCEEDED` porte de quoi décider sans nous écrire :

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

> [!INFO] Un refus de votre offre ne consomme pas le quota du jour
> Le quota mensuel de votre offre est vérifié avant que le quota journalier de
> la clef ne soit entamé. Un lot refusé par l'offre ne vous coûte donc rien sur
> la journée.

### 404, la ressource n'existe pas pour vous

Sur les points d'entrée publics et sur l'API à clef, un objet qui existe hors de
votre périmètre répond 404, exactement comme un objet inexistant. Un point de
vente d'une autre marque, un abonnement d'une autre marque, un produit d'une
autre marque : la réponse est la même que pour un identifiant inventé.

Le portail partenaire fait exception. Quand le produit existe chez une marque
qui ne vous a pas accrédité, il répond 403 et le dit, au lieu de 404.

| Condition | `detail` renvoyé | Ce que vous devez faire |
| --- | --- | --- |
| Le code de point de vente est inconnu, inactif, ou d'une autre marque | `"Retailer 'BTQ-EXEMPLE-01' not found for this brand"` | créez le point de vente, ou activez-le, avant de déclarer la vente |
| L'identifiant de produit ne correspond à rien | `"Product not found for this identifier"` | vérifiez l'empreinte d'étiquette, l'identifiant de jeton ou le numéro de certificat |
| L'abonnement demandé n'existe pas ou appartient à une autre marque | `"Webhook subscription not found"` | listez vos abonnements pour retrouver le bon identifiant |
| Le produit demandé n'existe pas | `"Product not found"` | vérifiez l'identifiant |
| Le produit existe et n'a aucun passeport publié | `"No published passport found for this product"` | publiez le passeport depuis la console |
| Le lien GS1 ne mène à rien | `"Unknown GS1 Digital Link"` | vérifiez le GTIN et, s'il y en a un, le numéro de série |
| Aucun certificat n'a été émis pour ce produit | `"No certificate found for this product"` | émettez le certificat avant de le demander |
| Le produit n'appartient à aucun lot ancré | `"No Merkle anchor for this product"` | la preuve d'ancrage n'existe pas pour cet article |
| Un partenaire du portail ouvre un produit inexistant | `"Produit introuvable"` | vérifiez l'identifiant lu sur le produit |

> [!INFO] Un identifiant de lot inconnu ne renvoie pas 404
> Le suivi d'un lot répond 200 avec `"status": "unknown"` quand l'identifiant ne
> désigne aucun lot de votre marque. Traitez `unknown` comme une absence.

### 409, l'état actuel interdit cette opération

La requête est correcte. Elle entre en conflit avec ce qui existe déjà.

| Condition | `detail` renvoyé | Ce que vous devez faire |
| --- | --- | --- |
| La même clef d'idempotence a déjà servi pour un lot différent | `"Idempotency-Key 'exemple-lot-001' was already used with a different request body"` | changez de clef d'idempotence pour ce nouveau lot |
| Un appel portant la même clef d'idempotence est en cours de traitement | `"A request with this Idempotency-Key is already being processed"` | attendez quelques secondes, puis relisez le résultat du premier appel |
| Un scellé NFC est lu sur un article dont la frappe est partie et n'est pas confirmée | `"MINT_PENDING: mint submitted, waiting for on-chain confirmation."` | réessayez dans quelques minutes, la situation se résout seule |
| Un scellé NFC est lu sur un article jamais frappé | `"MINT_NOT_SUBMITTED: this product has not been minted yet."` | la frappe n'a pas eu lieu. Réessayer ne changera rien, reprenez la création de l'article |
| La preuve d'ancrage d'un lot ne peut pas être servie en l'état | une phrase qui dit que le lot doit être ancré de nouveau | rejouer ne résout pas ce refus. La preuve ne pourra être servie qu'après un nouvel ancrage du lot, que nous seuls déclenchons. Relevez le `X-Request-Id` et signalez-le nous |

Les deux messages du scellé NFC commencent par un code en majuscules suivi de
deux points. Testez ce préfixe. La phrase qui suit peut être reformulée.

> [!ATTENTION] Deux lots différents sous la même clef
> Rejouer une clef d'idempotence avec un lot identique renvoie la réponse du
> premier appel, sans refrapper. Rejouer la même clef avec un lot différent
> renvoie 409. L'égalité porte sur le contenu normalisé : l'ordre des lignes,
> l'ordre des colonnes, le choix entre CSV et JSON, et les cellules vides n'y
> changent rien.

### 413, l'envoi est trop gros

| Condition | `detail` renvoyé | Ce que vous devez faire |
| --- | --- | --- |
| Un CSV compressé dépasse environ 20 Mio une fois décompressé | `"Fichier compresse trop volumineux une fois decompresse (plafond 20 Mio)"` | découpez le fichier. Un lot ne dépasse de toute façon pas 500 articles |

### 422, l'API refuse une valeur que vous avez envoyée

C'est le code des paramètres et des corps de requête que le serveur sait lire
et refuse. Le champ `detail` est alors une liste, sauf mention contraire dans
le tableau.

| Condition | Ce que vous devez faire |
| --- | --- |
| Un champ obligatoire manque au corps d'une requête JSON | ajoutez le champ nommé dans `loc` |
| Un champ hors contrat est envoyé à la déclaration de vente ou à un abonnement | retirez le champ. Ces corps refusent tout champ non prévu |
| L'adresse d'un abonnement ne commence pas par `https://`, ou sort des bornes de 10 à 2048 caractères | corrigez l'adresse |
| Un type d'événement inconnu est demandé à l'abonnement | reprenez un nom de la liste des événements souscriptibles |
| Un paramètre de pagination sort de ses bornes | ramenez `skip` à 0 ou plus, et `limit` entre 1 et 100 |
| Une valeur de filtre ou de pagination sort du domaine que la route accepte. `detail` est alors une phrase | ramenez la valeur dans les bornes documentées de la route |
| Une empreinte de puce mal formée est envoyée à la vérification d'originalité. `detail` est alors une phrase, par exemple `"uid_hex doit faire 7 ou 10 octets"` | corrigez la valeur nommée dans le message |
| Un partenaire du portail déclare un type d'intervention non couvert par ses accréditations. `detail` est alors une phrase qui liste les types autorisés | déclarez un type figurant dans la liste rendue |
| Un code de garde présenté par un partenaire est refusé. `detail` est alors une phrase | l'intervention n'a pas été enregistrée et le code n'a pas été consommé. Redemandez un code valide |

> [!INFO] 400 sur les lots, 422 ailleurs
> Les lots de frappe valident leurs lignes eux-mêmes et refusent en 400, avec
> le numéro de la ligne fautive. La déclaration de vente et les abonnements
> passent par la validation générale et refusent en 422, avec le chemin du
> champ fautif. Prévoyez les deux formes dans votre client.

### 429, vous appelez trop souvent

Quatre compteurs différents rendent ce code. Les en-têtes vous disent lequel a
refusé.

| Compteur | Comment le reconnaître | Ce que vous devez faire |
| --- | --- | --- |
| Le débit de votre clef | `X-RateLimit-Scope: key` et `Retry-After` | attendez le nombre de secondes annoncé, puis rejouez la requête à l'identique |
| Le débit de votre marque, toutes clefs confondues | `X-RateLimit-Scope: brand` et `Retry-After` | ralentissez l'ensemble de vos intégrations. Créer des clefs supplémentaires n'augmente pas ce plafond |
| Le quota journalier de votre clef | les en-têtes `X-Quota-Limit`, `X-Quota-Remaining` et `X-Quota-Reset`, et l'absence de `Retry-After` | attendez la remise à zéro, au passage de minuit en temps universel. Le quota journalier se fixe à la création de la clef et ne se modifie plus. Si le besoin est régulier, créez une nouvelle clef avec un quota plus haut, basculez votre intégration dessus, puis révoquez l'ancienne |
| Le plafond par adresse sur les routes publiques de vérification | `"Rate limit exceeded: 30 requests per 60s"`. Le nombre annoncé est celui du chemin que vous avez appelé | espacez vos lectures. Les chemins `/passport`, `/certificate` et `/resolve` acceptent 60 appels par minute et par adresse. Les chemins `/qr`, `/verify`, `/timeline` et `/sdm` en acceptent 30. Chaque famille de chemins a son propre compteur, et le préfixe `/v1` ne crée pas un second budget |

Un refus de plafond public porte `Retry-After`, en secondes. Le refus de
`/timeline` fait exception et ne porte pas cet en-tête. Sa fenêtre est fixe et
dure 60 secondes : attendez ce délai avant de rappeler.

Le refus du débit d'une clef d'API nomme le plafond qui a refusé et sa fenêtre :

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

Quand c'est le plafond de la marque qui refuse, la phrase le dit, elle donne le
plafond de la marque, et elle précise que cette clef reste sous son propre
plafond. L'en-tête `X-RateLimit-Scope` vaut alors `brand`.

Le dépassement du quota journalier prend une autre forme :

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

> [!ATTENTION] Le quota journalier se consomme par article
> Un lot de 100 articles consomme 100 unités de quota. Une déclaration de vente
> en consomme 1. Les routes d'abonnement n'en consomment aucune. Un lot rejoué
> sous la même clef d'idempotence ne consomme ni le quota du jour ni celui de
> votre offre. Il consomme en revanche un appel de votre plafond de débit.

### 500, la panne est de notre côté

Votre requête n'a rien de fautif. Ne la corrigez pas. Relevez le
`X-Request-Id` de la réponse, rejouez une fois après quelques secondes, et
signalez-nous cet identifiant si le refus persiste.

Le corps d'un 500 n'a pas de forme garantie. N'écrivez aucune logique contre
son contenu. Branchez-vous sur le statut 500 et sur rien d'autre.

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

Sur une opération qui écrit, comme la frappe d'un lot, considérez le résultat
comme inconnu. Rejouez avec la même valeur d'`Idempotency-Key` que le premier
appel : soit le premier appel avait abouti et vous recevez sa réponse, soit il
n'avait rien créé et le lot part.

### 503, l'API refuse l'appel sans rien modifier

| Condition | `detail` renvoyé | Ce que vous devez faire |
| --- | --- | --- |
| Le comptage des appels est momentanément indisponible | `"Rate limiting temporarily unavailable, please retry shortly"` | attendez quelques secondes et rejouez. Rien n'a été lu ni modifié |
| La lecture en chaîne a échoué pendant une vérification | `"Error during blockchain verification"` | réessayez plus tard. Le produit n'est pas déclaré faux pour autant |

> [!DANGER] Un 503 sur une suppression ne supprime rien
> Quand le comptage des appels est indisponible, les huit routes de l'API
> partenaire répondent 503 avant toute action. La suppression d'un abonnement
> incluse : l'abonnement est toujours là. Rejouez la suppression après quelques
> secondes, avec son paramètre `confirm`.

## Une réponse 200 peut vouloir dire non

Six points d'entrée répondent 200 sur un cas que vous devez traiter comme un
refus. Un client qui ne regarde que le code HTTP les manque tous les six. Pour
chacun, le champ à lire est nommé ci-dessous.

**La vérification publique d'un QR.** `GET /qr/verify` répond 200 avec
`"valid": false` dans cinq situations. Le champ `message` dit laquelle
s'applique. Lisez toujours `valid`.

| Valeur de `message` | Ce que cela veut dire |
| --- | --- |
| `Invalid product: blockchain verification failed.` | la vérification en chaîne a échoué. C'est le seul des cinq cas qui déclare le produit non authentique |
| `Invalid QR code signature. This QR code may be tampered with.` | la signature du code est fausse. Le code a été modifié, ou il ne vient pas de nous |
| `QR code has expired. Please request a new QR code.` | le code a plus de 30 jours. Faites produire un nouveau QR |
| `Mint submitted, waiting for on-chain confirmation.` | la frappe de l'article est partie et n'est pas encore confirmée. Réessayez dans quelques minutes |
| `This product has not been minted yet.` | l'article n'a jamais été frappé. Réessayer ne changera rien |

```json title="200 OK, et pourtant refusé"
{
  "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
}
```

**La vérification d'une signature d'originalité.**
`POST /originality/read-sig/verify` répond 200 avec `"valid": false` quand la
signature lue sur la puce ne se vérifie pas contre la clef publique utilisée. Le
champ `error` vaut alors `signature invalide`. Lisez `valid`.

**La vérification d'intégrité d'un passeport.**
`GET /passport/{identifier}/verify` répond 200 même quand une comparaison
échoue. Trois champs portent le résultat.

`db_hash_match` et `ipfs_match` valent `true`, `false` ou `null`. `false` veut
dire que la comparaison a échoué. `null` veut dire qu'elle n'a pas pu être
faite, ce qui est différent d'un échec.

`seal.chain_link_match` vaut `true` ou `false`, et il est absent du bloc `seal`
quand la version du passeport n'a pas été scellée, ou quand elle a été scellée
avant l'existence du chaînage. Lisez d'abord `seal.sealed` et `seal.linked` :
quand les deux valent `true`, `seal.chain_link_match` est présent. C'est le
champ qui dit si une version scellée a été modifiée après publication.

**La vérification du justificatif vérifiable.**
`GET /passport/{identifier}/vc/verify` répond 200 avec `"verified": false`
quand la signature du justificatif ne se vérifie pas contre la clef de la
marque. Le champ `error` vaut alors `verification_failed` et
`credential_subject` vaut `null`. Lisez `verified`.

**Le suivi d'un lot.** `GET /v1/partner/mint/batch/status/{job_id}` répond 200
avec `"status": "unknown"` quand le lot est introuvable, et 200 avec
`"status": "failed"` quand il a échoué. Les valeurs que vous verrez sont
`queued`, `started`, `finished`, `failed` et `unknown`. Bouclez sur `status`,
et arrêtez-vous dès qu'il quitte `queued` et `started`. Ne bouclez pas sur
`is_finished` : la réponse `unknown` ne porte que `job_id` et `status`, et une
boucle qui attend `is_finished` ne s'arrêterait jamais.

**La déclaration de vente.** `POST /v1/partner/sellout` répond 200 avec
`"status": "already_activated"` quand cet article avait déjà été déclaré vendu.
Rien n'a été créé une seconde fois.

## Que rejouer, que ne pas rejouer

| Code | Rejouer à l'identique ? | Pourquoi |
| --- | --- | --- |
| 400 | non | la requête est fautive, elle le restera |
| 401 | non | il faut d'abord réparer l'en-tête d'autorisation |
| 403 | non | il faut d'abord changer la clef, le droit ou l'offre |
| 404 | non | l'objet n'existe pas dans votre périmètre |
| 409 sur une clef d'idempotence | non | changez de clef, ou lisez le résultat du premier appel |
| 409 `MINT_PENDING` | oui, après quelques minutes | la confirmation en chaîne arrive |
| 409 `MINT_NOT_SUBMITTED` | non | la frappe n'a jamais eu lieu |
| 409 sur la preuve d'ancrage d'un lot | non | il faut d'abord que le lot soit ancré de nouveau, et cela ne dépend pas de vous |
| 413 | non | il faut réduire l'envoi |
| 422 | non | l'API refuse une valeur que vous avez envoyée |
| 429 de débit | oui, après `Retry-After` | la fenêtre de débit se libère |
| 429 de quota journalier | non avant la remise à zéro de minuit en temps universel | ce refus ne porte pas de `Retry-After`. Le compteur ne se libère qu'au changement de jour |
| 500 | une fois, avec la même clef d'idempotence | le résultat de l'appel est inconnu |
| 503 | oui, après quelques secondes | rien n'a été modifié |

Un rejeu automatique se fait avec un délai qui croît, et un nombre d'essais
borné. Rejouez sur 503, sur 500, et sur les 429 qui portent un `Retry-After`.
Sur un 429 de quota journalier, arrêtez d'appeler jusqu'au changement de jour.
Sur les autres codes 4xx, corrigez la requête avant tout nouvel appel.

## Une gestion d'erreur complète, en trois langages

Ces trois programmes font la même chose : ils déclarent une vente, ils
distinguent les familles d'erreur, et ils gardent l'identifiant de requête.

Exécutez ces trois programmes sur votre serveur. L'API partenaire n'accepte pas
d'appel venant d'un navigateur, et une clef d'API n'a rien à faire dans du code
envoyé au navigateur. Depuis un navigateur, la lecture de `X-Request-Id` et
celle de `Retry-After` rendraient `null` : ces deux en-têtes ne sont pas exposés
au code de page.

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

## Ce qu'il faut retenir

- Le corps d'erreur porte toujours `detail`, sous trois formes : une phrase, un
  objet à `code`, ou une liste de valeurs refusées.
- Branchez votre code sur le statut HTTP et sur `detail.code`. Ne comparez
  jamais les phrases.
- Gardez le `X-Request-Id` de chaque échec. C'est ce que nous vous demanderons.
- Rejouez sur 503, sur 500, et sur les 429 qui portent un `Retry-After`.
  Corrigez sur tout le reste.
- Sur une écriture, rejouez avec la même valeur d'`Idempotency-Key` que
  l'appel dont vous ignorez le sort.
- Lisez `valid`, `verified`, `status` et les champs de correspondance : six
  points d'entrée décrivent un refus dans une réponse 200.
