Core concepts

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

StatusMeaningRetry?
400Invalid request, or a quote, fill or order refusal. Read code.Depends on code, see below. Never retry unchanged otherwise.
401Missing or invalid x-app-id, API key, or access token.Refresh the access token once, then retry. See Authentication & sessions.
403Not 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.
404Not 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.
409Conflict: 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.
429Rate limited.Yes, after the Retry-After header (seconds).
503A dependency is temporarily unavailable.Yes, with backoff.
5xxServer 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.

codeMeaningWhat to do
quote_not_foundThe 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_expiredThe 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_executedThis quoteId was already filled.Do not re-fill. Poll status for it.
quote_cancelledThe quote was cancelled.Re-quote.
quote_user_mismatchThe quote belongs to another user, or is a preview quote with no user.Re-quote with the user's access token.
quote_app_blockedThe 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_unfillableThe 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_sizeThe 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_balanceThe 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_inactiveThe market stopped accepting orders.Re-quote, or pick another market.
quote_self_custody_unsupported_venueSelf-custody fill routes to a venue without self-custody support.Re-quote without signingAddress, or restrict allowedVenues.
quote_self_custody_app_feeSelf-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_unsupportedSelf-custody sell that settles by redeeming.Re-quote without signingAddress.
venue_not_executableDirect 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_ambiguousDirect 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_unsupportedDirect 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

CallKeyOn repeatAfter a timeout
POST /execution/ordersexternalId (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-ordersclientOrderId (optional, unique per user)409, no second orderResend with the same clientOrderId, or GET /execution/orders?clientOrderId=. See below.
POST /execution/fillThe quoteId400 with quote_already_executed or quote_expired; never a second tradeResend 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/withdrawrequestId (optional UUID, exact-amount only)Same body returns the original withdrawal; a changed body returns 409Retry with the same requestId and body. See below.
POST /execution/redeemNone. Each outcome is checked on its own.200 with rejected / redeem in progress, or ineligible / already redeemed; never a second payoutResend the same venueMarketOutcomeIds
POST /execution/fill/{quoteId}/signaturesThe stepIdA step that already succeeded is ignoredResubmit 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:

  1. Keep the same quoteId. Poll GET /execution/status?quoteId=, or resend the fill with that quoteId.
  2. Orders in the status response mean the fill was accepted. Track it to a terminal status.
  3. A 404 from status means no orders are recorded yet. The original request may still be running, so treat the result as unknown and keep polling.
  4. Treat the fill as not executed only when the quote's lifetime has passed, status for that quoteId still returns 404, and a resend with the same quoteId returns quote_not_found or quote_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 400 writes an order or uses up the clientOrderId.
  • Withdrawal: a 400 for an unviable price leaves a failed withdrawal that never moves funds. Send a requestId. With it, every other 400 writes nothing. Without it, a hand-off to execution that fails is also returned as 400 with a failed record, and funds can still move in rare cases.
  • Claim: no 4xx writes 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.

RouteRecordVisibleRecover
POST /execution/limit-ordersOrder, written before the responseAt once, by ?clientOrderId= on GET /execution/orders or GET /apps/{appId}/users/{userId}/ordersAlways send a clientOrderId. Resend with it: 409 means the order exists, so read it. 200 means the first never committed.
POST /execution/withdrawWithdrawal, written before the responseAt once, in GET /apps/{appId}/users/{userId}/withdrawalsAlways send a requestId. Resend the same body: you get the original withdrawal, or a new one if the first never arrived.
POST /execution/redeemredeem activity, written before anything is sent on-chainAt once, in GET /users/activity?type=redeemResend 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.

On this page