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. Confirming
  an authenticator on a signed-in account answers the same code as a 400.
- `expired_two_factor_challenge` - the challenge expired, was already used, or
  was burned by five wrong codes. Start again from the password.
- `expired_workspace_choice` - the choice between environments expired or was
  already used. Start again from the password.
- `admin_access_required` - signing in to the Propper app, or using
  [its endpoints](/docs/api/admin/overview), with an account that has no part in
  running the environment: a trader, or staff whose role grants nothing.

### 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, except in the Propper app, which
  [sets it up](/docs/api/apps/sign-in#response-two-factor-to-set-up) during
  sign-in.
- `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 six 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.
- `workspace_not_permitted` - the `Propper-Workspace` header names an
  environment this account cannot act in, or one that does not exist. The two
  answer the same.
- `forbidden` - the account lacks the permission this action takes, such as
  `manage_mailbox` for blocking senders. Signing in again changes nothing. On
  the [admin endpoints](/docs/api/admin/overview) the response includes
  `required_permission`.
- `platform_required` - an admin endpoint of the platform itself, asked by
  somebody who is not our staff or from inside a firm.
- `assist_unavailable` - the environment has no AI for the
  [writing help](/docs/api/mail/assist): none is connected, or it is switched
  off. Waiting does not change it.
- `ai_unavailable` - the firm has [the assistant](/docs/api/apps/ai-overview)
  switched off, or not for this kind of person. Waiting does not change it.
- `ai_kyc_required` - a trader whose account is locked until the identity
  check is done. Finishing it on the website opens the assistant again.

### Request

- `workspace_not_found` - no environment is served from that address, or the
  environment picked after a sign-in was not one of those offered.
- `workspace_required` - an admin endpoint that works on one firm, asked by our
  own staff without naming one in the `Propper-Workspace` header.
- `invalid_folder` - not one of `inbox`, `archive`, `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`, or the draft in the path, is a message that is not a draft.
- `invalid_sender` - not an address or a domain that can be blocked, or a
  message with no sender address.
- `own_sender` - an environment cannot block its own addresses or domains.
- `invalid_attachment` - the file's `data` is not base64, or it is empty.
- `attachment_too_large` - the file is bigger than a draft, or a question to
  the assistant, may carry.
- `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.
- `invalid_avatar` - a picture that is not base64, is empty, or is not a JPEG,
  PNG or WebP.
- `avatar_too_large` - a picture over 2 MB.
- `invalid_current_password` - changing the password without the right
  current one.
- `two_factor_already_enabled` - setting up an authenticator on an account that
  has one. Turn it off first.
- `two_factor_not_started` - confirming an authenticator without asking for a
  secret first.
- `two_factor_not_enabled` - recovery codes for an account without an
  authenticator.
- `last_two_factor_method` - removing the only second factor of an account
  that must keep one.
- `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.
- `invalid_sort` - `sort` names something this list does not sort on.
- `invalid_kind` - `kinds` names a kind this list does not have, or a kind of
  notice the account does not receive.
- `invalid_period` - `period` is not one of the periods the dashboard offers.
- `missing_idempotency_key` - a POST without an `Idempotency-Key` header.
- `invalid_idempotency_key` - the header is longer than 255 characters.

### Payouts

Each one is a `409` unless it says otherwise: the request was well formed,
and the payout is no longer where the person deciding thought it was. Read it
again before deciding.

- `payout_already_reviewed` - approving a payout that is no longer pending.
- `payout_consistency_failed` - approving a payout whose account fails the
  consistency rule as it is now. `consistency` carries the best day, the total
  profit, the ratio, the limit and the profit at which it would pass.
- `payout_already_settled` - rejecting a payout that was already paid or
  rejected.
- `payout_not_approved` - marking paid a payout that is not approved.
- `payout_balance_too_low` - marking paid a payout larger than what the
  account holds now.
- `payout_held` - approving or marking paid a payout of a trader whose
  payouts the team holds from a fraud review. Rejecting still works. The hold
  is released on the Fraud screens, not through the API.
- `ticket_not_traders` - linking a ticket of another trader to a payout. A
  `422`.
- `trader_gone` - opening a ticket about a payout whose trader deleted their
  account.
- `trader_has_open_ticket` - opening a ticket about a payout while the trader
  has an open one. Link that one instead.

### Tickets

Each one is a `409` unless it says otherwise: the request was well formed,
and the ticket or the firm does not allow it as things stand.

- `ticket_ended` - answering, assigning, renaming, blocking the assistant on,
  resolving or closing a ticket that was already resolved or closed. Only its
  tags still change. A new question becomes a new ticket.
- `assistant_unavailable` - asking the assistant for a reply while the firm
  has switched it off for its team.
- `trader_has_open_ticket` - opening a ticket with a trader who has an open
  one, since a trader has one at a time. Opened directly, the error names that
  ticket in `ticket`, with `id` and `subject`. Opened from a payout, see
  Payouts above.
- `invalid_image` - a picture that is not base64, is empty, or is not a JPEG,
  PNG, GIF or WebP. A `400`.
- `image_too_large` - a picture over 10 MB once decoded. A `400`.

### Fraud

- `override_exists` - creating a trader override for a trader who already has
  one on that detector. A `409`, naming that override in `override`. Change
  that one instead.

### 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).
- `assist_limit_reached` - the environment's AI allowance is used up. Answered
  with `429`, with `state` (`session`, `week`, `none` or `platform`) saying
  which, and `resets_at` saying when it comes back. `resets_at` is absent when
  waiting will not help.
- `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.
- `ai_limit_reached` - a question to [the assistant](/docs/api/apps/ai-overview)
  was refused because an allowance is used up. Answered with `429`, with
  `state` saying which: `session` or `week` for the firm's AI allowance, `none`
  for a package without AI, `platform` for our own capacity, and `daily_limit`
  for the firm's limit per trader. `resets_at` says when it comes back, and is
  absent when waiting will not help.
- `ai_answer_in_progress` - a question in a conversation whose previous answer
  is still being written. A `409`. Wait for it or stop it, then ask again.
- `assist_failed` - the model behind the writing help did not answer.
  Answered with `503`. Nothing changed, so asking again is fine.
- `internal_error` - something broke on our side. Retry, and send us the
  `request_id` if it keeps happening.