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

### 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 case that stays `400`: a required `Idempotency-Key` header is absent. `fields` names the header.                     |
| 409  | `CONFLICT`         | The write collides with an existing record — a duplicate unique value, or a reference to something that is not there.        |
| 429  | `RATE_LIMITED`     | Too many requests for this key in the current window. See [Rate limits](https://www.salafems.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**.                                       |

> **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.salafems.com/developers/idempotency.md).

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