# Error Responses

Every request the ODUS API rejects answers with the same JSON body, whatever went wrong:

400 Bad Request

```
{

  "statusCode": 400,

  "requestId": "dfc10de3-36bb-4bcc-b081-316216194033",

  "message": "amount must be greater than 0",

  "type": "invalid_request_error",

  "code": "validation_failed",

  "errors": ["amount must be greater than 0"]

}
```

| Field        | Type       | Description                                                                                                                  |
| ------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `statusCode` | `number`   | The HTTP status code, repeated in the body.                                                                                  |
| `requestId`  | `string`   | Identifier for this request. Quote it in a support ticket — it is what we search the logs by.                                |
| `type`       | `string`   | **Always present.** The kind of failure. This is the field to branch on. See [Error types](#error-types).                    |
| `message`    | `string`   | Human-readable description, written for a developer reading logs. Not stable — do not match on it.                           |
| `code`       | `string`   | Optional, finer detail where it adds something `type` does not. See [Error codes](#error-codes).                             |
| `errors`     | `string[]` | Present on a validation failure. Every problem found, not just the first. Field-specific entries are prefixed with the path. |

## Branch on `type`

`message` is prose. We reword it whenever a clearer phrasing turns up, and it is deliberately not a contract — matching on it will break.

`statusCode` is the opposite problem: a `400` covers everything from a missing field to a resource in the wrong state, so it cannot tell you which of those happened. A decline has a status of its own, `402`, but the rest of the coarseness stays.

`type` is the stable value in between, and it is on **every** error response including `5xx`. The set is closed and every member is permanent, so you can handle it exhaustively.

```
const response = await fetch(`${BASE_URL}/v2/payments/${id}/authorize`, {

  method: 'POST',

  headers: { Authorization: `Bearer ${secretKey}` },

  body: JSON.stringify(payload),

});



if (!response.ok) {

  const error = await response.json();



  switch (error.type) {

    case 'invalid_request_error':

      // Your request is wrong. `error.errors` says how.

      return showFieldErrors(error.errors);

    case 'invalid_state_error':

      // Your request is fine; the resource is not in a state that allows it.

      return refreshPayment(id);

    case 'payment_error':

      // The payment was refused. Nothing is wrong with the request — read the

      // payment for the reason, with a secret key.

      return askForAnotherPaymentMethod();

    case 'conflict_error':

      // Retry with backoff only when the resource was held in flight. An

      // idempotency conflict never succeeds with a different body, and a stale

      // revision never succeeds until you read the resource again.

      return error.code === 'resource_locked' ? retryLater() : fail(error);

    case 'rate_limit_error':

      return retryWithBackoff();

    case 'api_error':

      // Our side, and `statusCode` is what tells you which of the three you have.

      if (error.statusCode === 503) return retryWithBackoff(); // transient dependency failure

      if (error.statusCode === 504) return reconcile(id); // may still have taken effect

      return reportFailure(error.requestId); // 500 — retrying gets the same answer

    default:

      return reportFailure(error.requestId);

  }

}
```

## Error types

Closed set. Every error response carries exactly one.

| `type`                  | Status | Meaning                                                                                                                              |
| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `invalid_request_error` | `400`  | The request is malformed, missing a field, or fails a field-level rule. Fix the request.                                             |
| `authentication_error`  | `401`  | No API key was supplied, or the key is not recognised.                                                                               |
| `permission_error`      | `403`  | The key is valid but does not carry the permission this operation requires.                                                          |
| `not_found_error`       | `404`  | No resource exists with the requested identifier, or it is not visible to this key.                                                  |
| `invalid_state_error`   | `400`  | The request is well-formed and permitted, but the resource is not in a state that allows it.                                         |
| `conflict_error`        | `409`  | The request collides with another — a replayed idempotency key, a resource held in flight, or a revision that is out of date.        |
| `rate_limit_error`      | `429`  | Too many requests. Back off and retry. See [Rate limits](#rate-limits).                                                              |
| `payment_error`         | `402`  | The payment was refused by the issuer, the processor, or risk.                                                                       |
| `api_error`             | `5xx`  | Something went wrong on our side, or a dependency was briefly unavailable. See [`api_error` is three statuses](#api-error-statuses). |

### `invalid_request_error` versus `invalid_state_error`

Both are `400`, and telling them apart is most of the reason `type` exists.

`invalid_request_error` means **fix the request**: a field is missing, malformed, or out of range. `errors` names each one.

`invalid_state_error` means **the request was fine, but the resource was not**. Capturing a payment that is already captured, cancelling one that has settled, retrying one that was never set up to retry. Nothing about the request will make it succeed — the resource has to change first. Read it before you retry.

### `payment_error` is not your fault either

A refused payment is not a bad request. Your payload was correct and the platform worked; the issuer said no. Retrying the identical request will get the identical answer — offer another payment method instead.

**A decline answers `402 Payment Required`.** Usually the issuer, the processor or risk refused it, but a payment that never reached an authorization answers `402` too. Treat `402` as "the payment did not go through" and `400` as "the request was not accepted".

v1 answers the same decline with `400`, and `type` is `payment_error` in both versions. So an integration moving from v1 that branches on `type` needs no change; one that branches on the status does. See [Upgrading from v1 to v2](https://docs.odus.com/docs/guides/upgrading/v1-to-v2.md).

To find out *why* it was refused, read the payment: `outcome.errorCode` on the transaction carries the normalised decline reason, along with `declineType` and `retryStrategy`. See [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md) under Payments. Those fields require a secret key — they are not returned to a checkout key, and the error body does not carry them.

## Error codes

`code` is the **finer** level, present only where it distinguishes something `type` cannot. Most errors carry no `code` at all.

| `code`                 | Under                   | Meaning                                                                                                                                                                          |
| ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `validation_failed`    | `invalid_request_error` | The request failed schema or field validation; `errors` lists every problem.                                                                                                     |
| `idempotency_conflict` | `conflict_error`        | The `X-Idempotency-Key` was already used with a different body. Retrying will not help.                                                                                          |
| `resource_locked`      | `conflict_error`        | Another in-flight request holds the resource. Retry with backoff.                                                                                                                |
| `timeout`              | `api_error`             | The request exceeded the time limit and was abandoned. It **may still have taken effect**.                                                                                       |
| `api_version_sunset`   | `invalid_request_error` | Answered with `410`: the request used an API version that is no longer served. Send it under a current version. Retrying will not help.                                          |
| `stale_revision`       | `conflict_error`        | The request named a `revision` that is no longer the resource's current one, so nothing was changed. Read the resource again before you retry. Retrying unchanged will not help. |

warning

**The set of codes is open and grows over time.** New codes appear as we add payment methods and processors. Treat a `code` you do not recognise as if it were absent and fall back to `type`, which is closed and will not surprise you. Never fail on an unfamiliar `code`.

`payment_error` carries no `code` today — the decline reason is a secret-key field, and the error body has no equivalent of the key-based gating the payment resource has.

The `409`s are the clearest case for why `code` exists: `resource_locked` succeeds if you retry with backoff, while `idempotency_conflict` and `stale_revision` never will. The status cannot tell you which you have.

`timeout` is the equivalent on `5xx`. A plain `api_error` means the request did not take effect; `code: "timeout"` means it might have. Reconcile with a `GET` rather than retrying blindly, or retry with the same `X-Idempotency-Key`.

## `api_error` is three statuses, not one

`type` alone does not tell you whether to retry a `5xx`. The status does:

| Status | Meaning                                                                                                                  | What to do                                                                              |
| ------ | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| `500`  | Something failed on our side while serving the request.                                                                  | Retrying the same request usually gets the same answer. Report it with the `requestId`. |
| `503`  | A dependency we needed was briefly unavailable. Nothing about your request is wrong, and it never reached the operation. | **Retry with backoff.** This is the one `5xx` that is worth retrying as-is.             |
| `504`  | The request exceeded our processing limit and was abandoned. It **may still have taken effect**.                         | Reconcile with a `GET`, or retry with the same `X-Idempotency-Key`.                     |

## Rate limits

A request over a rate limit is answered with `429` and `type` `rate_limit_error`. Back off, then retry when the response tells you to.

Don't build your integration around a fixed number of requests. An integration that backs off on every `429` keeps working when a limit changes.

### Limits can change

We set rate limits, and any limit on concurrent requests, at our discretion. We can change them at any time, on these terms:

* **Increases take effect without notice.** Your integration needs no change when a limit goes up.
* **Reductions are announced in the [changelog](https://docs.odus.com/changelog) at least 30 days before they take effect.** That gives you time to lower your request rate before the new limit applies.
* **Protecting the platform is the exception.** If traffic threatens the API's availability, for example abuse or an incident in progress, we can lower a limit for that traffic immediately and without notice.

## Declines versus errors

A payment the issuer declines still creates a transaction, and its full detail lives on the `outcome` object — `errorCode`, `declineType`, `retryStrategy`, `safeErrorMessage`. That is richer than anything the error body carries, and it is the right place to read from when you have a payment to inspect. See [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md).

The error body's `payment_error` exists so you can tell a decline from a bad request *without* a second call.
