<!-- Source: https://docs.infraio.xyz/en/api-reference/errors -->
<!-- Last updated: 2026-10-04 -->

# Errors

Every 4xx/5xx response carries the same JSON envelope:

```json
{
  "code": 400,
  "message": "invalid_input",
  "details": [
    { "field": "items[0].unit_price", "message": "must be a positive decimal string" }
  ]
}
```

- `code` — the **numeric HTTP status** (`400`, `401`, `404`, …). Useful for generic HTTP-layer handling, but for branching logic switch on `message` instead — `code` won't disambiguate between, say, `invalid_input` and `payment_method_not_supported` (both 400).
- `message` — the **lower-snake-case** sentinel name, the sentinel name in lower snake case (e.g. `INVALID_INPUT` becomes `"invalid_input"`). Stable across releases — switch on this.
- `details` — populated on validation errors. Array of `{ field, message }` objects so the client can pin errors to inputs. Omitted otherwise.

> **Warning:**
>
> Error bodies don't include a trace ID or timestamp. To report a problem, send support the response `Date` header, the `X-RateLimit-*` headers, your merchant ID, the endpoint, and the approximate time of the request.

> **Note:**
>
> **Authentication failures use a different shape.** The envelope above
> is what the API returns for most errors. Requests rejected before they
> are processed (a missing or invalid `X-Signature`, an unknown
> `X-Client-ID`, or a stale `X-Timestamp` on a `/b2b/v1/*`
> call) come back as `{ "error": "...", "message": "..." }`, where
> `error` is a coarse slug (`unauthorized` / `bad_request` /
> `service_unavailable`) and `message` carries the specifics. There is
> no numeric `code` and no `details`. Branch on the
> HTTP status first, then read `message`. Example (401):
>
> ```json
> { "error": "unauthorized", "message": "invalid signature" }
> ```

## HTTP status → typical codes

| HTTP | Typical `code` values | What it means |
| --- | --- | --- |
| **400** | `INVALID_INPUT`, `MISSING_REQUIRED`, `INVALID_FORMAT`, `INVALID_LENGTH`, `INVALID_VALUE`, `PAYMENT_METHOD_NOT_SUPPORTED`, `AMOUNT_BELOW_MINIMUM` | Bad request — look at `details` |
| **401** | `INVALID_CREDENTIALS`, `INVALID_TOKEN`, `TOKEN_EXPIRED`, `INVALID_SIGNATURE` (B2B); `SESSION_EXPIRED` (dashboard only) | Auth failed — bad key, expired timestamp, wrong signature. Dashboard sign-in codes aren't relevant to B2B integrations. |
| **402** | `INSUFFICIENT_CREDIT` | Merchant prepaid balance ran out — top up before retrying |
| **403** | `FORBIDDEN`, `IP_BLOCKED` | Key is valid but lacks the scope/IP allowance for this call |
| **404** | `NOT_FOUND`, `RECORD_NOT_FOUND` | Resource doesn't exist (or doesn't exist for this merchant) |
| **409** | `ALREADY_EXISTS` | Idempotency replay with a different body, or state machine refused the transition |
| **429** | `TOO_MANY_REQUESTS`, `TOO_MANY_ATTEMPTS` | Rate limit hit; back off and retry |
| **500** | `INTERNAL_SERVER_ERROR`, `EXTERNAL_SERVICE_ERROR` | Our fault; safe to retry with backoff. |
| **503** | `SERVICE_UNAVAILABLE` | A downstream dependency is down. Retry with backoff |

## Full code reference

The `code` values you may see:

### Authentication (401)
- `INVALID_CREDENTIALS` — API key combination rejected
- `INVALID_TOKEN` — token unparseable or tampered
- `TOKEN_EXPIRED` — token expired
- `SESSION_EXPIRED` — dashboard session expired
- `INVALID_SIGNATURE` — HMAC signature mismatch on B2B / webhook calls

### Authorization (403)
- `FORBIDDEN` — authenticated, but you aren't allowed to perform this action
- `IP_BLOCKED` — requests from this IP address are blocked

### Not found (404)
- `NOT_FOUND` — generic
- `RECORD_NOT_FOUND` — row missing for the given ID

### Conflict (409)
- `ALREADY_EXISTS` — generic

