Error codes

The complete error-code catalogue with HTTP status and meaning.

Errors return a stable code string alongside the HTTP status. Branch on code and nothing else. Money errors are 409 (a conflict with current state) unless noted.

  • code — stable machine identifier. The only field safe to branch on.
  • msg — human-readable text, translated into the language of your request — negotiated from Accept-Language (q-values honoured), or forced with a ?lang= query parameter, which wins over the header. A string comparison against this field passes in one locale and fails in another.
  • Which languages. Error text exists in 22 languages, matched on the primary subtag only: ar bn de en es fr hi id it ja ko nl pl pt ru sv th tr uk ur vi zh. pt-BR and pt-PT both resolve to pt. Anything unrecognised falls back to English rather than failing.
  • The same header also selects market and event titles — but there the region subtag is kept, because pt-BR and pt-PT are different translations of a title, not two spellings of one error string. Send the full tag (zh-CN, not zh) if you want translated titles. A title that has not been translated yet returns its original English text, which looks identical to a language we do not translate — never treat an English title as an error.
  • rid — request id. Log it. It is the one value that lets us find your exact request when you report a problem.
  • reason — underlying cause, present only in non-production environments or for a few errors that publish it deliberately. Never assume it is there.
Error envelopejson
{
  "code": "insufficient_balance",
  "msg": "Insufficient balance",
  "rid": "0f3a9c1e7b2d4856"
}
  • Rejected — nothing moved

    The request was understood and declined. Resending it unchanged will be declined again, so change the intent or stop. Only price_moved is worth a fresh attempt, and it needs a new idempotency key because the order itself changed.

    • insufficient_balance
    • market_not_open
    • market_locked
    • position_limit
    • price_moved
    • bad_request
  • Finished — already conserved

    That idempotency key has reached a terminal state and will never move again. Funds are settled on both sides. Retrying cannot succeed; a client that treats this as transient will spin forever.

    • idem_terminal
  • Unknown — may or may not have moved

    The call did not come back with a verdict, so you cannot tell whether it booked. Resend it with the SAME idempotency key: that is what makes the retry safe, because a repeat returns the original order instead of placing a second one. Never mint a new key here.

    • internal_server_error
    • upstream_error
    • too_many_requests
    • retry_later
    • timeout / no response

Retrying without double-spending

Two of these look alike and mean opposite things. retry_later (429) says someone else currently holds the lease for your idempotency key — resend the identical request after a backoff and it will go through. idem_terminal (409) says that key is finished and will never move again; funds are already conserved on both sides. Retrying it cannot succeed, and a client that treats it as transient will spin forever — which is exactly why the two are separate codes rather than one shared 409.

The global limit is counted per source IP, and the IP is the real TCP peer — forwarded headers are deliberately not trusted, so they cannot be used to widen your budget. If your calls leave through one NAT or egress gateway, every one of your servers shares the same 300-per-10-seconds bucket; size against that rather than against per-host throughput. The window is fixed, not sliding: it starts on the first request and the whole allowance returns at once when it expires.

Only too_many_requests carries a Retry-After header, and its value is the whole window rather than the time left in it — always 10, whether you were refused in the first second of the window or the last. Read it as a ceiling: waiting that long is always safe, and a client that treats it as an exact deadline recovers more slowly than one that retries a little sooner. For everything else, including retry_later, bring your own backoff. Reuse the same idempotency key whenever you are retrying the same intent; mint a new one only when the intent itself changed — after price_moved, for instance, you are placing a different order.

