Errors & Retries
One problem shape, stable machine codes, a correlation id on every response, request budgets you can read — and the retry rules that keep money safe.
The API fails in deliberately boring, uniform ways: one problem shape, stable machine codes, a correlation id on every response — success or failure — and idempotency where retries matter. This page is the contract your error paths should be written against.
The problem shape
Every error response is an RFC 9457 problem document, sent as application/problem+json; charset=utf-8:
{
"title": "The request could not be completed.",
"status": 409,
"detail": "Use the error code and correlation ID when contacting support.",
"code": "price_above_maximum",
"correlationId": "0198e001a2b37c4d8e9f0a1b2c3d4e5f",
"quotedTotal": 61.20,
"currency": "USD",
"maximumTotal": 60.00,
"message": "The price is now above the most you agreed to pay."
}| Member | Meaning |
|---|---|
title, detail | Generic sentences, the same for every error, in the response language. |
status | The HTTP status code. |
code | The stable, machine-readable discriminator — branch on it, never on title, detail or message. |
correlationId | Identifies this exact request in our telemetry, and is the same value the response's X-Correlation-Id header carries. Log it with every failure and quote it in support tickets; it is the difference between "something failed yesterday" and an answer. |
| facts | Some codes carry the facts you need to decide without another call: quotedTotal, currency and maximumTotal (price_above_maximum); limit, currency and, for 30-day limits, used (the *_limit_exceeded codes); availableAtUtc (payout_destination_cooling_off); orderStatus (fulfillment_unavailable); reviewHours (order_held_for_review); reason and, when known, paidThroughUtc (api_access_inactive). |
errors | Present only when a request body has a member the contract does not have: a map from the member's path in the body (maximumTotl, item.valu) to a list of messages. |
message | What went wrong, as one sentence for a person, in the response language. Every code in the table below carries a sentence of its own — api_access_inactive carries one per reason, and unauthorized is worded for an API key, not for a person's session — so the generic title appears here only as the fallback for a code your client is newer than. It is written for display and may change; it never replaces code. |
The response language
message, title, detail and the messages in errors are written in the language you ask for with the X-Language header — or, without it, your Accept-Language — and in English otherwise. Xegora writes en, ar, de, es, fa, fr, hi, pt, ru, tr and zh. The response says which it used in Content-Language, with Vary: X-Language, Accept-Language. Codes, correlation ids and facts are the same in every language, and successful responses are not translated.
Error responses are never cacheable (Cache-Control: no-store).
Correlation ids
Send X-Correlation-Id with a request to have your own id carried through our logs and audit records: it is kept when it is 1–128 characters of ASCII letters, digits, ., _, : and -, and replaced by an id of ours (32 lowercase hex characters) otherwise — so a value with spaces, a comma, or anything else is never echoed back.
Every response carries the id in the X-Correlation-Id response header — successes and problem documents alike, on every path: a refusal from the rate limiter, from authentication, from the access-standing check, or from an unexpected fault. A problem document carries the same value again in its correlationId, so the header and the body always agree and either one is safe to read.
Status codes and what to do
| Status | Typical code | Meaning | Your move |
|---|---|---|---|
| 400 | invalid_request | Malformed body or header, bad parameter, unknown body member, missing Idempotency-Key | Fix the request. Never retry unchanged. |
| 401 | unauthorized | Missing, malformed, unknown, revoked or expired API key, or a wrong secret | Check key configuration; alert — never loop. |
| 403 | api_access_inactive | Valid key; the workspace may not use the API right now (reason) | Settle billing or contact Xegora; reads may still work. |
| 403 | ip_not_allowed | Valid key and secret, from an address outside the key's allow-list | Fix the allow-list or your egress address. |
| 403 | forbidden / entitlement_required | Valid key without the required scope; or API purchasing not enabled | Re-issue the key with the right scopes / contact your account manager. |
| 404 | not_found | Resource absent in your workspace (or no such route) | Treat as authoritative; don't retry. |
| 405 / 415 | method_not_allowed / unsupported_media_type | Wrong HTTP method, or a body that is not application/json | Fix the request. |
| 409 | context-specific | Legitimate business conflict: expired/used quote, unavailable product, non-revealable order, over-balance withdrawal, reused idempotency key | Handle as a domain outcome (re-quote, wait for fulfillment, lower the amount). Retrying unchanged will conflict again — except concurrent_update, which is resent under the same key. |
| 413 | payload_too_large | The request body is over 256 KiB | Fix the request. |
| 422 | context-specific | The request is well-formed but cannot be done as asked: currency, availability, limits, minimums | Change the request. |
| 429 | rate_limited | A request budget is spent | Honor Retry-After (below). |
| 500 | request_failed | Unexpected server fault | Retry with backoff under the same Idempotency-Key. |
| 503 | deposits_unavailable / media_unavailable | Deposits are paused, or images are temporarily unavailable | Honor Retry-After (5 seconds). |
Codes
Every code the API answers with. Branch on these; keep clients tolerant of codes added later.
code | Status | Meaning |
|---|---|---|
invalid_request | 400 | A parameter, header or body field is malformed or out of range, or the body has a member the contract does not have (named in errors). |
unauthorized | 401 | No valid API key: missing, malformed, unknown, revoked or expired, or a wrong secret. The message is worded for a key — nothing on this surface has a session to sign in to. |
ip_not_allowed | 403 | The key is valid but not accepted from the request's source address. |
api_access_inactive | 403 | The workspace may not use the API right now: reason is workspace_suspended, setup_fee_unpaid, subscription_overdue or entitlement_ended; paidThroughUtc when known. See Authentication. |
forbidden | 403 | The key lacks the scope the endpoint requires. |
entitlement_required | 403 | API purchasing is not enabled for the workspace (normally answered earlier as api_access_inactive). |
not_found | 404 | Absent in your workspace, or no such route. |
method_not_allowed | 405 | The route does not take this HTTP method. |
payload_too_large | 413 | The request body is too large. |
unsupported_media_type | 415 | The body is not JSON. |
rate_limited | 429 | A request budget is spent; honor Retry-After. |
product_unavailable | 409 / 422 | The product or denomination is not sold right now (409 when an order finds it withdrawn after its quote; nothing was charged). |
currency_not_supported | 422 | The currency is not the product's sellCurrency. |
pricing_unavailable | 422 | The item cannot be priced right now. |
quote_unavailable | 409 / 422 | The quote expired, was used, or cannot be ordered — quote again. |
quote_channel_mismatch | 409 | The quote was made for another channel (your Xegora storefront) and cannot be ordered through the API — quote again through the API. |
quote_buyer_mismatch | 409 | The quote is bound to a storefront buyer and cannot be ordered through the API — quote again through the API. |
price_above_maximum | 409 | Today's total is above the order's maximumTotal; the body carries quotedTotal, currency, maximumTotal. |
insufficient_funds | 409 | The wallet's available balance does not cover the order. |
wallet_unavailable | 422 | No active wallet in that currency. |
wallet_funding_on_hold | 409 | New orders in the wallet's currency are on hold while Xegora resolves an incident affecting how that currency is funded; nothing was reserved. |
purchase_input_invalid | 422 | A top-up's phone number is missing or not in E.164 form, or purchase input was sent for a product that takes none. |
idempotency_conflict | 409 | The Idempotency-Key is already bound to something else: it placed a different order, made a different withdrawal, or revealed a different order. Nothing was done under it a second time. |
concurrent_update | 409 | State changed underneath the request — resend with the same key. |
order_held_for_review | 409 | The order waits for a review by Xegora before it can be placed; the body carries reviewHours. Nothing was reserved. Xegora's current rules never hold an order placed through the API. |
order_limit_exceeded | 422 | One order may cost at most the limit Xegora set for the workspace (the body carries limit and currency). Nothing was reserved. |
order_period_limit_exceeded | 422 | The last 30 days of orders, this one included, would pass the workspace's limit; used is what those days already count. |
currency_limits_not_configured | 422 | The workspace has limits in other currencies but none in this one, so orders and withdrawals in it are refused until Xegora sets them. |
fulfillment_unavailable | 409 | Your order has nothing to reveal yet, or nothing revealable; orderStatus usually says its status. |
fulfillment_reveal_limit_reached | 409 | The order's reveal limit is reached. |
fulfillment_replay_expired | 409 | The reveal's Idempotency-Key was used more than 15 minutes ago — reveal again under a new key. |
payment_not_allocatable | 409 | A payment cannot be applied to the order. Contact support. |
deposits_unavailable | 503 | Deposits are paused; honor Retry-After. |
deposit_asset_not_offered | 422 | That network and stablecoin are not offered. |
deposit_account_unavailable | 422 | A deposit address cannot be issued for the workspace right now. |
deposit_amount_invalid | 422 | The deposit amount is invalid (for a deposit quote: more than two decimals). |
insufficient_balance | 409 | A withdrawal is above the available balance. |
withdrawal_amount_below_minimum | 422 | The withdrawal amount is below the minimum. |
withdrawal_amount_below_fee | 422 | The withdrawal amount does not cover the fee. |
withdrawal_terms_changed | 409 | The fee and net sent with a withdrawal are not today's terms — quote again. |
withdrawal_not_pending | 409 | The withdrawal is no longer Requested: it was paid or cancelled. |
withdrawal_cancel_window_closed | 409 | The hold has ended (payableAfterUtc); the payout may already be under way, so it can no longer be cancelled. |
withdrawal_reconciliation_required | 409 | The amount the withdrawal held is no longer held on the wallet; Xegora reconciles it before anything moves. Contact support. |
withdrawal_ineligible | 409 | The workspace cannot withdraw. |
withdrawal_unavailable | 409 | No active wallet in that currency to withdraw from. |
withdrawal_limit_exceeded | 422 | One withdrawal may take at most the limit set for the workspace (the body carries limit and currency). Nothing was held. |
withdrawal_period_limit_exceeded | 422 | The last 30 days of withdrawals, this one included, would pass the workspace's limit; used is what those days already count. |
payout_destination_required | 409 | The currency has no approved default payout destination. |
payout_destination_not_approved | 409 | The payout destination is not approved by Xegora. |
payout_destination_cooling_off | 409 | The default payout destination's cooling-off has not ended; availableAtUtc says when it can first receive money. |
media_unavailable | 503 | Images are temporarily unavailable; honor Retry-After. |
conflict | 409 | Any other conflict. Rare; quote the correlationId to support if one persists. |
validation_failed | 422 | Any other request that cannot be done as asked. |
request_failed | 500 | An unexpected failure. Retry with backoff under the same Idempotency-Key; quote the correlationId if it persists. |
Rate limiting
Two budgets apply to a workspace, each a fixed one-minute window with no queueing, shared by all of the workspace's keys:
- The API budget: 120 requests per minute per workspace, for every endpoint except catalog images.
GET /api/v1/workspacereports it asrateLimit.requestsPerMinute. - The catalog image budget: 300 requests per minute per workspace, for
GET /api/v1/catalog-product-images/…only, so drawing product pictures never spends what your orders need.
Before a key is checked, every request is also counted by its source address: a per-minute budget and a cap on concurrent requests per address. All workspaces and keys calling from one egress address share it. A request without a valid key is counted by its address.
Every admitted response says the budget it was counted against and what is left of it in the current window:
RateLimit-Limit: 120
RateLimit-Remaining: 87
A refused request receives 429 with code: "rate_limited" and a Retry-After header — whole seconds, as the limiter reports them: 60 when a per-minute budget is spent (the window's length — your budget may come back sooner, never later) and 1 when the per-address concurrency cap refused it. A refusal of a per-minute budget also carries RateLimit-Limit (120, 300, or the per-address budget), RateLimit-Remaining: 0 and RateLimit-Reset (the same seconds as Retry-After).
- Spread scheduled jobs (catalog walks, reconciliation sweeps) across the minute instead of firing at
:00, and pace them byRateLimit-Remaining. - On
429, sleep the advertisedRetry-After, then resume; add jitter if several workers share the workspace. - Sustained 429s mean your architecture is polling where it should be consuming webhooks.
Retry rules that keep money safe
- Idempotent mutations, always keyed.
POST /ordersandPOST /orders/{id}/fulfillmentrequire anIdempotency-Key, andPOST /withdrawalstakes one — send it. Retry timeouts/5xx with the same key and you can never double-purchase, double-withdraw or lose a response — including a one-call order that names itsitem: the retry is answered by the order the key already placed, and nothing is priced again. - The scary case — a timeout on
POST /orders— is already solved. You don't know whether the order was created; resending with the same key returns the existing order if it was, creates it if it wasn't. Never "check first, then maybe resend" — just resend with the key. - A reveal's key replays for 15 minutes. Retry a timed-out reveal under the same key within 15 minutes and you receive the stored response without spending a reveal; after that the key answers
409 fulfillment_replay_expired. A reveal key sent for a different order answers409 idempotency_conflict— one key per intended reveal. - Reads are safe to retry (GETs are side-effect free). Use capped exponential backoff with jitter: e.g. 1s, 2s, 4s, 8s (±20%), give up after ~5 attempts into your dead-letter/alerting path.
- Never retry 4xx other than 429 and
409 concurrent_update(resend it under the same key) — every other outcome is deterministic until you change the request or its circumstances (fund the wallet, quote again). - On
409 idempotency_conflict, look the order up before you reach for another key.GET /orders?clientReference=<your reference>is authoritative: a new key for a purchase that was in fact already placed buys it a second time. Only use a fresh key once you have seen that no order of yours matches. (A one-call retry of an order whose receipt kept no record of the request — one placed before this release, or one placed from a quote — is still answered when the order itself matches the retry: see Orders.) - Withdrawal requests sent without a key cannot be told apart: on a timeout, read before retrying —
GET /withdrawals/page?status=Requested— and only resend if your request is genuinely absent. Sending anIdempotency-Keyremoves the problem.
Timeouts and connections
- A client timeout of 10–15 s covers every endpoint comfortably; most respond in tens of milliseconds.
- Reuse HTTPS connections (keep-alive); per-request TLS handshakes are wasted latency.
- Keep the number of concurrent requests from one address modest: the per-address concurrency cap refuses the excess with
429andRetry-After: 1. - Treat DNS/TLS/socket errors exactly like 5xx: retry idempotently with backoff.
An error-handling skeleton
response = send(request with Idempotency-Key where applicable)
switch response.status:
2xx → done
429 → sleep(Retry-After); retry (same key)
503 → sleep(Retry-After); retry (same key)
5xx / IO → backoff(attempt); retry (same key); after N attempts → dead-letter + alert
409 → domain outcome → dedicated handler (re-quote / wait / adjust); concurrent_update → retry (same key)
422 → domain outcome → change the request or stop
404 → authoritative absence → reconcile local state
400 → bug in request construction → log correlationId, fail fast, alert
403 → api_access_inactive → billing/standing alert (reads may still work)
ip_not_allowed / forbidden → credentials, scopes or egress misconfigured → alert operations
401 → credentials misconfigured → alert operationsWire correlationId through every branch — future-you, and our support team, will thank you.
Updated 18 days ago