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
- Every error has the same shape
- The headers that come with a response
- X-Request-Id, the identifier of every call
- The budget headers
- The catalog, code by code
- 400, your request is malformed
- 401, the API does not know who you are
- 403, the API knows who you are and refuses
- 404, the resource does not exist for you
- 409, the current state forbids this operation
- 413, the upload is too large
- 422, the API refuses a value you sent
- 429, you are calling too often
- 500, the failure is on our side
- 503, the API refuses the call without changing anything
- A 200 response can mean no
- What to replay, what not to replay
- Complete error handling, in three languages
- What to remember
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.
{
"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.
{
"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.
#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.
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.
{
"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.
{
"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.
| 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 |
#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:
{
"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.
| 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 |
#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.
#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 |
#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:
{
"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:
{
"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.
{
"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 |
#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 |
{
"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.
#!/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" ;;
esacconst 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);
}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 acode, 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-Idof 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-Keyvalue as the call whose outcome you do not know. - Read
valid,verified,statusand the match fields: six endpoints describe a refusal inside a 200 response.
Was this page helpful?
Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.