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

# Errors

> One envelope, machine-readable codes, and a request ID for every failure.

Every error — validation, authentication, business rule, or unexpected — returns the
same shape. You never have to branch on the response format.

```json theme={null}
{
  "error": {
    "code": "INSUFFICIENT_SCOPES",
    "message": "This API key does not have the required permissions.",
    "details": [
      { "field": "scopes", "issue": "Missing required scope: payouts:write" }
    ],
    "request_id": "req_01HX..."
  }
}
```

| Field        | Description                                                             |
| ------------ | ----------------------------------------------------------------------- |
| `code`       | Stable, machine-readable identifier. Branch on this.                    |
| `message`    | Human-readable summary. Safe to log; do not parse.                      |
| `details`    | Field-level problems, each with a `field` and an `issue`. May be empty. |
| `request_id` | Echoes your `X-Request-Id`. Quote it in support requests.               |

<Warning>
  Branch on `code`, never on `message`. Messages are wording and may be reworded at
  any time; codes are part of the contract and only change with a version bump.
</Warning>

## Status codes

| Status | Meaning                                               | What to do                                                   |
| ------ | ----------------------------------------------------- | ------------------------------------------------------------ |
| `400`  | Validation or business-rule failure                   | Fix the request. Retrying unchanged fails identically.       |
| `401`  | Missing or invalid API key                            | Check the key. See [Authentication](/guides/authentication). |
| `403`  | Valid key, insufficient scope, or KYB not approved    | Fix configuration. Never retry.                              |
| `404`  | Not found, or not yours                               | Check the ID. Another account's records read as `404`.       |
| `409`  | Conflict — duplicate in flight, or invalid state      | Back off and retry.                                          |
| `422`  | Semantically invalid — expired quote, bad destination | Re-derive the input.                                         |
| `429`  | Rate limited                                          | Back off. `details` carries the retry delay.                 |
| `500`  | Unexpected failure on our side                        | Retry with the same idempotency key. Quote the `request_id`. |
| `503`  | An upstream service is unreachable                    | Transient. Retry with backoff.                               |

## Retry matrix

| Retryable                  | Not retryable                     |
| -------------------------- | --------------------------------- |
| `409`, `429`, `500`, `503` | `400`, `401`, `403`, `404`, `422` |

Retry the left column with backoff, **reusing the same `Idempotency-Key`**. The
right column needs the request or your configuration to change first — a retry loop
on a `422` is just a slower failure.

## Every code

### Authentication and authorisation

| Code                      | Status |
| ------------------------- | ------ |
| `MISSING_API_KEY`         | `401`  |
| `INVALID_API_KEY`         | `401`  |
| `API_KEY_INACTIVE`        | `401`  |
| `API_KEY_REVOKED`         | `401`  |
| `API_KEY_EXPIRED`         | `401`  |
| `TENANT_INACTIVE`         | `401`  |
| `INSUFFICIENT_SCOPES`     | `403`  |
| `COMPLIANCE_NOT_APPROVED` | `403`  |
| `RATE_LIMIT_EXCEEDED`     | `429`  |

### Idempotency

| Code                      | Status | Meaning                               |
| ------------------------- | ------ | ------------------------------------- |
| `MISSING_IDEMPOTENCY_KEY` | `400`  | No `Idempotency-Key` on a write       |
| `REQUEST_IN_PROGRESS`     | `409`  | The original request is still running |

### Payouts

| Code                          | Status | Meaning                                           |
| ----------------------------- | ------ | ------------------------------------------------- |
| `PAYOUT_NOT_FOUND`            | `404`  | No such payout for your account                   |
| `REFUND_DESTINATION_REQUIRED` | `422`  | No refund destination for the settlement currency |
| `UNSUPPORTED_CORRIDOR`        | `422`  | No corridor for that currency pair                |
| `SETTLEMENT_BELOW_MINIMUM`    | `422`  | Settlement amount under the corridor floor        |
| `PAYOUT_SANCTIONS_BLOCKED`    | `403`  | Blocked by sanctions screening                    |
| `VELOCITY_LIMIT_EXCEEDED`     | `422`  | Account or destination velocity limit hit         |

### Rates and quotes

| Code                   | Status | Meaning                                |
| ---------------------- | ------ | -------------------------------------- |
| `QUOTE_NOT_FOUND`      | `404`  | No such quote                          |
| `QUOTE_EXPIRED`        | `422`  | Past its TTL — lock a fresh one        |
| `QUOTE_NOT_ACCEPTABLE` | `409`  | Already accepted or used               |
| `RATE_UNAVAILABLE`     | `503`  | Could not price the corridor right now |

