# Errors and retries

Relay returns stable JSON envelopes for application errors. Use the HTTP status for transport behavior, `error.code` for program logic, and `meta.requestId` for investigation.

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "One or more fields are invalid.",
    "details": [
      {
        "field": "useremail",
        "code": "isEmail"
      }
    ]
  },
  "meta": {
    "requestId": "ab16ed04-fceb-4415-a609-967bc77972fa"
  }
}
```

## HTTP status codes

| Status | Default code | Meaning | What to do |
| ---: | --- | --- | --- |
| `400` | `INVALID_REQUEST` or `VALIDATION_FAILED` | The body, query, asset, amount, or workflow rule is invalid | Correct the request; do not retry unchanged |
| `401` | `UNAUTHORIZED` | The `accesskey` is missing, invalid, or inactive | Fix or rotate credentials |
| `403` | `FORBIDDEN` | The authenticated actor is not allowed to perform the operation | Review account access; do not retry unchanged |
| `404` | `NOT_FOUND` or a resource code | The tenant-scoped resource does not exist | Check the resource identifier |
| `409` | `CONFLICT` | The request conflicts with current resource state | Retrieve current state before deciding whether to retry |
| `422` | `VALIDATION_FAILED` | Semantically invalid input on endpoints that use this status | Correct the fields shown in `details` |
| `429` | `RATE_LIMITED` | A plan or protection limit was reached | Respect `Retry-After` when present and back off |
| `500` | `INTERNAL_ERROR` | Relay encountered an unexpected error | Retry with backoff; contact Relay if it persists |

Relay does not publish one global request quota. Limits may vary by environment or commercial plan. Build clients that can safely handle `429` without assuming a fixed requests-per-minute value.

## Business validation errors

Some `400` responses have a message specific to the business rule. Common examples include:

| Message or code | Cause |
| --- | --- |
| `Invalid Crypto Token` | The network/token pair is not active for payment intents |
| `INVALID_PAYMENT_AMOUNT` | The amount is non-positive or cannot fit the token precision |
| `Provide postTransactionAddress when using external_wallet` | `external_wallet` was selected without a destination |
| `Your organization must set a settlement address…` | The selected token/network has no saved destination for a payment intent or customer wallet |
| `Asset is unavailable` | A customer-wallet asset is inactive or missing its contract configuration |
| `Customer wallet fees are not configured for this asset` | Relay has not configured a fee policy for the organization and asset |
| `PAYMENT_NOT_FOUND` | No payment intent with that `txId` belongs to the organization |

Messages add context but may become more specific. Prefer branching on `error.code` where a dedicated code exists.

## Retry policy

Retry only errors that can reasonably recover without changing the request:

- network failures before you receive a response;
- `429` responses, honoring a bounded `Retry-After`; and
- `500` responses with exponential backoff and jitter.

Do not automatically retry `400`, `401`, `403`, `404`, or ordinary `409` responses.

```javascript
const retryable = response.status === 429 || response.status >= 500;
const retryAfter = Number(response.headers.get("retry-after") ?? 0) * 1000;
const delay = Math.max(retryAfter, Math.min(30_000, 500 * 2 ** attempt));
```

## Creation and idempotency

`txRef` is your merchant reference; it is not an HTTP idempotency key. The current payment-intent creation routes do not accept a documented idempotency header. A repeated creation request can create another intent.

For ambiguous timeouts:

1. record whether Relay returned a `txId` before retrying;
2. use a durable local state machine around each order;
3. avoid blindly sending the same create request again; and
4. reconcile with your stored Relay resource or contact Relay using the request ID when the outcome is uncertain.

Read endpoints are safe to retry. Webhook deliveries are explicitly at-least-once and keep the same event ID across retries.
