# 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. |
| 403    | `insufficient_scope` | A read-only key tried to write.                                                          | Use a Read & write key.                                                  |
| 403    | `read_only`          | The workspace’s free trial or subscription has ended, so it’s read-only.                 | The owner subscribes in Settings. Reads still work.                      |
| 403    | `forbidden`          | Not allowed for this key.                                                                | Contact support if you think this is wrong.                              |
| 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 cancelled with no note | `400` “Give a reason for cancelling”                        |
| `PUT /products` with the same SKU twice        | `400` validation error “Duplicate SKU hx-8510”              |
