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.

On this page

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.

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.

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 keyWhat it contains
locthe path of the offending value, as a list, for example ["body", "retailer_code"] or ["query", "page"]
typethe name of the rule that was not met, for example less_than_equal
msgthe 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.

#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.

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.

HeaderWhat it holds
X-RateLimit-Limitthe call ceiling of the current window
X-RateLimit-Remainingthe number of calls still accepted in this window
X-RateLimit-Resetthe Unix timestamp, in seconds, of the end of the window
X-RateLimit-Scopekey if the tightest counter is the key's, brand if it is the brand's
Retry-Afterpresent on a rate refusal, the number of seconds to wait, never below 1
X-Quota-Limitthe daily quota of the key. Present only on the 429 refusal of the daily quota
X-Quota-Remainingwhat is left of it today. Present only on the 429 refusal of the daily quota
X-Quota-Resetthe 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-Authenticateset 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.

Conditiondetail returnedWhat 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 invalidan object whose error is CSV invalide and whose rows lists each offending rowfix each row reported, see just after this table
Some objects of the JSON are invalidan object whose error is JSON invalide and whose rows lists each offending objectfix 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 damageda sentence that starts with Fichier gzip illisible : and gives the technical reasonrecompress the file, or send it without compression
The deletion of a subscription carries no confirmationan object whose code is CONFIRMATION_REQUIRED, together with message and what_to_typeadd the confirm parameter set to the exact URL of the subscription
The deletion confirmation does not matchan object whose code is CONFIRMATION_MISMATCH, together with message and what_to_typecopy 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.

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.

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

#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.

Conditiondetail returnedWhat 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

#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.

Conditiondetail returnedWhat 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 planan object whose code is QUOTA_EXCEEDED, detailed just after this tablewait 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:

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

#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.

Conditiondetail returnedWhat 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

#409, the current state forbids this operation

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

Conditiondetail returnedWhat 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 standa sentence saying that the batch must be anchored againreplaying 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.

#413, the upload is too large

Conditiondetail returnedWhat 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.

ConditionWhat you must do
A required field is missing from the body of a JSON requestadd the field named in loc
A field outside the contract is sent to the sell-out declaration or to a subscriptionremove 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 charactersfix the URL
An unknown event type is requested on the subscriptionuse a name from the list of subscribable events
A pagination parameter falls outside its boundsbring 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 sentencebring 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 typesdeclare a type that appears in the list returned
A custody code presented by a partner is refused. detail is then a sentencethe intervention was not recorded and the code was not consumed. Ask for a valid code again

#429, you are calling too often

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

CounterHow to recognize itWhat you must do
The rate of your keyX-RateLimit-Scope: key and Retry-Afterwait the number of seconds announced, then replay the request unchanged
The rate of your brand, across all keysX-RateLimit-Scope: brand and Retry-Afterslow down all of your integrations. Creating extra keys does not raise this ceiling
The daily quota of your keythe X-Quota-Limit, X-Quota-Remaining and X-Quota-Reset headers, and the absence of Retry-Afterwait 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 calledspace 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:

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:

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

#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.

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

Conditiondetail returnedWhat 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

#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 messageWhat 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
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

CodeReplay unchanged?Why
400nothe request is at fault, and it will stay so
401nothe authorization header must be repaired first
403nothe key, the scope or the plan must be changed first
404nothe object does not exist in your scope
409 on an idempotency keynochange the key, or read the result of the first call
409 MINT_PENDINGyes, after a few minutesthe on-chain confirmation is on its way
409 MINT_NOT_SUBMITTEDnothe mint never happened
409 on the anchoring proof of a batchnothe batch must be anchored again first, and that does not depend on you
413nothe upload must be reduced
422nothe API refuses a value you sent
429 of rateyes, after Retry-Afterthe rate window frees up
429 of daily quotano before the midnight Universal Time resetthis refusal carries no Retry-After. The counter frees up only at the change of day
500once, with the same idempotency keythe result of the call is unknown
503yes, after a few secondsnothing 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.

#!/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

#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.

Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.

Suggest a correctionReport a problem