Generic
success200OK.
bad_request400Malformed or missing parameters.
unauthorized401Missing or invalid signature, timestamp outside the ±300s window, unknown or disabled key, replayed nonce, or a merchant that is not enabled. One code for all of them, deliberately: a finer answer on an unauthenticated surface would let a stranger probe which keys exist. Note what is NOT in this list any more — a session problem answers session_required or session_invalid instead, because by then the caller has proved it holds your secret and there is nothing left to protect.
forbidden403Authenticated but not allowed.
not_found404The resource does not exist, or exists and is not visible to you — these are deliberately indistinguishable, so that a 404 cannot be used to probe which market ids are real. A market your player traded and settled minutes ago returns this once it leaves your catalogue; so does an id nobody ever issued. Do not treat it as data loss or alert on it. Read settled markets from /settlements and /positions, whose rows carry the market title for exactly this reason.
conflict409Request conflicts with current state.
too_many_requests429Fixed-window rate limit exceeded, sent with a Retry-After header. A limit of 300 requests per 10 seconds (~30 rps) is counted per source IP address and applies to every endpoint including the B2B ones — only /healthz and /metrics are exempt. It is a flood guard on the address, not a quota attached to your credentials, so clients sharing an egress IP share the budget. Login and the public lead form carry tighter limits of their own on top.
payload_too_large413Request body exceeds 1 MiB. Rejected before signature verification — the body is never truncated, because a truncated body would not match the signature you computed.
internal_server_error500Unexpected server error.
upstream_error502A downstream dependency failed.
status_transition_invalid409The resource moved out from under you — another request got there first. Orders racing on one idem_key return this to the losers: measured live, eight concurrent posts gave one 200 and seven of these, all reporting the same order_id, debited once. It is not a malformed request, and retrying the identical call is correct — it will come back as the idempotent replay. Cash-outs racing on one position do not use this code; they answer 200 with ok:false and reject_code already_closed.
Identity & sessions
player_id_header_retired400The X-Player-Id header is gone. It is refused BEFORE the signature is checked, so this answer tells you nothing about your key — and it deliberately arrives instead of the 401 you would otherwise get from still signing the old seven-field canonical. Player identity now comes from a session: take one at POST /b2b/v1/auth/session with body {"player_id":"…"}, then send Authorization: Bearer on every player-scoped call. Remove the header and the PLAYER-ID line from your canonical in the same edit — the canonical is six fields now. Refused across all of /b2b/v1, including endpoints that never used the header, so that removing every send is checkable in one place.
invalid_player_id400Your player_id is outside ^[A-Za-z0-9._-]{1,64}$. Checked before anything is created or minted, so nothing was consumed and a corrected retry succeeds — this is deliberately not the old behaviour, where an out-of-range id would mint a launch token, burn it, and then 401 forever. The response carries the pattern and the length received, and does not echo the value back.
session_required401The signature verified; you simply did not send Authorization: Bearer on an endpoint that resolves a player. Do not go looking for a signing bug.
session_invalid401The signature verified; the session did not. Idle past 30 minutes, past the four-hour absolute cap, kicked by a newer session for the same player — one player holds exactly one session — or issued to a different merchant than the key that signed. Those four are one code on purpose: distinguishing them would tell a caller that someone else's token exists. Take a new session; it resolves to the same player.
merchant_currency_unset409Your merchant has no registered settlement currency, so the platform refuses to create the player rather than guessing one. Currency is the root of every ledger row and cannot be corrected after the fact — the previous behaviour, silently falling back to USDT, wrote a guess into money records permanently. Configuration state, not transient: retrying never clears it, and it clears the moment your account is completed.
Money & trading
insufficient_balance409Wallet cannot cover the stake.
market_not_open409Market is not accepting bets.
price_moved409Price exceeded max_price before execution.
position_limit409The order would take this player past their cap on one side of one market. The cap is the tighter of a platform-wide limit and the per-listing local_max_position you set, and it is measured against shares already held plus shares reserved by orders still awaiting wallet confirmation — so a player who has fired several orders in quick succession can hit it before any of them shows up in their positions. Not retryable as-is: close some of the position or wait for the pending orders to conclude.
market_locked409Four different causes share this code, and the difference matters when you decide whether to retry. The side you asked for is suspended; or the market is past its close_at — already shut, not approaching it, checked against the clock on every request so a lagging cron cannot leave a window open on a decided outcome; or the market has no anchored price, meaning it has never been fed a real one or its upstream book is empty and reports the 0.5 placeholder; or its price feed has gone stale and quoting from the last anchor would be quoting a price that no longer exists. Do not expect feed_age to explain a refusal: the no-anchor case is checked first and reads as freshly fed, so a market rejected one second after a feed frame is normal, not a bug. Everything except past close can clear on its own and a retry is reasonable. Past close never clears — treat it as terminal and stop.
capacity_exceeded409No room left on this side of the book.
quote_expired409The quote you are confirming has expired or was never issued. Quote again and confirm against the new one — never resend the old quote_id.
amount_too_small422The order is too small to be expressed honestly in money, and it is refused rather than rounded. Four ways in, all on the buy side: a stake that floors to zero cents; a cost below one minor unit once your margin is added; a share count under 0.01, whose maximum payout floors to zero; and the case worth knowing about — a quantised charge that has reached the most those shares could ever pay out, so the bet has no upside left to win. That last one is why a high-priced order with a fractional share count can be refused while a larger order at the same price goes through. Not retryable as sent: change the size. The same string also appears as a cash-out reject_code in a 200 body, where it means something different — the proceeds would floor below one minor unit and the residue is paid at settlement instead — so read the HTTP status before you branch.
insufficient_holding422A sell order asks for more than the player holds, counting shares already committed to open sell orders. Note the 422: this is one of the trading rejections that is not a 409.
idem_terminal409This idem_key was already carried to a terminal outcome — rejected, refunded, or handed to a human — and no live order remains under it. Funds are settled either way. Retrying the same key will never succeed; issue a new one.
player_forbidden403Player is blocked from trading.
margin_exceeds_cap422Requested margin exceeds the configured cap.
merchant_frozen403Merchant account is suspended.
merchant_domain_not_ready409Returned by the launch call when your merchant has no enabled player domain, so there is no host to build game_url on. Nothing is substituted — there is no platform-hosted origin to fall back to — and the response carries no URL. It is a configuration state rather than a transient one: it clears when a domain is activated and never by retrying. Every other B2B surface keeps working meanwhile, which is why a backend can look healthy while no player can be launched.
Limit orders
invalid_shares400Share amount is non-positive or malformed.
invalid_price400Limit price outside 0.01–0.99, or malformed. Note the bounds are that pair, not the open interval (0,1) — 0.005 is inside (0,1) and still refused, verified live.
insufficient_holding422Selling more shares than the player holds.
retry_later429Another party holds the reconciliation lease for this idempotency key. Transient — retry the identical request after a backoff. No Retry-After header is sent for this one.
idem_terminal409This idempotency key has reached a terminal state and will never progress. Funds are already settled either way. Do not retry it — a new attempt needs a new key.