Skip to main content

Error Handling

Every API response uses standard HTTP status codes with structured JSON error bodies.

Error Response Format

By default, the /v1/* surface returns the Polymarket-shape single-field envelope — one error key holding a human-readable description:
{"error": "Account balance $12.50 insufficient for order cost $25.00"}
FieldTypeDescription
errorstringHuman-readable description. For PM-shape parity, the machine-readable short code is carried out-of-band in the X-Polysim-Code response header (e.g. INSUFFICIENT_BALANCE, RATE_LIMIT_EXCEEDED).
For stable error handling, branch on the X-Polysim-Code header, not on the body text — the body holds the prose, the header holds the code. The X-Request-Id header echoes the request id for log correlation. 402 UPGRADE_REQUIRED is the one carve-out to the PM-shape default: its body adds feature_key and upgrade_url alongside error so SDKs can render an upsell flow without an extra round-trip. Every other status code (401, 403, 404, 429, 5xx, …) sticks with the single-field shape and exposes the machine code via X-Polysim-Code.
// 402 UPGRADE_REQUIRED — feature_key + upgrade_url at the root
{
  "error": "UPGRADE_REQUIRED",
  "feature_key": "wallets.sandbox_baseline",
  "upgrade_url": "/pricing"
}
For the runtime allowlist gate (ACCESS_RESTRICTED — an already-issued key whose account isn’t on the API v1 allowlist, or is flagged / under review), the body is PM-shape and the code is in the header:
// 403 ACCESS_RESTRICTED (not on the allowlist / flagged account)
// + headers: X-Polysim-Code: ACCESS_RESTRICTED
{"error": "API access restricted. Contact support to request access."}
The closed-beta key-issuance gate uses the same shape: POST /v1/keys and POST /v1/keys/bootstrap return 403 CLOSED_BETA (the default for every non-admitted caller, including paying Pro / Pro+; the API_PRO_COMING_SOON variant appears only once self-serve issuance is enabled) with the machine code in the X-Polysim-Code header and the human message in the body’s error field. The feature_key / upgrade_url hints are body fields only on 402 responses, not these 403s. See Closed Beta Errors below.
Verbose body opt-in. Send X-Polysim-Verbose: true on any request to get the legacy multi-field shape:
{"error": "INVALID_KEY", "message": "Invalid API key",
 "details": null, "request_id": "a1b2c3d4-..."}
Useful when writing or debugging an SDK; PM-shape is the default so Polymarket-CLOB SDK ports work without translation.

HTTP Status Codes

CodeMeaningRetry?Action
200SuccessProcess response
400Bad requestNoFix request payload
401Invalid or missing API keyNoCheck X-API-Key header
403Insufficient permissionsNoCheck key scopes
404Resource not foundNoVerify market ID or order ID
409Conflict (duplicate idempotency key)NoUse original response
422Validation errorNoFix input fields
429Rate limitedYesWait for Retry-After header
500Internal server errorYesRetry with backoff
502Upstream errorYesRetry with backoff
503Service unavailableYesRetry with backoff

Common Error Codes

Trading Errors

This table is the canonical trading error-code reference — other trading pages link here rather than restating the codes.
Error CodeHTTPDescription
INSUFFICIENT_BALANCE400Not enough funds for order
INSUFFICIENT_POSITION400Sell rejected — your position size for the chosen token_id is smaller than the requested quantity. PolySimulator does not support naked shorts: every SELL must be backed by an existing position. See No naked shorts below for the two-sided-quote workaround.
INVALID_QUANTITY400Non-positive quantity rejected by the execution engine. (A schema-level out-of-range value is caught earlier by Pydantic as 422 VALIDATION_FAILED — see HTTP Status Codes above.)
PRICE_REQUIRED400Market order submitted without the required price (worst-price limit).
FOK_ORDER_NOT_FILLED_ERROR400The order could not fill entirely at or beyond your worst-price limit (BUY: best price above your cap; SELL: best price below your floor). This is the worst-price rejection for market / FOK orders — there is no 409 LIMIT_PRICE_NOT_MET code.
INVALID_ORDER_PAYLOAD400Body shape invalid (PM-shape /v1/order): bad maker/taker amounts, missing tokenId, or unsupported side
INVALID_ORDER_MIN_TICK_SIZE400Limit price doesn’t conform to the market’s tick size (0.1 / 0.01 / 0.001 / 0.0001). Round to a multiple of GET /v1/tick-size/{token_id}. Mirrors Polymarket’s behaviour exactly.
UNSUPPORTED_ORDER_TYPE400order_type=IOC on POST /v1/clob/order is rejected (use GTC to rest or FOK for immediate-or-fail). Code is in the X-Polysim-Code header.
MARKET_NOT_FOUND404Unknown market_id (or unknown tokenId on PM-shape /v1/order). Verify with GET /v1/markets-by-token/{token_id}.
MARKET_CLOSED400Market is resolved or inactive
ORDER_NOT_FOUND404Unknown order_id
ORDER_NOT_CANCELLABLE400The order is already FILLED, CANCELLED, or EXPIRED and can’t be cancelled.
DUPLICATE_CLIENT_ORDER_ID409A new order reused a client_order_id already bound to a different order.
IDEMPOTENCY_KEY_REUSE409The same Idempotency-Key was replayed with a different request body. (An identical replay instead returns the original order — see Idempotency below.)
IDEMPOTENCY_CONFLICT_PENDING409The same Idempotency-Key is still being processed; carries Retry-After: 1.
EXECUTION_ERROR500Server-side error during fill — report with request_id
There is no 409 LIMIT_PRICE_NOT_MET, 409 IDEMPOTENCY_CONFLICT, CANNOT_CANCEL, or HTTP_409 trading code — those names appeared in earlier drafts but are not emitted by the engine. Use the codes above.

Order status values

OrderResponse.status (and the status filter on GET /v1/orders) use the PolySimulator-native enum:
StatusMeaning
PENDINGLimit order resting on the book, not yet filled.
FILLEDOrder matched and executed.
CANCELLEDOrder cancelled (by you, FOK/IOC non-fill, or the dead-man’s-switch).
EXPIREDOrder expired (e.g. market resolved while resting).
REJECTEDPer-entry batch failure (see Batch Orders).
ERRORPer-entry batch internal error (batch only).
SDKs ported from Polymarket read the PM-shape ORDER_STATUS_* enum from GET /v1/data/orders instead — see CLOB Compatibility.

No naked shorts — INSUFFICIENT_POSITION explained

PolySimulator (and Polymarket itself) requires every SELL order to be backed by an existing position in that exact token_id. There is no margin, no borrow, no synthetic short. If you try to sell shares you don’t hold, the order is rejected with 400 INSUFFICIENT_POSITION (header X-Polysim-Code: INSUFFICIENT_POSITION). For binary markets (every /markets/{id} with two outcomes), the standard market-maker idiom is a two-sided buy rather than a buy + a short:
# Wrong — naked short on the "Down" side:
sell_down = post("/v1/order", json={"token_id": down_tok, "side": "SELL", "quantity": 100, "price": 0.51})
# → 400 INSUFFICIENT_POSITION (you don't hold any Down shares)

# Right — buy both sides instead. Up + Down ≈ 1.00, so total notional is similar
# to a two-sided quote, and you keep the spread on whichever side fills first:
buy_up   = post("/v1/order", json={"token_id": up_tok,   "side": "BUY", "quantity": 100, "price": 0.49})
buy_down = post("/v1/order", json={"token_id": down_tok, "side": "BUY", "quantity": 100, "price": 0.49})
After a buy fills you accumulate position; subsequent SELLs against that position are accepted up to the held quantity. Use GET /v1/account/positions to check your inventory per token_id before submitting a SELL.

Authentication Errors

Error CodeHTTPDescription
MISSING_AUTH401No Authorization: Bearer … or X-API-Key on a route that accepts either
MISSING_API_KEY401X-API-Key (or legacy POLY_API_KEY) missing on a key-only route
INVALID_KEY401Key doesn’t exist or is revoked
INVALID_TOKEN401Bearer JWT is malformed, missing sub, or fails signature verification
KEY_EXPIRED401API key has expired
KEY_DEACTIVATED401Key was administratively disabled
TOKEN_EXPIRED401Bearer JWT past its exp claim
INSUFFICIENT_PERMISSION403Key missing the required permission (e.g. trade for order endpoints)
ACCESS_RESTRICTED403An already-issued key (or Bearer JWT) is not on the admin-managed API v1 allowlist (or the account is flagged / under review). Returned on authenticated /v1/* requests. Body is PM-shape; check the X-Polysim-Code header.
CLOSED_BETA403Key issuance refused on POST /v1/keys / POST /v1/keys/bootstrap. The default for every non-admitted caller while the beta is closed, including paying Pro / Pro+. Machine code in the X-Polysim-Code header; the body’s error is the human message.
API_PRO_COMING_SOON403Conditional variant of the issuance gate for a paying Pro / Pro+ caller without a cohort grant — reached only once self-serve issuance is enabled (until then they also get CLOSED_BETA). Machine code in the X-Polysim-Code header.
UPGRADE_REQUIRED402Pro-tier cap reached (sandbox count, etc.). Only 402 carries feature_key + upgrade_url at the root of the response body.

Rate Limit Errors

The backend emits two distinct 429 codes (in the X-Polysim-Code header). Both are retryable and both carry Retry-After — branch on either, or simply treat any 429 as a back-off signal:
Error CodeHTTPDescription
RATE_LIMIT_EXCEEDED429The per-tier in-process concurrency cap (all /v1/* paths) or the IP / per-key request-rate limiter. Retry after Retry-After.
RATE_LIMITED429The cross-worker trade-concurrency limiter on the three trade-write paths (POST /v1/orders, /v1/orders/batch, /v1/clob/order). Body also carries retry_after_ms. Retry after Retry-After.
A bot that branches only on RATE_LIMIT_EXCEEDED will miss the RATE_LIMITED 429s from the trade-write paths (and vice-versa). The robust pattern is to back off on resp.status_code == 429 regardless of which code is in the header.
The per-tier limits (authoritative source: GET /v1/keys/tiers, seeded from the ApiRateLimit ladder):
TierReq/secReq/minWS connsMax batch
Free212011
Pro1060035
Pro+301,8001010
Enterprise1006,0005025
Closed-beta cohort keys run at the enterprise tier until the cutoff (2026-08-31), then auto-downgrade to free + read-only. If a static value here ever disagrees with GET /v1/keys/tiers, the endpoint wins.

Closed Beta Errors

The API is in an ongoing closed beta. Key issuance is cohort-gated:
Error CodeHTTPDescription
CLOSED_BETA403POST /v1/keys / POST /v1/keys/bootstrap refused. The default for every non-admitted caller while the beta is closed, including paying Pro / Pro+. Machine code in the X-Polysim-Code header; the body’s error is the human message. Not retryable — apply via the waitlist at https://polysimulator.com/api-trading.
API_PRO_COMING_SOON403Same endpoints, conditional variant for a paying Pro / Pro+ caller without a cohort grant — reached only once self-serve issuance is enabled (until then they also get CLOSED_BETA). Machine code in the X-Polysim-Code header.
ACCESS_RESTRICTED403Separate runtime gate: an already-issued key (or Bearer JWT) is not on the admin-managed allowlist (or is flagged / under review). Returned on authenticated /v1/* requests. Check the X-Polysim-Code header. Not retryable.
COHORT_FULL409Beta cohort issuance refused (admin endpoint). Detail: current_active, cap, requested.
Beta-issued keys carry an X-API-Beta-Cutoff response header on every request after the cutoff date — SDKs can pivot to read-only mode without an extra round-trip.
# Handle the closed-beta key-issuance gate cleanly. The issuance codes
# (CLOSED_BETA / API_PRO_COMING_SOON) are in the `X-Polysim-Code` header;
# the body's `error` field holds the human message.
resp = requests.post(f"{BASE_URL}/v1/keys", headers={"X-API-Key": KEY}, json={"name": "bot"})
if resp.status_code == 403:
    code = resp.headers.get("X-Polysim-Code")
    if code == "CLOSED_BETA":
        # Not in an admitted cohort yet — apply via the waitlist.
        print("Closed beta — join the waitlist at https://polysimulator.com/api-trading")
    elif code == "API_PRO_COMING_SOON":
        # Paying Pro / Pro+ without a cohort grant — rolling out in cohorts.
        print("Pro API access is rolling out — see /account/billing")

Retry Strategy

import time
import requests

def api_call_with_retry(method, url, max_retries=3, **kwargs):
    """Make API call with exponential backoff on retryable errors."""
    for attempt in range(max_retries + 1):
        try:
            resp = requests.request(method, url, **kwargs)

            if resp.status_code == 429:
                # Rate limited — use server-provided wait time
                wait = int(resp.headers.get("Retry-After", 2 ** attempt))
                print(f"Rate limited, waiting {wait}s...")
                time.sleep(wait)
                continue

            if resp.status_code >= 500:
                # Server error — retry with backoff
                wait = 2 ** attempt
                print(f"Server error {resp.status_code}, retry in {wait}s...")
                time.sleep(wait)
                continue

            # Success or client error (no retry)
            resp.raise_for_status()
            return resp.json()

        except requests.exceptions.ConnectionError:
            wait = 2 ** attempt
            print(f"Connection error, retry in {wait}s...")
            time.sleep(wait)

    raise Exception(f"Max retries ({max_retries}) exceeded for {url}")

WebSocket Error Handling

WebSocket connections use custom close codes:
Close CodeMeaningAction
1000Normal closeReconnect if desired
1001Server going awayReconnect after 1s
4001Authentication failedGet new token, reconnect
4002Subscription limit exceededReduce subscriptions
import asyncio
import aiohttp

async def resilient_ws(url, token, market_ids):
    """WebSocket connection with automatic reconnection."""
    backoff = 1
    while True:
        try:
            async with aiohttp.ClientSession() as session:
                async with session.ws_connect(f"{url}?token={token}") as ws:
                    backoff = 1  # Reset on successful connect

                    await ws.send_json({
                        "action": "subscribe",
                        "markets": market_ids,
                    })

                    async for msg in ws:
                        if msg.type == aiohttp.WSMsgType.TEXT:
                            handle_message(msg.data)
                        elif msg.type == aiohttp.WSMsgType.CLOSED:
                            break
                        elif msg.type == aiohttp.WSMsgType.ERROR:
                            break

        except Exception as e:
            print(f"WS error: {e}")

        wait = min(backoff, 30)
        print(f"Reconnecting in {wait}s...")
        await asyncio.sleep(wait)
        backoff *= 2

Best Practices

Always check status codes

Never assume a 2xx response. Parse the status code and handle each category appropriately.

Use Retry-After header

On 429 responses, the Retry-After header tells you exactly how long to wait. Don’t guess.

Don't retry 4xx errors

Client errors (400-422) indicate a problem with your request. Fix the payload instead of retrying.

Log error details

Always log the full error response body for debugging — the details field often contains actionable info.