Errors, retries & idempotency
Error shapes, HTTP statuses, quote and fill error codes, and which calls are safe to retry
Error shape
Errors return JSON with a readable message. Quote and trading errors can add a stable code:
{ "message": "Quote has expired", "code": "quote_not_found" }Branch on code when it is present, and always show or log message. Some errors carry only a
message. A few 400 messages start with a stable token such as
app_fee_override_requires_api_key:; match on that prefix.
A body that fails schema validation returns 400 with a different shape and no code:
{ "statusCode": 400, "message": "Request validation failed", "errors": [{ "field": "sizeRaw", "message": "..." }] }HTTP statuses
| Status | Meaning | Retry? |
|---|---|---|
400 | Invalid request, or a quote, fill or order refusal. Read code. | Depends on code, see below. Never retry unchanged otherwise. |
401 | Missing or invalid x-app-id, API key, or access token. | Refresh the access token once, then retry. See Authentication & sessions. |
403 | Not allowed: origin not in allowed origins, wrong-app API key, testing-mode cap reached, or trading not available in the user's region. A regional refusal carries code: "geo_blocked" on every trading route: fill, orders, limit orders, cancel, withdraw (and its preview and quote), and redeem. Trading is not available in your region is your app's or the platform's region list. Venue X is not available in your region (CC) is one venue's list. A 403 never creates an order, withdrawal or claim. | No. Fix the cause. |
404 | Not found, or not visible to this user or app. A 404 on GET /execution/status is not final. | For that status call, follow the fill-timeout steps below. Do not get a new quote yet. Other 404s: no. |
409 | Conflict: a repeated idempotency key, an order that can no longer be cancelled, a changed withdrawal requestId body, or another withdrawal already active for the user. A 409 writes nothing, but on a repeated key or an active withdrawal the existing one may be running. | No. Read the existing resource instead. |
429 | Rate limited. | Yes, after the Retry-After header (seconds). |
503 | A dependency is temporarily unavailable. | Yes, with backoff. |
5xx | Server error. | Yes, with backoff, but only for calls that are safe to retry (below). |
Quote and fill codes
These appear on 400 responses from GET /orderbook/{venueMarketOutcomeId}/route,
POST /execution/fill, POST /execution/orders, and POST /execution/limit-orders. The same
condition returns the same status and code on all three order routes: for example, a cash
balance that does not cover the order is always 400 with quote_insufficient_balance, and a venue
account or execution mode that changed while the order was being placed is always 400 with
quote_unfillable.
code | Meaning | What to do |
|---|---|---|
quote_not_found | The quote is not in the cache, usually because it expired. | Follow the fill-timeout steps below. Do not get a new quote until step 4. fallbackToLatest is on Execution. |
quote_expired | The quote expired, or an earlier fill of this quoteId is still running or failed partway. | Check GET /execution/status?quoteId= first, then follow the fill-timeout steps. Do not get a new quote until step 4. |
quote_already_executed | This quoteId was already filled. | Do not re-fill. Poll status for it. |
quote_cancelled | The quote was cancelled. | Re-quote. |
quote_user_mismatch | The quote belongs to another user, or is a preview quote with no user. | Re-quote with the user's access token. |
quote_app_blocked | The route uses a venue or category your app has disabled, or a venue that is no longer available. | Re-quote. The new route avoids it. |
quote_unfillable | The quote cannot be executed as priced, or the fill does not match it (for example mode or signing wallet). On POST /execution/limit-orders: the managed wallet does not exist yet (Wallet is not ready for limit orders), or the venue account or execution mode changed while the order was placed. | Re-quote (for a limit order, resend it). Read message for the reason. For a wallet that is not ready, call GET /execution/deposit-addresses and poll until ready: true. |
quote_min_order_size | The amount is below a venue minimum. message names it. Not used on POST /execution/limit-orders, where size and tick refusals are 400 with no code. | Re-quote with a larger amount. |
quote_insufficient_balance | The user's balance no longer covers the trade. This includes venue-held cash (Kalshi, a Direct ProphetX or Novig account, TipRun account cash). On POST /execution/limit-orders, a sell whose available shares do not cover sizeRaw also returns this code. | Refresh balances and re-quote a smaller amount, or fund the account. |
quote_market_inactive | The market stopped accepting orders. | Re-quote, or pick another market. |
quote_self_custody_unsupported_venue | Self-custody fill routes to a venue without self-custody support. | Re-quote without signingAddress, or restrict allowedVenues. |
quote_self_custody_app_fee | Self-custody fill pays its fee with wallet-sent transfers and the wallet lacks the gas for them. A fee that cannot be collected is waived, not refused. | See Self-custody trading. |
quote_self_custody_redeem_unsupported | Self-custody sell that settles by redeeming. | Re-quote without signingAddress. |
venue_not_executable | Direct order on a venue that does not support execution, or a quote or order on a market its venue cannot trade right now. | Terminal for that venue or market. Pick another. |
outcome_ambiguous | Direct order: several outcomes on the venue match and none is the one you named. message lists them. | Send the outcome id that lives on that venue. |
paper_mode_unsupported | Direct order from an app in paper trading mode. Direct orders are live only. | Terminal. Paper-trade with mode: "paper" on the managed quote and fill. |
A rejected fill is remembered for the rest of the quote's life. Sending the same quoteId again
returns the same error. For quote_not_found and quote_expired, do not get a new quote until
step 4 below. For any other code in the table, get a new quote only when that row says to.
Idempotency and safe retries
| Call | Key | On repeat | After a timeout |
|---|---|---|---|
POST /execution/orders | externalId (required, max 36 chars, unique within your app) | 409, no second trade. A request rejected before its order was queued does not use up the externalId. | Resend with the same externalId, or GET /execution/orders?externalId= to find the order |
POST /execution/limit-orders | clientOrderId (optional, unique per user) | 409, no second order | Resend with the same clientOrderId, or GET /execution/orders?clientOrderId=. See below. |
POST /execution/fill | The quoteId | 400 with quote_already_executed or quote_expired; never a second trade | Resend with the same quoteId, or poll GET /execution/status?quoteId=. Orders there mean the fill was accepted. 404 is not a final answer; see below. |
POST /execution/withdraw | requestId (optional UUID, exact-amount only) | Same body returns the original withdrawal; a changed body returns 409 | Retry with the same requestId and body. See below. |
POST /execution/redeem | None. Each outcome is checked on its own. | 200 with rejected / redeem in progress, or ineligible / already redeemed; never a second payout | Resend the same venueMarketOutcomeIds |
POST /execution/fill/{quoteId}/signatures | The stepId | A step that already succeeded is ignored | Resubmit the batch |
POST /execution/fill has no separate idempotency key: one quoteId can create at most one set of
orders, so resending the same quoteId is always safe. Never get a new quote to retry a fill whose
result you do not know, or you can trade twice.
After a timeout on POST /execution/fill:
- Keep the same
quoteId. PollGET /execution/status?quoteId=, or resend the fill with thatquoteId. - Orders in the status response mean the fill was accepted. Track it to a terminal status.
- A
404from status means no orders are recorded yet. The original request may still be running, so treat the result as unknown and keep polling. - Treat the fill as not executed only when the quote's lifetime has passed, status for that
quoteIdstill returns404, and a resend with the samequoteIdreturnsquote_not_foundorquote_expired. Only then get a new quote.
Generate one externalId per intended trade, store it before you send the request, and reuse it
across retries. A direct order the venue refuses after acceptance still uses up its externalId.
Limit orders, withdrawals and claims
A 4xx on these routes means the request started nothing that can place an order or move funds,
now or later, with the one withdrawal exception below. 401, 403, 409 and 429 write nothing.
For 400:
- Limit order: no
400writes an order or uses up theclientOrderId. - Withdrawal: a
400for an unviable price leaves afailedwithdrawal that never moves funds. Send arequestId. With it, every other400writes nothing. Without it, a hand-off to execution that fails is also returned as400with afailedrecord, and funds can still move in rare cases. - Claim: no
4xxwrites a claim.
A timeout or 5xx is unknown, not a failure. The server keeps working after your client gives up,
and the record is written before the response, so it can exist even though you got no answer.
| Route | Record | Visible | Recover |
|---|---|---|---|
POST /execution/limit-orders | Order, written before the response | At once, by ?clientOrderId= on GET /execution/orders or GET /apps/{appId}/users/{userId}/orders | Always send a clientOrderId. Resend with it: 409 means the order exists, so read it. 200 means the first never committed. |
POST /execution/withdraw | Withdrawal, written before the response | At once, in GET /apps/{appId}/users/{userId}/withdrawals | Always send a requestId. Resend the same body: you get the original withdrawal, or a new one if the first never arrived. |
POST /execution/redeem | redeem activity, written before anything is sent on-chain | At once, in GET /users/activity?type=redeem | Resend the same ids. Outcomes are claimed one at a time, so after a timeout some may be submitted and some not; the resend tells you which. |
Do not wait for "no row" to become final. There is no fixed time after which a missing row is proven. The resend is the check.
A 200 on a limit order does not mean it will rest. A venue can still refuse it after it is
accepted. The order then ends failed and keeps its clientOrderId.
A requestId is remembered permanently and is not returned on any response. The same
requestId and body replays the original withdrawal, even long after the preview's
quoteExpiresAt, and even when that original failed. Use a new requestId for a new attempt. The
body comparison covers amountRaw, tokenSymbol, destinationChainId and
destinationAddress.
Reads (GET) are always safe to retry.
Rate limits
A 429 carries Retry-After in seconds. Wait that long before retrying. Server calls with a
validated x-app-api-key get their own budget. See
Rate limits.