Errors
The error envelope, every code, and what to do with each.
Every failure — from a malformed key to a database conflict — comes back in one shape. Write one error handler, not twelve.
{
"error": {
"code": "NOT_FOUND",
"message": "Order not found.",
"request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59"
}
}The error object
| Field | Always present | What it is for |
|---|---|---|
code | Yes | A stable machine-readable identifier. Branch on this. |
message | Yes | A human-readable sentence. Show it to a developer; never parse it. |
fields | Only on validation failures | A map of field name to the list of things wrong with it. |
request_id | Whenever a request id was stamped | Correlates with our logs. Echoed in the X-Request-Id header. |
There is no success field and no envelope around the error. The HTTP status
and the code are the answer.
Every error code
Authentication and authorization
| HTTP | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED | No Authorization header, a token that is not shopdesk_sk_…, an unknown key, a plan without API access, or a store or company that is not live. |
| 401 | KEY_REVOKED | The key exists and was deliberately revoked. |
| 401 | KEY_EXPIRED | The key's expiry date passed, or its rotation grace window closed. |
| 403 | FORBIDDEN_SCOPE | The key is valid but was not granted the scope this endpoint requires. Scopes are fixed at creation — make a new key. |
| 403 | PLAN_FEATURE_REQUIRED | The key and its scopes are fine, but the store's plan does not include the feature this request needs — today, Advanced Reports for compare=previous_period and GET /reports/top-products, and product videos for every video endpoint and for videos[] on POST /products. A new key will not help; the merchant has to upgrade the plan. The message names the feature. |
Request problems
| HTTP | Code | When |
|---|---|---|
| 404 | NOT_FOUND | No such record in the data this key can see. Also what a record belonging to another tenant returns. |
| 422 | VALIDATION_ERROR | A parameter or body field was missing, the wrong type, out of range, or not in the allowed set. Carries fields. |
| 400 | VALIDATION_ERROR | The one validation failure that stays 400: a bad Idempotency-Key header. It is missing on an endpoint that requires it, or it is sent empty or longer than 255 characters on any write. fields names the header. |
| 409 | CONFLICT | The write collides with an existing record — a duplicate unique value (two variants of a product with the same options among them), or a reference to something that is not there. |
| 413 | VALIDATION_ERROR | An uploaded file is larger than the endpoint allows (10 MB for any image), or one request's files total more than 50 MB. Only the multipart writes answer this: POST /products/{id}/images, the category and brand writes, and a video cover upload (POST /videos/{id}/poster, 8 MB for the part — the cover itself must be 2 MB or less, a 422). |
| 429 | RATE_LIMITED | Too many requests for this key in the current window — or, on a write that carries images, images that would pass the write limit (each counts as one write), a third such call while two are running for the key, or the company's daily 2,000 links used up — or a daily video cap: 50 uploads started per store, 20 imports per key. Every 429 carries Retry-After. See Rate limits. |
Ours, not yours
| HTTP | Code | When |
|---|---|---|
| 500 | INTERNAL | Something failed on our side. The message is always "Something went wrong on our end." — deliberately generic, because the alternative is leaking our internals into your logs. The real cause is recorded against the request_id. |
Retry a 500 once, with a short delay. If it persists, send us the
request_id.
On the write endpoints
| HTTP | Code | When |
|---|---|---|
| 400 | INSUFFICIENT_STOCK | Not enough stock to satisfy the request. fields.items names the offending line, with what was requested and what is available. |
| 400 | INVALID_STATUS_TRANSITION | The order cannot move from its current status to the requested one. fields.status says which move was refused. |
| 422 | IDEMPOTENCY_CONFLICT | The Idempotency-Key was reused with a different request body. |
| 409 | IDEMPOTENCY_IN_FLIGHT | The same Idempotency-Key is still being processed. Retry shortly, with the same key. |
| 409 | CONFLICT | A status change or cancel refused because the order's courier parcel is still live. fields.status is ["live_parcel"]. Before the courier picks the parcel up, cancel it in the dashboard (and on the courier's side) first; after pickup it can no longer be cancelled — ask for it back with a Return request in the dashboard, and the order moves when the courier returns it. Or a move to returned while a courier that reports pickups (Pathao, RedX, CarryBee, Bahok) has not reported picking the parcel up: fields.status is ["parcel_not_picked_up"]. Or a move to in_transit while the order's parcel is cancelled: fields.status is ["shipment_cancelled"] — book it again in the dashboard. |
Full detail on Writes & idempotency.
On the video endpoints
The /videos endpoints, and placing a video on a product, add their own codes.
Each tells you what to do next without parsing the message:
| HTTP | Code | When |
|---|---|---|
| 403 | VIDEO_UPLOADS_DISABLED | Video uploads are switched off for now: no upload, import, cover or placement (taking a video off a product still works). Nothing is wrong with the key or the plan; try again later. |
| 403 | PLAN_LIMIT_REACHED | The plan's video storage is full, or the product already shows its plan's number of videos. Delete videos you no longer need, or the merchant upgrades. Only video writes answer this: the plan's limit on the number of products still answers 403 FORBIDDEN_SCOPE on POST /products, as before. |
| 409 | TOO_MANY_OPEN_UPLOADS | This key already has 4 unfinished uploads or imports, or the store 6. Complete or delete one first. |
| 409 | FILE_CHANGED | A part was already signed with a different checksum, or a stored part does not match its checksum: this is not the same file. Start a new upload. |
| 409 | PARTS_INCOMPLETE | complete found parts missing from storage. fields.parts names them; send them and complete again. |
| 409 | UPLOAD_IS_IMPORT | Part URLs or complete on a video imported from a link: Shopdesk sends its parts. Poll it instead. |
| 409 | UPLOAD_VERIFYING | The video is still being checked. Wait until it is ready. |
| 409 | VIDEO_IN_USE | A live product shows the video you tried to delete. Take it off the product first. |
| 410 | UPLOAD_NOT_ACTIVE | The upload expired, was cancelled or already ended. Start a new one. |
| 422 | VIDEO_* | The file breaks a rule — VIDEO_HEVC, VIDEO_FRAGMENTED, VIDEO_TOO_LONG and eleven more — with how to fix it in fields.video. The full list is in Upload a product video. |
| 502 | STORAGE_UNAVAILABLE | Video storage did not answer. Retry in a moment. |
A video another key uploaded is 404 NOT_FOUND on every /videos endpoint,
like another store's data — unless your key was made by rotating that key,
which keeps its uploads.
Validation errors
fields maps each parameter to everything wrong with it. Ordinary validation
failures are normalised to 422, so there is one status meaning "your request
was wrong" — the exceptions are the three 400s named above, which carry
their own codes.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request was not valid.",
"fields": {
"limit": [
"limit must not be greater than 100",
"limit must be an integer number"
]
},
"request_id": "4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59"
}
}Request ids
Every request through the public API is stamped with an id, returned in the
X-Request-Id response header and included in the body of any error.
HTTP/1.1 404 Not Found
X-Request-Id: 4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59
Content-Type: application/jsonLog it on every failure. It is the only handle that finds your exact request in our logs — far more useful than a timestamp and an endpoint name, particularly for a sync that makes thousands of calls an hour.
You can also supply your own: send an X-Request-Id header and we will use
your value instead of generating one. That lets you carry a trace id from your
own system straight through ours, so one identifier spans both sides.
Handling errors well
- Switch on
code, with a default branch. New codes may be added in v1 — treat the list as open, and let anything unrecognised fall through to "unexpected error, log and alert". - Do not retry
4xx. A401,403,404or422will fail exactly the same way the second time. The only retryable statuses are429(wait forRetry-After) and500(once, briefly). - Distinguish
KEY_REVOKEDfromUNAUTHORIZEDin your alerting. A revoked key means a human deliberately turned your integration off; it should page someone, not silently retry forever. - Keep
request_idin your own error records, not just in a log line that rotates out after a week.