Every failure returns the same shape:

```json
{
    "error": {
        "type": "invalid_request_error",
        "code": "email_already_registered",
        "message": "This email address already has a reserved referral code.",
        "param": "email",
        "doc_url": "https://proppertrading.com/docs/api/errors#email_already_registered",
        "request_id": "req_01kz4bkr1aj1gv63d8fv3133ey"
    }
}
```

:::warning Branch on code, never on message
`code` is stable forever once it is published here. `message` is prose for a
human reading your logs and may be reworded at any time.
:::

`param` is present when one specific input caused the failure.

## Types

`type` is a coarse bucket, so an unfamiliar `code` can still be handled sensibly.

| Type                    | Status                  | What to do                               |
| ----------------------- | ----------------------- | ---------------------------------------- |
| `authentication_error`  | 401                     | Fix the credential                       |
| `permission_error`      | 403                     | The key needs a wider scope              |
| `invalid_request_error` | 400, 404, 405, 415, 422 | Fix the request                          |
| `idempotency_error`     | 409                     | See [Idempotency](/docs/api/idempotency) |
| `rate_limit_error`      | 429                     | Back off and retry after the window      |
| `api_error`             | 500                     | Our problem. Safe to retry               |

## Validation

A failed validation returns 422 with every field error, not just the first:

```json
{
    "error": {
        "type": "invalid_request_error",
        "code": "validation_failed",
        "message": "The email address is not valid.",
        "param": "email",
        "errors": { "email": ["The email address is not valid."] },
        "request_id": "req_01kz4bkr1aj1gv63d8fv3133ey"
    }
}
```

## Codes

### Authentication

- `missing_api_key` - no `Authorization` header was sent.
- `invalid_api_key` - the credential does not exist or the secret does not match.
- `revoked_api_key` - the key was revoked in the dashboard.
- `expired_api_key` - the key passed its expiry date.
- `ip_not_allowed` - the key has an IP allowlist and this address is not on it.

### Authentication as a person

These only come back on the [app endpoints](/docs/api/apps/overview). All of them
mean the same thing to a client: send the person back to the sign-in screen.

- `invalid_token` - not a token, or the secret does not match.
- `revoked_token` - the device was signed out, here or from another one.
- `expired_token` - ninety days without a request, or a set expiry passed.
- `invalid_login` - wrong address, wrong password, or no such account in that
  environment. The three are deliberately indistinguishable.
- `account_deactivated` - the account exists and has been switched off.
- `invalid_two_factor_code` - wrong code. The challenge survives.
- `expired_two_factor_challenge` - the challenge expired, was already used, or
  was burned by five wrong codes. Start again from the password.

### Permission

- `insufficient_scope` - the credential is valid but lacks the required scope.
  The response includes `required_scope`.
- `two_factor_setup_required` - the firm requires two-factor and this account has
  none. It has to be set up on the web first.
- `two_factor_method_unavailable` - the account signs in with a passkey only,
  which an app cannot present yet.
- `block_anonymizer` - the firm blocks VPN, proxy and Tor connections.
- `block_country` - the firm blocks connections from this country.
- `mailbox_not_permitted` - the account may not open a mailbox at all. A
  permission of the environment, not a scope of the device.
- `channel_not_allowed` - an app asked to listen to a websocket channel that is
  not one of the two it may have.
- `not_your_note` - only the person who wrote a note may change or remove it.
  `manage_mailbox` is the exception, for tidying up after somebody who left.

### Request

- `workspace_not_found` - no environment is served from that address.
- `invalid_folder` - not one of `inbox`, `drafts`, `sent`, `spam`, `trash`.
- `not_in_trash` - a message can only be erased out of the bin. Move it there
  first.
- `not_a_draft` - `draft_id` points at a message that is not a draft.
- `missing_since` - the mail sync needs a `since`.
- `invalid_since` - not a timestamp and not a handle we handed out.
- `sync_window_expired` - the client has been away longer than the window in
  which deletions are kept. Rebuild the cache rather than syncing.
- `nothing_to_mark` - marking notifications read without saying which.
- `validation_failed` - one or more fields are invalid. See `errors`.
- `invalid_json` - the body is not valid JSON.
- `unsupported_content_type` - the body is not `application/json`.
- `unknown_endpoint` - that URL is not an endpoint.
- `resource_not_found` - the endpoint is fine, the id does not exist. Note that a
  resource belonging to another firm also returns this.
- `method_not_allowed` - wrong HTTP method for this endpoint.
- `invalid_limit` - `limit` is not a positive integer.
- `invalid_cursor` - `starting_after` does not match any resource.
- `missing_idempotency_key` - a POST without an `Idempotency-Key` header.
- `invalid_idempotency_key` - the header is longer than 255 characters.

### Idempotency

- `idempotency_key_reuse` - the key was already used with a different body.
- `idempotent_request_in_flight` - an earlier request with this key is still
  running. Retry shortly.

### Affiliates

- `pre_registration_disabled` - the firm has not enabled pre-registration.
- `email_already_registered` - that address already has a reserved code.
- `pre_registration_not_pending` - the reservation was already claimed or
  withdrawn.

### Other

- `rate_limit_exceeded` - see [Rate limits](/docs/api/rate-limits).
- `tenant_in_maintenance` - the firm is closed. Answered with `503`; everyone
  belonging to it is locked out until it reopens.
- `send_failed` - the mail provider would not take the message. It is still
  saved as a draft, so offer to try again rather than saying it is gone.
- `internal_error` - something broke on our side. Retry, and send us the
  `request_id` if it keeps happening.