# Errors

## Two shapes

The token endpoint answers in the OAuth shape, so stock OAuth client libraries
can parse it:

```json title="POST /api/v1/s2s/auth/token"
{
  "error": "invalid_client",
  "error_description": "Client authentication failed."
}
```

Every other endpoint uses the API's error envelope:

```json
{
  "statusCode": 404,
  "error": "Not Found",
  "code": "USER_NOT_FOUND",
  "message": "User not found"
}
```

`code` is a stable, upper-snake-case identifier for the failure. Branch on it,
not on `message`, whose wording can change. Every code is listed in
[Error codes](/error-codes). `message` is always a string. When request
validation fails, `code` is `VALIDATION_FAILED`, `message` joins every failure
with `; `, and `errors` lists them one per entry:

```json
{
  "statusCode": 400,
  "error": "Bad Request",
  "code": "VALIDATION_FAILED",
  "message": "amount must be a number string; chain should not be empty",
  "errors": ["amount must be a number string", "chain should not be empty"]
}
```

Do not write one parser for both. If your HTTP client is configured to expect
`{ data }` everywhere, the token call is the one that will surprise it.

### Insufficient balance

Every construct call (stake, unstake, buy, sell, bridge) checks the wallet's
on-chain balances before it builds anything, with the platform fee counted. A
wallet that cannot afford the batch gets a `400` with the code
`INSUFFICIENT_BALANCE` and the numbers behind it, so you can render it without
parsing `message`:

```json
{
  "statusCode": 400,
  "error": "Bad Request",
  "code": "INSUFFICIENT_BALANCE",
  "message": "Insufficient USDC on BASE: needs 100.1 (amount plus platform fee), holds 100.0",
  "token": { "address": "0x8335…2913", "symbol": "USDC", "decimals": 6 },
  "required": "100100000",
  "available": "100000000",
  "shortfall": "100000"
}
```

`required`, `available` and `shortfall` are in the token's base units. `token`
is the token that is short - the one being spent, or the fee token (USDC) when
only the fee is missing. To build for the largest amount that *will* pass, send
`maxAmount: true` to the construct call instead of computing it yourself: the
fee tier steps down as the amount grows, and the max accounts for that. With
`maxAmount`, this error means the wallet cannot afford even the smallest
amount.

## Status codes

| Status | Meaning | What to do |
| --- | --- | --- |
| `400` | Malformed request (`VALIDATION_FAILED`), unknown pool, unlinked wallet (`WALLET_NOT_LINKED`), a spent/expired intent (`INTENT_NOT_FOUND`), or a wallet that cannot afford the batch (`INSUFFICIENT_BALANCE`) | Fix the request; for intents, rebuild rather than retry; for a balance, construct with `maxAmount: true` |
| `401` | Missing, malformed, expired or revoked access token | Fetch a new token. If a fresh token also fails, the credential or the organisation has been stopped |
| `403` | Valid token, insufficient scope | Ask us to add the scope - retrying will not help |
| `404` | No such resource, **or** it belongs to another partner | Not proof the ID is wrong; see below |
| `409` | Email or wallet address already belongs to another user | The person likely already has a Restake account |

### `401` versus `403`

The split is meaningful and worth branching on:

- **`401` means fix your token.** It carries a `WWW-Authenticate: Bearer`
  challenge. It also names `error="invalid_token"` only when a token was
  actually presented and rejected - a request with no `Authorization` header at
  all is challenged without that code, because an absent token is not an invalid
  one.
- **`403` means the token is fine but is not allowed to do this.** No amount of
  re-authenticating changes it; the credential needs the scope added.

### `404` on a user

A user ID that belongs to another partner is indistinguishable from one that
does not exist. That is deliberate: a `403` here would let anyone with a partner
token probe whether a given account is on the platform.

So `404` on a `userId` means one of three things - it never existed, it was
created by a different partner, or it belongs to an app signup - and the API
will not tell you which.

## Revocation is immediate

A revoked credential does not keep working until its tokens expire. Both the
credential and every token issued to it stop within seconds, and the same
happens if your organisation's account is suspended.

Practically, that means a sudden wall of `401`s across every endpoint at once is
not a token-refresh bug - check whether the credential is still live before
rewriting your auth code.

## Retries

| Situation | Retry? |
| --- | --- |
| `401` after a token refresh | No - the credential is stopped |
| `403` | No |
| `404`, `409`, `400` | No - the request has to change |
| `5xx` or a timeout | Yes, with exponential backoff |

Take particular care retrying `POST …/transaction/batch/send` on a timeout: the
operation may have been submitted. Re-submitting the same `intentId` is safely
rejected as spent, so the risk is misreading that rejection as a failed stake -
confirm on-chain with the `userOpHash` before rebuilding.
