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."
}
MemberMeaning
title, detailGeneric sentences, the same for every error, in the response language.
statusThe HTTP status code.
codeThe stable, machine-readable discriminator — branch on it, never on title, detail or message.
correlationIdIdentifies 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.
factsSome 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).
errorsPresent 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.
messageWhat 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

StatusTypical codeMeaningYour move
400invalid_requestMalformed body or header, bad parameter, unknown body member, missing Idempotency-KeyFix the request. Never retry unchanged.
401unauthorizedMissing, malformed, unknown, revoked or expired API key, or a wrong secretCheck key configuration; alert — never loop.
403api_access_inactiveValid key; the workspace may not use the API right now (reason)Settle billing or contact Xegora; reads may still work.
403ip_not_allowedValid key and secret, from an address outside the key's allow-listFix the allow-list or your egress address.
403forbidden / entitlement_requiredValid key without the required scope; or API purchasing not enabledRe-issue the key with the right scopes / contact your account manager.
404not_foundResource absent in your workspace (or no such route)Treat as authoritative; don't retry.
405 / 415method_not_allowed / unsupported_media_typeWrong HTTP method, or a body that is not application/jsonFix the request.
409context-specificLegitimate business conflict: expired/used quote, unavailable product, non-revealable order, over-balance withdrawal, reused idempotency keyHandle 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.
413payload_too_largeThe request body is over 256 KiBFix the request.
422context-specificThe request is well-formed but cannot be done as asked: currency, availability, limits, minimumsChange the request.
429rate_limitedA request budget is spentHonor Retry-After (below).
500request_failedUnexpected server faultRetry with backoff under the same Idempotency-Key.
503deposits_unavailable / media_unavailableDeposits are paused, or images are temporarily unavailableHonor Retry-After (5 seconds).

Codes

Every code the API answers with. Branch on these; keep clients tolerant of codes added later.

codeStatusMeaning
invalid_request400A 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).
unauthorized401No 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_allowed403The key is valid but not accepted from the request's source address.
api_access_inactive403The 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.
forbidden403The key lacks the scope the endpoint requires.
entitlement_required403API purchasing is not enabled for the workspace (normally answered earlier as api_access_inactive).
not_found404Absent in your workspace, or no such route.
method_not_allowed405The route does not take this HTTP method.
payload_too_large413The request body is too large.
unsupported_media_type415The body is not JSON.
rate_limited429A request budget is spent; honor Retry-After.
product_unavailable409 / 422The product or denomination is not sold right now (409 when an order finds it withdrawn after its quote; nothing was charged).
currency_not_supported422The currency is not the product's sellCurrency.
pricing_unavailable422The item cannot be priced right now.
quote_unavailable409 / 422The quote expired, was used, or cannot be ordered — quote again.
quote_channel_mismatch409The quote was made for another channel (your Xegora storefront) and cannot be ordered through the API — quote again through the API.
quote_buyer_mismatch409The quote is bound to a storefront buyer and cannot be ordered through the API — quote again through the API.
price_above_maximum409Today's total is above the order's maximumTotal; the body carries quotedTotal, currency, maximumTotal.
insufficient_funds409The wallet's available balance does not cover the order.
wallet_unavailable422No active wallet in that currency.
wallet_funding_on_hold409New 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_invalid422A 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_conflict409The 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_update409State changed underneath the request — resend with the same key.
order_held_for_review409The 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_exceeded422One order may cost at most the limit Xegora set for the workspace (the body carries limit and currency). Nothing was reserved.
order_period_limit_exceeded422The last 30 days of orders, this one included, would pass the workspace's limit; used is what those days already count.
currency_limits_not_configured422The workspace has limits in other currencies but none in this one, so orders and withdrawals in it are refused until Xegora sets them.
fulfillment_unavailable409Your order has nothing to reveal yet, or nothing revealable; orderStatus usually says its status.
fulfillment_reveal_limit_reached409The order's reveal limit is reached.
fulfillment_replay_expired409The reveal's Idempotency-Key was used more than 15 minutes ago — reveal again under a new key.
payment_not_allocatable409A payment cannot be applied to the order. Contact support.
deposits_unavailable503Deposits are paused; honor Retry-After.
deposit_asset_not_offered422That network and stablecoin are not offered.
deposit_account_unavailable422A deposit address cannot be issued for the workspace right now.
deposit_amount_invalid422The deposit amount is invalid (for a deposit quote: more than two decimals).
insufficient_balance409A withdrawal is above the available balance.
withdrawal_amount_below_minimum422The withdrawal amount is below the minimum.
withdrawal_amount_below_fee422The withdrawal amount does not cover the fee.
withdrawal_terms_changed409The fee and net sent with a withdrawal are not today's terms — quote again.
withdrawal_not_pending409The withdrawal is no longer Requested: it was paid or cancelled.
withdrawal_cancel_window_closed409The hold has ended (payableAfterUtc); the payout may already be under way, so it can no longer be cancelled.
withdrawal_reconciliation_required409The amount the withdrawal held is no longer held on the wallet; Xegora reconciles it before anything moves. Contact support.
withdrawal_ineligible409The workspace cannot withdraw.
withdrawal_unavailable409No active wallet in that currency to withdraw from.
withdrawal_limit_exceeded422One withdrawal may take at most the limit set for the workspace (the body carries limit and currency). Nothing was held.
withdrawal_period_limit_exceeded422The last 30 days of withdrawals, this one included, would pass the workspace's limit; used is what those days already count.
payout_destination_required409The currency has no approved default payout destination.
payout_destination_not_approved409The payout destination is not approved by Xegora.
payout_destination_cooling_off409The default payout destination's cooling-off has not ended; availableAtUtc says when it can first receive money.
media_unavailable503Images are temporarily unavailable; honor Retry-After.
conflict409Any other conflict. Rare; quote the correlationId to support if one persists.
validation_failed422Any other request that cannot be done as asked.
request_failed500An 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/workspace reports it as rateLimit.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 by RateLimit-Remaining.
  • On 429, sleep the advertised Retry-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

  1. Idempotent mutations, always keyed. POST /orders and POST /orders/{id}/fulfillment require an Idempotency-Key, and POST /withdrawals takes 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 its item: the retry is answered by the order the key already placed, and nothing is priced again.
  2. 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.
  3. 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 answers 409 idempotency_conflict — one key per intended reveal.
  4. 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.
  5. 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).
  6. 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.)
  7. 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 an Idempotency-Key removes 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 429 and Retry-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 operations

Wire correlationId through every branch — future-you, and our support team, will thank you.


Did this page help you?