Skip to content

Rate Limits & Errors

The operational contract for /v1/partner/*: the rate-limit budget, the error envelope and code tables, idempotency, and the keyset cursor.


Requests are metered by two independent sliding-window buckets (per-second and per-minute), at two scopes:

ScopePer-secondPer-minute
Per-tenant (aggregate across all your keys)503,000
Per-key (each individual key)503,000

The per-tenant bucket is checked first — a runaway key cannot silently drain the whole tenant’s budget — and both buckets must pass.

You get HTTP 429 with a Retry-After header (seconds) and the standard error envelope:

HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json
{ "error": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded. Retry after 1s." }
  • Per-second cap → Retry-After: 1.
  • Per-minute cap → Retry-After is the seconds until the minute window rolls over.

Retry-After on the 429 is the authoritative back-off signal — honour it. The limiter fails open on an internal error, so an infrastructure hiccup won’t spuriously 429 you; don’t treat a missing limit as a guarantee, still back off.

Every /v1/partner/* response carries X-RateLimit-* headers (with X-Polysim-RateLimit-* aliases of identical value):

HeaderMeaning
X-RateLimit-Limit / -RemainingRequest budget + remaining for the current minute window.
X-RateLimit-ResetSeconds until that window resets.
X-RateLimit-Limit-Per-Second / -Remaining-Per-SecondPer-second budget + remaining.

Treat these as a coarse pacing hint, not an exact read-out of your prop-firm budget: today they reflect platform-wide request-limit defaults rather than your per-tenant bucket, and the top-level (non--Per-Second) values track the per-minute window only — so a request rejected by the per-second bucket can still show a non-zero X-RateLimit-Remaining. The 429 Retry-After is the signal to trust.

import time, requests
def partner_request(method, url, *, headers, json=None, max_retries=4):
for attempt in range(max_retries):
resp = requests.request(method, url, headers=headers, json=json)
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", "1")))
continue
if resp.status_code >= 500:
time.sleep(2 ** attempt) # exponential backoff on 5xx
continue
return resp
raise RuntimeError(f"giving up after {max_retries} retries")

Every /v1/partner/* error returns a two-field JSON body:

{ "error": "ACCOUNT_NOT_FOUND", "message": "Account not found." }
  • error — a stable machine code; branch on this (and the HTTP status).
  • message — human-readable, for logs/display. Don’t parse it.

The machine code is also in the X-Polysim-Code response header, identical to the body error. Route on whichever is convenient.

resp = partner_request("POST", f"{BASE}/v1/partner/accounts", headers=H, json=payload)
if resp.status_code in (200, 201):
account = resp.json()
elif resp.status_code == 409 and resp.json().get("error") == "ACCOUNT_EXTERNAL_ID_CONFLICT":
# external_id reused with different user/stage/balance — fix the payload
...
elif resp.status_code == 404 and resp.json().get("error") == "USER_NOT_FOUND":
# register the trader first
...
elif resp.status_code == 422:
# VALIDATION_FAILED — a field failed schema validation (the message names it)
raise ValueError(resp.json()["message"])

A request that fails schema validation returns 422 VALIDATION_FAILED; the message names the offending field(s):

{ "error": "VALIDATION_FAILED", "message": "missing external_id" }

This covers a missing or wrong-typed body field, an out-of-range query param (limit outside 1500), an unknown side filter on the trades endpoint, and — on PATCH /accounts/{id} — an unknown body field (the PATCH body forbids extra keys, so a typo like {"rulez": {...}} is a 422, not a silent no-op). A malformed path UUID uses the dedicated INVALID_ACCOUNT_ID code instead.

StatuserrorMeaning
401MISSING_API_KEYNo X-API-Key (and no Authorization: Bearer) sent
401INVALID_KEYKey doesn’t exist or was revoked
401KEY_DEACTIVATEDKey disabled
401KEY_EXPIREDKey past its expires_at
403TENANT_DISABLEDYour tenant is disabled
403INSUFFICIENT_PERMISSIONKey lacks the scope the route requires
404NOT_FOUNDPartner surface not enabled for your tenant yet
StatuserrorEndpointMeaning
400INVALID_EMAILPOST /usersEmail failed the shape check
409EXTERNAL_ID_CONFLICTPOST /usersexternal_id already mapped to a different email
409USER_ALREADY_MAPPEDPOST /usersThis trader is already mapped under a different external_id
400REDIRECT_NOT_ALLOWEDPOST /users/{id}/login-linkredirect_url not on the tenant allowlist
404USER_NOT_FOUNDGET /users/{id}, POST .../login-link, POST /accountsUser not mapped to your tenant
502AUTH_PROVISION_FAILEDPOST /usersUpstream identity provisioning failed (retry)
502MAGIC_LINK_FAILEDPOST /users/{id}/login-linkUpstream email dispatch failed (retry)
StatuserrorEndpointMeaning
400INVALID_STAGEPOST /accountsstage not in demo/challenge/funded
400INVALID_BALANCEPOST /accountsstarting_balance not a number or > 2 decimals
400BALANCE_OUT_OF_BOUNDSPOST /accountsstarting_balance outside the tenant min/max
409ACCOUNT_EXTERNAL_ID_CONFLICTPOST /accountsexternal_id reused with different user_id/stage/starting_balance
400INVALID_CURSORGET /accounts/{id}/tradescursor is malformed
404ACCOUNT_NOT_FOUNDGET/PATCH/close/reset/.../tradesAccount doesn’t exist or belongs to another tenant
409ORDERS_NOT_CANCELLEDPOST /accounts/{id}/close, .../resetOpen orders over the bulk-cancel limit or a transient lock — retry to drain the remainder
422INVALID_ACCOUNT_IDany /accounts/{id}…account_id in the path is not a valid UUID
422VALIDATION_FAILEDPOST/PATCH /accounts, .../tradesField failed schema validation (wrong type, unknown PATCH field, bad side/limit)
429RATE_LIMIT_EXCEEDEDanyPer-tenant or per-key limit hit — see Retry-After

Idempotency is enforced by a uniqueness guarantee on your external_id (unique within your tenant), not a separate header:

OperationKeyBehavior on replay
POST /v1/partner/usersexternal_idSame email → 200 with the existing mapping. Different email → 409 EXTERNAL_ID_CONFLICT.
POST /v1/partner/accountsexternal_idMatching user_id+stage+starting_balance200 with the existing account. Any differ → 409 ACCOUNT_EXTERNAL_ID_CONFLICT. First create is 201.
POST /v1/partner/accounts/{id}/closeaccount stateClosing an already-closed account is a 200 no-op (never re-stamps closed_at).

So:

  • Retry freely. A timeout on a create can be retried with the same external_id — you get the original resource back, never a duplicate.
  • Pick stable external_ids. They are the join key for every subsequent call and the guarantee against double-provisioning.
  • Concurrency is safe. Two simultaneous identical creates resolve to one row (one wins the insert; the other returns the existing resource).

Treat 409 *_CONFLICT as a caller bug, not a transient error: the same external_id was reused with different immutable fields. Don’t retry it — fix the payload (or use a new external_id, e.g. for a fresh challenge attempt).


GET /v1/partner/accounts/{account_id}/trades uses keyset (cursor) pagination — no offset, no total count.

  • Trades are ordered filled_at DESC, order_id DESC (newest first).
  • The opaque cursor encodes the (filled_at, order_id) of the last row you saw.
  • Each response returns next_cursor; pass it back as cursor for the next page.
  • next_cursor: null means the last page. Stop.
  • limit is 1500 (default 100).
GET .../trades?limit=500 → { data: [...500], next_cursor: "AAA" }
GET .../trades?limit=500&cursor=AAA → { data: [...500], next_cursor: "BBB" }
GET .../trades?limit=500&cursor=BBB → { data: [...37], next_cursor: null } ← done

A cursor we didn’t issue returns 400 INVALID_CURSOR — treat it as opaque, never construct or mutate it.

Offset pagination slows linearly as an account accumulates thousands of fills, and can skip or duplicate rows when new trades land mid-pagination. Keyset is O(1) per page at any depth and stable under concurrent inserts — what reconciliation against a busy account needs. The trades endpoint shows the full loop.