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

# Errors

> Every error code the API returns, when it happens, and the exact response body.

Failures return a stable machine-readable code:

```json theme={null}
{
  "error": {
    "code": "INVALID_INPUT",
    "message": "Invalid request",
    "details": {
      "formErrors": [],
      "fieldErrors": { "name": ["String must contain at least 1 character(s)"] }
    },
    "request_id": "req_4f2a9c1e8b7d4a5f9e3c2b1a8d7f6e5c"
  }
}
```

<Warning>
  Switch on `error.code`, never on `error.message`. Codes are part of the contract;
  messages are written for humans and may be reworded without notice.
</Warning>

## Every code

| Status | Code                 | When                                                                                                                                                                                |
| ------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `INVALID_INPUT`      | A query parameter or body field failed validation, the body was not valid JSON, or it exceeded the size limit.                                                                      |
| 401    | `INVALID_TOKEN`      | The Authorization header is missing or malformed, or the key is unknown, revoked, or expired. The message never says which.                                                         |
| 402    | `PLAN_LIMIT_REACHED` | Sending this invitation would exceed your plan quota for the current period.                                                                                                        |
| 403    | `INSUFFICIENT_SCOPE` | The key is valid but lacks the scope this endpoint requires. The message states what's needed, never what the key has.                                                              |
| 404    | `NOT_FOUND`          | No such resource, it belongs to another organization, or the id is not a UUID. All three answer identically on purpose — the API will not confirm that an id exists somewhere else. |
| 409    | `CONFLICT`           | The request conflicts with the current state of the resource.                                                                                                                       |
| 429    | `RATE_LIMITED`       | The key exhausted its hourly quota, or too many failed authentication attempts came from your IP. Honour the `Retry-After` header.                                                  |
| 500    | `INTERNAL_ERROR`     | A failure on our side. The body is deliberately generic; send us the `request_id` and we can find the exact call.                                                                   |

`details` appears on `400 INVALID_INPUT` (naming the offending fields) and on
`429 RATE_LIMITED` (carrying the limit and reset). Treat it as diagnostic detail for
logs and developers, not something to parse into user-facing copy.

## The ones worth designing for

<AccordionGroup>
  <Accordion title="404 is deliberately ambiguous" icon="ghost">
    A resource that doesn't exist, one that belongs to another organization, and an id that
    isn't a UUID all return the identical `404`. The API will not confirm that an id exists
    somewhere you can't see it.

    Practically: don't treat `404` as "deleted". It also means "never existed" and "not
    yours".
  </Accordion>

  <Accordion title="402 means the plan quota is spent" icon="credit-card">
    Only `POST /v1/assessments/{id}/invitations` can return it — quota is consumed when an
    invitation is sent, not when an assessment is created.

    Surface it as an admin action ("your plan's assessments for this period are used up"),
    not as a retryable failure. Retrying returns `402` until the period rolls over or the
    plan changes. Reproduce it in test mode by inviting `limit@example.com`.
  </Accordion>

  <Accordion title="429 carries Retry-After" icon="gauge-high">
    Honour the `Retry-After` header (seconds) rather than inventing a backoff. `details`
    also carries `limit` and `reset` if you'd rather compute your own.

    A `429` can also come from repeated failed authentication from your IP, in which case
    it's telling you to stop retrying a bad key.
  </Accordion>

  <Accordion title="500 is ours, and needs a request id" icon="bug">
    The body is deliberately generic — we don't leak internals into responses. Everything
    needed to diagnose it is in the `request_id`, so log that and send it to us.

    Safe to retry with backoff. If it's reproducible, it's a bug; tell us.
  </Accordion>
</AccordionGroup>

## Suggested handling

| Code                 | Retry? | Do                                                             |
| -------------------- | ------ | -------------------------------------------------------------- |
| `INVALID_INPUT`      | No     | Fix the request. `details` names the fields.                   |
| `INVALID_TOKEN`      | No     | Stop. Check the key is live, unexpired and unrevoked.          |
| `PLAN_LIMIT_REACHED` | No     | Alert an admin.                                                |
| `INSUFFICIENT_SCOPE` | No     | Mint a key with the scope the endpoint needs.                  |
| `NOT_FOUND`          | No     | Treat as absent. Don't distinguish deleted from never-existed. |
| `CONFLICT`           | Maybe  | Re-read the resource; its state changed under you.             |
| `RATE_LIMITED`       | Yes    | Wait `Retry-After`, then retry.                                |
| `INTERNAL_ERROR`     | Yes    | Exponential backoff. Keep the `request_id`.                    |

<Note>
  There is no idempotency key yet, so **a retried write can duplicate**. Retrying a
  timed-out invitation create may invite the candidate twice. Guard retries on your side
  until [idempotency](/changelog) ships.
</Note>
