Rate Limits & Errors
The operational contract for /v1/partner/*: the rate-limit budget, the error
envelope and code tables, idempotency, and the keyset cursor.
Rate limits
Section titled “Rate limits”Requests are metered by two independent sliding-window buckets (per-second and per-minute), at two scopes:
| Scope | Per-second | Per-minute |
|---|---|---|
| Per-tenant (aggregate across all your keys) | 50 | 3,000 |
| Per-key (each individual key) | 50 | 3,000 |
The per-tenant bucket is checked first — a runaway key cannot silently drain the whole tenant’s budget — and both buckets must pass.
When you exceed a limit
Section titled “When you exceed a limit”You get HTTP 429 with a Retry-After header (seconds) and the standard
error envelope:
HTTP/1.1 429 Too Many RequestsRetry-After: 1Content-Type: application/json{ "error": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded. Retry after 1s." }- Per-second cap →
Retry-After: 1. - Per-minute cap →
Retry-Afteris 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.
Rate-limit headers
Section titled “Rate-limit headers”Every /v1/partner/* response carries X-RateLimit-* headers (with
X-Polysim-RateLimit-* aliases of identical value):
| Header | Meaning |
|---|---|
X-RateLimit-Limit / -Remaining | Request budget + remaining for the current minute window. |
X-RateLimit-Reset | Seconds until that window resets. |
X-RateLimit-Limit-Per-Second / -Remaining-Per-Second | Per-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.
Handling 429 with backoff
Section titled “Handling 429 with backoff”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")async function partnerRequest( method: string, url: string, init: { headers: Record<string, string>; body?: string }, maxRetries = 4,): Promise<Response> { for (let attempt = 0; attempt < maxRetries; attempt++) { const resp = await fetch(url, { method, ...init }); if (resp.status === 429) { const wait = Number(resp.headers.get("Retry-After") ?? "1"); await new Promise((r) => setTimeout(r, wait * 1000)); continue; } if (resp.status >= 500) { await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); continue; } return resp; } throw new Error(`giving up after ${maxRetries} retries`);}Error envelope
Section titled “Error envelope”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"])Validation errors (422 VALIDATION_FAILED)
Section titled “Validation errors (422 VALIDATION_FAILED)”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 1–500), 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.
Error code reference
Section titled “Error code reference”Authentication & authorization
Section titled “Authentication & authorization”| Status | error | Meaning |
|---|---|---|
401 | MISSING_API_KEY | No X-API-Key (and no Authorization: Bearer) sent |
401 | INVALID_KEY | Key doesn’t exist or was revoked |
401 | KEY_DEACTIVATED | Key disabled |
401 | KEY_EXPIRED | Key past its expires_at |
403 | TENANT_DISABLED | Your tenant is disabled |
403 | INSUFFICIENT_PERMISSION | Key lacks the scope the route requires |
404 | NOT_FOUND | Partner surface not enabled for your tenant yet |
| Status | error | Endpoint | Meaning |
|---|---|---|---|
400 | INVALID_EMAIL | POST /users | Email failed the shape check |
409 | EXTERNAL_ID_CONFLICT | POST /users | external_id already mapped to a different email |
409 | USER_ALREADY_MAPPED | POST /users | This trader is already mapped under a different external_id |
400 | REDIRECT_NOT_ALLOWED | POST /users/{id}/login-link | redirect_url not on the tenant allowlist |
404 | USER_NOT_FOUND | GET /users/{id}, POST .../login-link, POST /accounts | User not mapped to your tenant |
502 | AUTH_PROVISION_FAILED | POST /users | Upstream identity provisioning failed (retry) |
502 | MAGIC_LINK_FAILED | POST /users/{id}/login-link | Upstream email dispatch failed (retry) |
Accounts
Section titled “Accounts”| Status | error | Endpoint | Meaning |
|---|---|---|---|
400 | INVALID_STAGE | POST /accounts | stage not in demo/challenge/funded |
400 | INVALID_BALANCE | POST /accounts | starting_balance not a number or > 2 decimals |
400 | BALANCE_OUT_OF_BOUNDS | POST /accounts | starting_balance outside the tenant min/max |
409 | ACCOUNT_EXTERNAL_ID_CONFLICT | POST /accounts | external_id reused with different user_id/stage/starting_balance |
400 | INVALID_CURSOR | GET /accounts/{id}/trades | cursor is malformed |
404 | ACCOUNT_NOT_FOUND | GET/PATCH/close/reset/.../trades | Account doesn’t exist or belongs to another tenant |
409 | ORDERS_NOT_CANCELLED | POST /accounts/{id}/close, .../reset | Open orders over the bulk-cancel limit or a transient lock — retry to drain the remainder |
422 | INVALID_ACCOUNT_ID | any /accounts/{id}… | account_id in the path is not a valid UUID |
422 | VALIDATION_FAILED | POST/PATCH /accounts, .../trades | Field failed schema validation (wrong type, unknown PATCH field, bad side/limit) |
429 | RATE_LIMIT_EXCEEDED | any | Per-tenant or per-key limit hit — see Retry-After |
Idempotency
Section titled “Idempotency”Idempotency is enforced by a uniqueness guarantee on your external_id (unique
within your tenant), not a separate header:
| Operation | Key | Behavior on replay |
|---|---|---|
POST /v1/partner/users | external_id | Same email → 200 with the existing mapping. Different email → 409 EXTERNAL_ID_CONFLICT. |
POST /v1/partner/accounts | external_id | Matching user_id+stage+starting_balance → 200 with the existing account. Any differ → 409 ACCOUNT_EXTERNAL_ID_CONFLICT. First create is 201. |
POST /v1/partner/accounts/{id}/close | account state | Closing 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).
Pagination (trades cursor)
Section titled “Pagination (trades cursor)”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
cursorencodes the(filled_at, order_id)of the last row you saw. - Each response returns
next_cursor; pass it back ascursorfor the next page. next_cursor: nullmeans the last page. Stop.limitis1–500(default100).
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 } ← doneA cursor we didn’t issue returns 400 INVALID_CURSOR — treat it as opaque, never
construct or mutate it.
Why keyset, not offset
Section titled “Why keyset, not offset”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.
Next Steps
Section titled “Next Steps”- Endpoint Reference — Per-endpoint request/response
- Quickstart — End-to-end flow
- Authentication — Tenant key handling