### Beneficiary lookup

| Code                              | Status | Meaning                                        |
| --------------------------------- | ------ | ---------------------------------------------- |
| `INVALID_DESTINATION_ACCOUNT`     | `422`  | No account with those details                  |
| `DESTINATION_ACCOUNT_INACTIVE`    | `422`  | Account exists but cannot receive              |
| `DESTINATION_TYPE_NOT_ENQUIRABLE` | `400`  | Crypto has no directory or name                |
| `ACCOUNT_NAME_MISMATCH`           | `422`  | Supplied name does not match the institution's |

### Settlement and refunds

| Code                               | Status | Meaning                                     |
| ---------------------------------- | ------ | ------------------------------------------- |
| `PAYMENT_INSTRUCTION_NOT_FOUND`    | `404`  | Not generated **yet**, or no active one     |
| `INFLOW_NOT_FOUND`                 | `404`  | No such settlement                          |
| `OUTFLOW_NOT_FOUND`                | `404`  | No such dispatch attempt                    |
| `REFUND_NOT_FOUND`                 | `404`  | No such refund                              |
| `UNSUPPORTED_REFUND_DESTINATION`   | `400`  | Refunds in that currency are not supported  |
| `BANK_ACCOUNT_VERIFICATION_FAILED` | `422`  | Name enquiry failed — destination not saved |
| `SETTLEMENT_TARGET_UNAVAILABLE`    | `503`  | Could not provision a settlement target     |

### Webhooks

| Code                     | Status | Meaning                            |
| ------------------------ | ------ | ---------------------------------- |
| `WEBHOOK_NOT_CONFIGURED` | `422`  | No webhook URL set for the account |
| `WEBHOOK_URL_INVALID`    | `400`  | Not a valid URL, or not HTTPS      |

### Platform

| Code                           | Status | Meaning                                       |
| ------------------------------ | ------ | --------------------------------------------- |
| `LIQUIDITY_ENGINE_UNAVAILABLE` | `503`  | Upstream pricing or payout engine unreachable |
| `INTERNAL_ERROR`               | `500`  | Unexpected failure. Never leaks internals.    |

<Note>
  Not every code you will see is an *API* error code. `DISPATCH_ERROR` and similar
  values appear as `failure_code` on an outflow or a refund record — they describe
  why an attempt failed downstream, and never come back as the `code` of an HTTP
  error response.
</Note>

<Note>
  `PAYMENT_INSTRUCTION_NOT_FOUND` is the one `404` that is routinely **not** an
  error. Instructions are generated asynchronously, so for a few seconds after
  creating a payout there genuinely is not one yet. Wait for
  `payout.pending_payment` rather than treating it as a failure.
</Note>

## Tracing a request

Send an `X-Request-Id` and it is echoed in the response and into every log line and
error we record. Generate one per attempt and log it next to your own record.

```bash theme={null}
curl -X POST "$SPENDIN_BASE_URL/v1/payouts" \
  -H "X-API-Key: $SPENDIN_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "X-Request-Id: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ ... }'
```

If you do not send one, `request_id` is reported as `unknown` — which makes a
support request considerably harder to answer.

<Warning>
  Use a **fresh** `X-Request-Id` per attempt and a **stable** `Idempotency-Key`
  across attempts of the same operation. They serve opposite purposes: one
  identifies this HTTP call, the other identifies the business operation behind it.
</Warning>

## Handling errors well

```typescript theme={null}
const response = await fetch(url, options);

if (!response.ok) {
  const { error } = await response.json();

  switch (error.code) {
    case 'REQUEST_IN_PROGRESS':
    case 'RATE_LIMIT_EXCEEDED':
    case 'RATE_UNAVAILABLE':
    case 'LIQUIDITY_ENGINE_UNAVAILABLE':
      return retryWithBackoff();                 // same idempotency key

    case 'QUOTE_EXPIRED':
      return lockFreshQuoteAndRetry();

    case 'REFUND_DESTINATION_REQUIRED':
    case 'COMPLIANCE_NOT_APPROVED':
    case 'INSUFFICIENT_SCOPES':
      // Configuration, not transport. Retrying will never help.
      throw new ConfigurationError(error.code, error.message);

    case 'INVALID_DESTINATION_ACCOUNT':
      return askUserToCorrectBeneficiary(error.details);

    default:
      logger.error({ code: error.code, request_id: error.request_id });
      throw new SpendinError(error);
  }
}
```

Log `code` and `request_id` on every failure. Those two fields are what turn a
support conversation into a lookup.