### Validation (400)
- `INVALID_INPUT` — generic; check `details`
- `MISSING_REQUIRED` — a required field was absent
- `INVALID_FORMAT` — value didn't match the expected format (e.g. UUID, URL, email)
- `INVALID_LENGTH` — value too short or too long
- `INVALID_VALUE` — value out of allowed enum/range
- `PAYMENT_METHOD_NOT_SUPPORTED` — provider/asset combo isn't enabled for the merchant
- `AMOUNT_BELOW_MINIMUM` — order amount below the per-network or env-level floor. The response's `details.floor_usd` carries the configured floor (USD) so you can surface it directly; the `message` text states it too.
- `INSUFFICIENT_BALANCE` — the buyer's wallet doesn't hold enough of the pay asset to cover the transfer.
- `INSUFFICIENT_GAS` — the buyer's wallet lacks the native token to pay the network fee (gas) for the transfer.

### Payment / billing (402)
- `INSUFFICIENT_CREDIT` — merchant prepaid balance can't cover the network fee (gas) and platform fee. Top up via the dashboard, then retry

### Rate limiting (429)
- `TOO_MANY_REQUESTS` — IP rate limit exceeded
- `TOO_MANY_ATTEMPTS` — repeated failed attempts on the same resource (e.g. OTP) tripped a throttle

### Server errors (500)
- `EXTERNAL_SERVICE_ERROR` — a third-party provider failed
- `INTERNAL_SERVER_ERROR` — unexpected error; contact support with the response `Date` header and `X-RateLimit-*` headers

### Availability (503)
- `SERVICE_UNAVAILABLE` — the service is temporarily unavailable; retry with backoff

## Validation errors (400)

When the issue is a malformed request body, `details` is an array so
you can map errors back to fields:

```json
{
  "code": 400,
  "message": "invalid_input",
  "details": [
    { "field": "items[0].unit_price", "message": "must be a positive decimal string" },
    { "field": "success_url", "message": "must be a valid https URL" }
  ]
}
```

## Auth errors (401)

`INVALID_SIGNATURE` has three common causes:

- Wrong secret key (re-check env var)
- Timestamp drift > 5 min (sync NTP)
- Wrong canonical string (most often: forgot `\n` separators, or
  signed a parsed-then-re-stringified body that differs from what
  you sent)

See [Authentication](https://docs.infraio.xyz/en/api-reference/authentication) for the exact
signing algorithm.

## Rate limits (429)

| Surface | Limit |
| --- | --- |
| All API routes (incl. `/b2b/v1/*`) | Per IP address — **500 req/min**, shared bucket. Not per-merchant. |
| Public checkout (`/checkout/*`) | Stricter sub-bucket — **20 req/min per IP** |

`X-RateLimit-Limit` and `X-RateLimit-Remaining` are included on
every rate-limited response, not just successful ones. Treat
them as the live budget for your IP.

Rate-limited responses include a `Retry-After` header (seconds until
the window resets) — honor it. As a fallback, back off with jitter —
1s base + exponential to 30s.

> **Note:**
>
> Rate limits may change. If you reach them with legitimate traffic
> (for example, reconciling a large historical range), contact support.

## Insufficient credit (402)

`INSUFFICIENT_CREDIT` (HTTP **402 Payment Required**) means your
prepaid balance can't cover the network fee (gas) and platform fee for the
operation you tried to perform, typically settling a crypto payment
or an on-chain action whose network fee is sponsored. Top up from the merchant
dashboard (**Billing → Add credit**), then retry the operation.
Operations already in progress continue automatically once the balance
is topped up.

## Server errors (5xx)

A 500 means we couldn't process the request. Retry with backoff —
your `idempotency_key` ensures you won't double-charge if the original
request did partially succeed.

If retries don't recover within a minute, surface a generic "payment
temporarily unavailable" to the buyer and reach out to support with
the failing endpoint, your merchant ID, the response `Date` header and
`X-RateLimit-*` values, and the approximate request time — that's
enough for us to find the request.

## Webhook delivery errors

Webhook deliveries are a separate failure channel — they don't surface
as API errors because your server isn't the one calling. When a
delivery returns a non-2xx (or times out), it's retried
with exponential backoff at 0s, 1min, 5min, 15min, 1h, 6h (six
attempts total, the same schedule as [Webhooks → Overview](https://docs.infraio.xyz/en/webhooks/overview)). The full per-delivery history shows up under **Developers →
Webhooks → [endpoint] → Delivery log** in the dashboard. After the
sixth attempt the delivery is marked "Failed" in the dashboard, and you
can replay it manually once your server is healthy.
