# Errors

> The error shape, every error code and how to retry safely.

Errors use normal HTTP status codes and a JSON body with a stable `code` you can check in your code, and a `message` written for people.

```json
{
  "error": {
    "code": "validation_error",
    "message": "Some fields are invalid.",
    "issues": [
      {
        "path": "products.0.listPrice",
        "message": "Too small: expected number to be >=0"
      }
    ]
  }
}
```

`issues` is only there for `validation_error`. Each issue’s `path` points at the field, with list positions counted from 0.

## Error codes

| Status | Code                  | What happened                                                                            | What to do                                                               |
| ------ | --------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 400    | `validation_error`    | A query parameter or body field is missing or invalid.                                   | Fix the fields listed in `issues`.                                       |
| 400    | `invalid_json`        | The body isn’t valid JSON.                                                               | Send JSON with `Content-Type: application/json`.                         |
| 400    | `invalid_request`     | The request is valid but not allowed right now, e.g. marking a new order as completed.   | Read `message`; don’t retry unchanged.                                   |
| 401    | `unauthorized`        | Missing, unknown or revoked API key.                                                     | Check the `Authorization: Bearer` header and that the key is still live. |
| 401    | `key_owner_removed`   | The person who made the key has left the workspace.                                      | Make a new key from someone who still has access.                        |
| 403    | `insufficient_scope`  | A read-only key tried to write.                                                          | Use a Read & write key.                                                  |
| 403    | `read_only`           | API writes are unavailable on the workspace’s current Free entitlement.                  | The owner manages the plan on the website. Existing keys still read.     |
| 403    | `forbidden`           | The person who made the key isn’t allowed to do that.                                    | Ask a workspace owner, or make a key from someone with that permission.  |
| 403    | `workspace_suspended` | TradeCatalog has suspended the workspace. The owner has been emailed why.                | Keys work again when the suspension is lifted.                           |
| 404    | `not_found`           | No such endpoint, or no such product, customer, order or price change in this workspace. | Check the id, SKU or account code.                                       |
| 405    | `method_not_allowed`  | The path exists but not with this method.                                                | Use a method from the `Allow` header.                                    |
| 429    | `rate_limited`        | More than 120 requests a minute with this key.                                           | Wait for `Retry-After` seconds (60), then retry.                         |
| 500    | `internal_error`      | Something went wrong on our side.                                                        | Retry with backoff. If it keeps happening, contact support.              |

Ids from another workspace always give `404 not_found`, never someone else’s data.

## Retrying

Retry `429` and `5xx` responses with backoff. Don’t retry other `4xx` responses: they’ll fail the same way.

All API writes are safe to repeat: `PUT` matches rows by SKU or account code, and `PATCH /orders/{id}` fails with `invalid_request` if the order has already moved on. So a retry after a network timeout won’t duplicate anything.

```js
async function api(path, init = {}, attempt = 0) {
  const res = await fetch(`https://tradecatalog.app/api/v1${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${process.env.TRADECATALOG_API_KEY}`,
      'Content-Type': 'application/json',
      ...init.headers,
    },
  })
  if ((res.status === 429 || res.status >= 500) && attempt < 5) {
    const wait = Number(res.headers.get('Retry-After')) || 2 ** attempt
    await new Promise((resolve) => setTimeout(resolve, wait * 1000))
    return api(path, init, attempt + 1)
  }
  const body = await res.json()
  if (!res.ok) {
    const error = new Error(`${body.error.code}: ${body.error.message}`)
    error.status = res.status
    error.code = body.error.code
    error.issues = body.error.issues
    throw error
  }
  return body
}
```

## Common messages

| Request                                       | Status and message                                          |
| --------------------------------------------- | ----------------------------------------------------------- |
| `GET /prices` with an unknown SKU             | `404` “No product with SKU HX-9999”                         |
| Any endpoint with an unknown customer         | `404` “Customer OAK99 not found”                            |
| `PATCH /orders/{id}` new → completed          | `400` “An order that is received can’t be marked completed” |
| `PATCH /orders/{id}` to canceled with no note | `400` “Give a reason for canceling”                         |
| `PUT /products` with the same SKU twice       | `400` validation error “Duplicate SKU hx-8510”              |
