> ## Documentation Index
> Fetch the complete documentation index at: https://docs.loci.ng/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> One response envelope, one error shape, and every code the four calls return.

Every response has the same outer shape, so error handling is written once.

<CodeGroup>
  ```json Success theme={null}
  { "data": { "id": "2163551", "geometry": { "…": "…" }, "bbox": [7.48751, 9.05143, 7.5003, 9.06201] } }
  ```

  ```json Failure theme={null}
  { "error": { "code": "not_found", "message": "no frame contains that point" } }
  ```
</CodeGroup>

A response carries `data` or `error`, never both. `code` is stable and
machine-readable; branch on it. `message` is for people, may be reworded
between releases, and for some codes is the underlying parser's own text;
never match on it.

## Unknown fields are rejected

The body of `POST /v1/frames/insight` is decoded strictly. A misspelt field
is a `400`, not a field quietly ignored:

```json theme={null}
{ "error": { "code": "invalid_body", "message": "json: unknown field \"questoin\"" } }
```

The same code covers malformed JSON and a body over 1 MiB. This is on your
side: a rejected body is a bug you fix in a minute, and an ignored field is a
bug you find in three months.

## Every code

| Code | Status | Returned by | Means | Do this |
| - | - | - | - | - |
| `unauthorized` | 401 | all | No key was sent. | Send `Authorization: Bearer loci_sk_…`. |
| `invalid_key` | 401 | all | The key is unknown, expired or revoked. Not treated as anonymous. | Check its expiry; mint a new one. |
| `insufficient_scope` | 403 | all | The key is valid but lacks the capability named in the message. | Mint a key with a wider preset. |
| `invalid_point` | 400 | `/v1/frames/at` | `lat` or `lon` missing, not a number, or out of range. | Send both, in degrees. |
| `invalid_bbox` | 400 | `/v1/frames` | `bbox` missing or not four numbers in a sensible order. | Send `minLon,minLat,maxLon,maxLat`. |
| `invalid` | 400 | `/v1/frames`, `/v1/frames/data`, `/v1/frames/insight` | `ids` or `frameIds` missing or over the cap, or an unknown `format`. The message says which. | Read the message. |
| `invalid_body` | 400 | `/v1/frames/insight` | Unparseable JSON, an unknown field, or a body over 1 MiB. | Fix the body. |
| `too_long` | 400 | `/v1/frames/insight` | The question is over 1,000 characters. | Shorten it. |
| `not_found` | 404 | `/v1/frames/at` | The point lies outside the grid. | Nothing; there is no frame there. |
| `rate_limited` | 429 | all | A budget is spent. `Retry-After` says how long to wait. | Wait exactly that long. |
| `internal` | 500 | all | Something failed on the server. The message is deliberately vague. | Retry once; if it persists, report the time and path. |

Two outcomes are not errors and arrive as `200`:

* A frame id that `GET /v1/frames/data` does not know comes back with no
  bounding box and an empty `datasets` list.
* A dataset licensed per query that the organisation's credits could not
  cover is left out of a data answer and named in `leftOut` with the reason,
  so the rest of the answer still arrives.

## Timeouts

Requests have a 30-second budget. A timed-out request does not reliably
produce an error envelope, so handle a non-JSON body on a `5xx`. Ordinary
errors always carry one.

## Handling errors well

```ts theme={null}
type Envelope<T> =
  | { data: T }
  | { error: { code: string; message: string } };

async function call<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`https://api.staging.loci.ng${path}`, {
    ...init,
    headers: { ...init?.headers, Authorization: `Bearer ${process.env.LOCI_KEY}` },
  });

  let body: Envelope<T> | null = null;
  try {
    body = await res.json();
  } catch {
    // A timeout or a proxy error may not be JSON at all.
  }

  if (!res.ok) {
    const code = body && "error" in body ? body.error.code : "unknown";
    switch (code) {
      case "invalid_key":
      case "insufficient_scope":
        // Terminal: the credential is dead or the wrong shape. Do not retry it.
        throw new DeadCredential(code);
      case "rate_limited":
        // Wait Retry-After seconds, then try again.
        throw new Backoff(Number(res.headers.get("Retry-After") ?? 5));
      case "internal":
        throw new Retryable(code);
      default:
        // Every other 4xx is a request you need to change.
        throw new ApiError(code, res.status);
    }
  }
  return (body as { data: T }).data;
}
```

Retry only `internal`, `rate_limited` after its wait, and transport failures.
Every other code is terminal until the request or the credential changes.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.