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

```json
{
  "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.     |

> **Warning — Branch on code, never on message**
>
> `code` is part of the contract and will not change without a version bump.
> `message` is human copy and may be reworded at any time, in any release. Code
> that does `if (error.message === "Order not found.")` is code that breaks on
> a typo fix.

There is no `success` field and no envelope around the error. The HTTP status
and the `code` are the answer.

> **Note — This differs from the dashboard API on purpose**
>
> If you have seen our internal API, it wraps everything in
> `{ success, message, data, meta }`. The public API deliberately does not.
> That `message` is UI copy — it gets reworded whenever a designer asks, which
> is fine for a screen and fatal for something your code branches on. Here the
> machine-readable code leads and the message is strictly human-facing.

## 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](https://www.shopdeskbd.com/developers/rate-limits.md). |

> **Note — 404 rather than 403 for another tenant's data**
>
> Asking for an id that belongs to a different store or company returns `404`,
> identical to an id that does not exist at all. A `403` would confirm the
> record is real, which turns the endpoint into an oracle for enumerating other
> merchants' ids. Every repository query is scoped by tenant before it runs, so
> there is nothing to leak in the first place.

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

> **Note — These two are 400, not 422**
>
> `INSUFFICIENT_STOCK` and `INVALID_STATUS_TRANSITION` come from the domain
> services with their own status, and it is deliberately not rewritten on the
> way out. Branch on the `code`, which is stable, rather than on the status —
> which is the advice for every error here, and the reason `code` exists.

Full detail on [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md).

### 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](https://www.shopdeskbd.com/developers/guide-upload-a-product-video.md#reject-codes).                                 |
| 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 `400`s named above, which carry
their own codes.

```json
{
  "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
HTTP/1.1 404 Not Found
X-Request-Id: 4f1c9b02-7d3e-4a85-9c16-b0e2f7a34d59
Content-Type: application/json
```

**Log 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`.** A `401`, `403`, `404` or `422` will fail exactly the
  same way the second time. The only retryable statuses are `429` (wait for
  `Retry-After`) and `500` (once, briefly).
- **Distinguish `KEY_REVOKED` from `UNAUTHORIZED` in your alerting.** A revoked
  key means a human deliberately turned your integration off; it should page
  someone, not silently retry forever.
- **Keep `request_id` in your own error records**, not just in a log line
  that rotates out after a week.
