Endpoint Reference
All endpoints are under https://api.polysimulator.com/v1/partner and authenticate
with your tenant X-API-Key (see Authentication).
| Method | Path | Permission | Status |
|---|---|---|---|
POST | /v1/partner/users | users:write | Available now |
GET | /v1/partner/users/{user_id} | accounts:read | Available now |
POST | /v1/partner/users/{user_id}/login-link | users:write | Available now |
POST | /v1/partner/accounts | accounts:write | Available now |
GET | /v1/partner/accounts/{account_id} | accounts:read | Available now |
GET | /v1/partner/accounts/{account_id}/trades | accounts:read | Available now |
PATCH | /v1/partner/accounts/{account_id} | accounts:write | Available now |
POST | /v1/partner/accounts/{account_id}/close | accounts:write | Available now |
POST | /v1/partner/accounts/{account_id}/reset | accounts:write | Available now |
The whole /v1/partner surface returns 404 until your tenant is enabled — see the
release status. Cross-tenant reads also return 404, never
403: you only ever see your own resources, and there is no enumeration signal.
POST /v1/partner/users
Section titled “POST /v1/partner/users”Register a trader, or idempotently resolve an existing one.
Request
Section titled “Request”| Field | Type | Required | Description |
|---|---|---|---|
external_id | string (1–255) | Yes | Your stable ID for this trader. Idempotency key; unique within your tenant. |
email | string (3–320) | Yes | Trader’s email. Lowercased + trimmed server-side. |
metadata | object | No | Free-form JSON you control. Stored and echoed back. |
{ "external_id": "pc_trader_123", "email": "trader@example.com", "metadata": { "country": "ES" }}Response — 201 Created (new) or 200 OK (idempotent replay)
Section titled “Response — 201 Created (new) or 200 OK (idempotent replay)”{ "user_id": 12345, "tenant_user_id": "6a2f0c4e-1b7d-4e2a-9f3c-1a2b3c4d5e6f", "external_id": "pc_trader_123", "email": "trader@example.com", "created": true}| Field | Type | Description |
|---|---|---|
user_id | int | The trader’s ID. Use it on all account calls. |
tenant_user_id | string (UUID) | Your mapping row for this trader. |
external_id | string | Echoes your external_id. |
email | string | The resolved (normalized) email. |
created | bool | true on a new mapping (201), false on idempotent replay (200). |
Idempotency
Section titled “Idempotency”- Same
external_id, same email →200, existing mapping (created: false). - Same
external_id, different email →409 EXTERNAL_ID_CONFLICT. - If the email already belongs to an existing trader, registration links to that
trader and returns a normal success — there is no signal about prior existence, so
you never branch on it. You always get back a
user_id.
Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
201 | — | New trader created |
200 | — | Idempotent replay (existing mapping, matching email) |
400 | INVALID_EMAIL | Email failed the shape check (one @, a domain with a dot) |
403 | INSUFFICIENT_PERMISSION | Key lacks users:write |
409 | EXTERNAL_ID_CONFLICT | external_id already mapped to a different email |
409 | USER_ALREADY_MAPPED | This trader is already mapped under a different external_id |
502 | AUTH_PROVISION_FAILED | Upstream identity provisioning failed (retry) |
GET /v1/partner/users/{user_id}
Section titled “GET /v1/partner/users/{user_id}”Fetch a tenant-scoped trader profile plus only your tenant’s accounts for that trader. Personal balances, billing, and leaderboard data are never exposed.
Response — 200 OK
Section titled “Response — 200 OK”{ "user_id": 12345, "tenant_user_id": "6a2f0c4e-1b7d-4e2a-9f3c-1a2b3c4d5e6f", "external_id": "pc_trader_123", "email": "trader@example.com", "accounts": [ { "account_id": "0b1d8e2a-3c4f-5a6b-7c8d-9e0f1a2b3c4d", "external_id": "pc_challenge_001", "stage": "challenge", "status": "active", "starting_balance": "10000.00", "wallet_id": 9911, "created_at": "2026-05-31T12:00:00Z" } ]}Each accounts entry is the summary shape (no balance/equity — call
GET /v1/partner/accounts/{account_id} for live state). accounts is [] if the
trader has no accounts under your tenant.
Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
200 | — | Profile returned |
403 | INSUFFICIENT_PERMISSION | Key lacks accounts:read |
404 | USER_NOT_FOUND | The user isn’t mapped to your tenant (also returned for unknown IDs) |
POST /v1/partner/users/{user_id}/login-link
Section titled “POST /v1/partner/users/{user_id}/login-link”Send the trader a one-time magic-link email to sign in to the native PolySimulator UI. The link is never returned in the response body.
Request
Section titled “Request”The body is a JSON object ({} minimum is required — an absent body is rejected 422).
| Field | Type | Required | Description |
|---|---|---|---|
redirect_url | string | No | Where the trader lands after auth. Must be in your tenant’s redirect allowlist. Defaults to https://polysimulator.com. |
{ "redirect_url": "https://polysimulator.com/markets" }Response — 202 Accepted
Section titled “Response — 202 Accepted”{ "status": "accepted" }202 means the email was dispatched, not that the trader has clicked it.
Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
202 | — | Magic-link email dispatched |
400 | REDIRECT_NOT_ALLOWED | redirect_url not in the tenant allowlist |
403 | INSUFFICIENT_PERMISSION | Key lacks users:write |
404 | USER_NOT_FOUND | The user isn’t mapped to your tenant |
502 | MAGIC_LINK_FAILED | Upstream email dispatch failed (retry) |
POST /v1/partner/accounts
Section titled “POST /v1/partner/accounts”Create a tenant-governed account, backed by an isolated wallet funded to
starting_balance.
Request
Section titled “Request”| Field | Type | Required | Description |
|---|---|---|---|
user_id | int | Yes | The user_id from POST /v1/partner/users. Must be mapped to your tenant. |
external_id | string (1–255) | Yes | Your stable ID for this account. Idempotency key; unique within your tenant. |
stage | string | Yes | One of demo, challenge, funded. A label for your evaluation flow. |
starting_balance | string | Yes | Opening balance. Max 2 decimal places, within tenant bounds (default $100–$1,000,000). |
metadata | object | No | Free-form JSON you control. |
rules | object | No | Free-form JSON. Stored verbatim and echoed back; PolySimulator does not interpret or enforce it. |
{ "user_id": 12345, "external_id": "pc_challenge_001", "stage": "challenge", "starting_balance": "10000.00", "metadata": { "plan": "10k", "attempt": 1 }, "rules": { "profit_target_pct": 20, "max_drawdown_pct": 10 }}Response — 201 Created (new) or 200 OK (idempotent replay)
Section titled “Response — 201 Created (new) or 200 OK (idempotent replay)”{ "account_id": "0b1d8e2a-3c4f-5a6b-7c8d-9e0f1a2b3c4d", "wallet_id": 9911, "user_id": 12345, "external_id": "pc_challenge_001", "stage": "challenge", "status": "active", "starting_balance": "10000.00", "rules": { "profit_target_pct": 20, "max_drawdown_pct": 10 }, "metadata": { "plan": "10k", "attempt": 1 }}| Field | Type | Description |
|---|---|---|
account_id | string (UUID) | The account’s ID. Use it on state/trades/close/reset/PATCH calls. |
wallet_id | int | The wallet backing this account. |
status | string | active on creation; closed after a close; back to active after a reset. |
rules | object | Echo of the rules you sent ({} if none). |
metadata | object | Echo of the metadata you sent ({} if none). |
Idempotency
Section titled “Idempotency”external_id is the idempotency key, unique within your tenant:
- Same
external_idwith matchinguser_id+stage+starting_balance→200with the existing account (nothing is created). - Same
external_idwith any of those three differing →409 ACCOUNT_EXTERNAL_ID_CONFLICT.
A safely-retried create never double-provisions a wallet. Treat both 201 and 200 as
success.
Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
201 | — | New account created |
200 | — | Idempotent replay (existing account, matching immutable fields) |
400 | INVALID_STAGE | stage not in demo/challenge/funded |
400 | INVALID_BALANCE | starting_balance not a number, or > 2 decimal places |
400 | BALANCE_OUT_OF_BOUNDS | starting_balance outside the tenant min/max |
403 | INSUFFICIENT_PERMISSION | Key lacks accounts:write |
404 | USER_NOT_FOUND | user_id not mapped to your tenant |
409 | ACCOUNT_EXTERNAL_ID_CONFLICT | external_id reused with different immutable fields |
422 | VALIDATION_FAILED | A field failed schema validation (wrong type, missing required field) |
GET /v1/partner/accounts/{account_id}
Section titled “GET /v1/partner/accounts/{account_id}”Return the live account-state envelope: balance, equity, unrealized PnL, and open positions marked to market against current Polymarket prices.
Response — 200 OK
Section titled “Response — 200 OK”{ "account_id": "0b1d8e2a-3c4f-5a6b-7c8d-9e0f1a2b3c4d", "external_id": "pc_challenge_001", "stage": "challenge", "status": "active", "balance": "9875.12", "starting_balance": "10000.00", "equity": "10142.42", "unrealized_pnl": "267.30", "realized_pnl": null, "open_positions": [ { "market_id": "0xabc123...", "outcome": "Yes", "quantity": "100.0000", "avg_entry_price": "0.4100", "current_price": "0.4350", "market_value": "43.50", "unrealized_pnl": "2.50" } ], "rules": { "profit_target_pct": 20, "max_drawdown_pct": 10 }, "metadata": { "plan": "10k", "attempt": 1 }, "updated_at": "2026-05-31T12:00:00Z"}| Field | Type | Description |
|---|---|---|
balance | string | Cash balance of the account’s wallet. |
starting_balance | string | The opening balance the account was provisioned with. |
equity | string | balance + Σ(open-position market value). Mark-to-market total. |
unrealized_pnl | string | equity − starting_balance. Mark-to-market P&L vs the baseline. |
realized_pnl | null | Always null in v1 — see caution below. |
open_positions[] | array | Per-market open positions, each marked to market. |
rules | object | Echo of the stored rules ({} if none). Updatable via PATCH. Stored verbatim; not interpreted. |
metadata | object | Echo of the stored metadata ({} if none). |
updated_at | string | Stamped once at creation and equal to created_at in v1 — it does not advance on PATCH/close/reset, so don’t use it as a change signal. Always present (never null). |
Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
200 | — | State returned |
403 | INSUFFICIENT_PERMISSION | Key lacks accounts:read |
404 | ACCOUNT_NOT_FOUND | Account doesn’t exist or belongs to another tenant |
422 | INVALID_ACCOUNT_ID | account_id in the path is not a valid UUID |
GET /v1/partner/accounts/{account_id}/trades
Section titled “GET /v1/partner/accounts/{account_id}/trades”The primary data feed for your evaluation engine: filled trades for the account’s wallet, newest first, with keyset (cursor) pagination.
Query parameters
Section titled “Query parameters”| Param | Type | Default | Description |
|---|---|---|---|
limit | int | 100 | Page size, 1–500. |
cursor | string | — | Opaque cursor from a previous page’s next_cursor. Omit for the first page. |
side | string | — | Filter: BUY or SELL (case-insensitive). Any other value → 422 VALIDATION_FAILED (not silently treated as “no match”). |
market_id | string | — | Filter to a single Polymarket condition_id. |
Response — 200 OK
Section titled “Response — 200 OK”{ "data": [ { "trade_id": 8881, "order_id": 8881, "market_id": "0xabc123...", "side": "BUY", "outcome": "Yes", "price": "0.4100", "quantity": "10.0000", "notional": "4.10", "fee": "0.00", "filled_at": "2026-05-31T12:30:00Z", "client_order_id": "trader-idempotency-key", "realized_pnl": null } ], "next_cursor": "eyJmaWxsZWRfYXQiOiIyMDI2LTA1LTMxVDEyOjMwOjAwKzAwOjAwIiwib3JkZXJfaWQiOjg4ODF9"}| Field | Type | Description |
|---|---|---|
data[] | array | Filled trades only, ordered filled_at DESC, order_id DESC. |
data[].notional | string | price × quantity in USD. |
data[].fee | string | Fee charged on the fill ("0.00" for paper trading today). |
data[].client_order_id | string | null | Idempotency key supplied when the trade was placed, if any. |
data[].realized_pnl | null | Always null in v1 (compute from fills). |
next_cursor | string | null | Opaque cursor for the next page. null when there are no more rows. |
Keyset pagination
Section titled “Keyset pagination”There is no offset and no total count. The cursor encodes (filled_at, order_id); follow
next_cursor until it is null. (Why keyset — see
Rate Limits & Errors.)
def all_trades(account_id, key, base="https://api.polysimulator.com"): cursor, out = None, [] while True: params = {"limit": 500} if cursor: params["cursor"] = cursor page = requests.get( f"{base}/v1/partner/accounts/{account_id}/trades", headers={"X-API-Key": key}, params=params, ).json() out.extend(page["data"]) cursor = page["next_cursor"] if not cursor: # null → last page return outasync function allTrades(accountId, key, base = "https://api.polysimulator.com") { let cursor = null; const out = []; do { const params = new URLSearchParams({ limit: "500" }); if (cursor) params.set("cursor", cursor); const page = await ( await fetch(`${base}/v1/partner/accounts/${accountId}/trades?${params}`, { headers: { "X-API-Key": key }, }) ).json(); out.push(...page.data); cursor = page.next_cursor; // null → last page } while (cursor); return out;}Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
200 | — | Page returned |
400 | INVALID_CURSOR | cursor is malformed (not a cursor we issued) |
403 | INSUFFICIENT_PERMISSION | Key lacks accounts:read |
404 | ACCOUNT_NOT_FOUND | Account doesn’t exist or belongs to another tenant |
422 | INVALID_ACCOUNT_ID | account_id in the path is not a valid UUID |
422 | VALIDATION_FAILED | side is not BUY/SELL, or limit is out of 1–500 |
PATCH /v1/partner/accounts/{account_id}
Section titled “PATCH /v1/partner/accounts/{account_id}”Update an account’s stored rules and/or metadata. Each field you send replaces the
stored object wholesale; an omitted (or null) field is left untouched. Returns the full
account-state envelope with the updated config echoed
back.
Only rules and metadata are patchable — stage, starting_balance, user_id, and
external_id are immutable. To advance a trader to a new stage, provision a new account
with the next stage label (there is no promote-stage operation; see the
v1 boundaries).
Request
Section titled “Request”| Field | Type | Required | Description |
|---|---|---|---|
rules | object | No | New rules object. Replaces the stored value wholesale. Omit to leave unchanged. |
metadata | object | No | New metadata object. Replaces the stored value wholesale. Omit to leave unchanged. |
The body is validated with extra='forbid': an unknown key (e.g. {"rulez": {...}})
returns 422 VALIDATION_FAILED rather than silently doing nothing. An empty body
({}) is a valid no-op that echoes current state back.
{ "rules": { "profit_target_pct": 8, "max_drawdown_pct": 6, "max_daily_loss_pct": 4 }, "metadata": { "plan": "8k", "attempt": 1, "cohort": "2026Q3" }}Response — 200 OK
Section titled “Response — 200 OK”The full account-state envelope (identical shape to
GET), with rules/metadata reflecting the update.
Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
200 | — | Config updated (or no-op on an empty body) |
403 | INSUFFICIENT_PERMISSION | Key lacks accounts:write |
404 | ACCOUNT_NOT_FOUND | Account doesn’t exist or belongs to another tenant |
422 | INVALID_ACCOUNT_ID | account_id in the path is not a valid UUID |
422 | VALIDATION_FAILED | Unknown field in the body, or rules/metadata not a JSON object |
POST /v1/partner/accounts/{account_id}/close
Section titled “POST /v1/partner/accounts/{account_id}/close”Archive an account — never destructive. Sets status to closed and stops any future
trade from running against it. Balance, open positions, trade history, and the rest of the
envelope are preserved — every read endpoint keeps working on a closed account.
Before closing, any open pending limit orders on the wallet are cancelled and their
reserved funds released (BUY notional refunded to the balance, SELL reservations
returned). In v1 trading is via the native UI, so the wallet usually has none — but
close handles them for when the execution path lands.
Takes no request body. close is idempotent: closing an already-closed account is a
200 no-op.
Response — 200 OK
Section titled “Response — 200 OK”The full account-state envelope (same shape as GET),
now with "status": "closed".
Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
200 | — | Account closed (or idempotent replay on an already-closed account) |
403 | INSUFFICIENT_PERMISSION | Key lacks accounts:write |
404 | ACCOUNT_NOT_FOUND | Account doesn’t exist or belongs to another tenant |
409 | ORDERS_NOT_CANCELLED | Open orders couldn’t all be cancelled (over the bulk-cancel limit or a transient lock); retry to drain the remainder |
422 | INVALID_ACCOUNT_ID | account_id in the path is not a valid UUID |
POST /v1/partner/accounts/{account_id}/reset
Section titled “POST /v1/partner/accounts/{account_id}/reset”Restart a challenge on the same account, preserving the audit trail. Unlike a new account,
reset keeps the same account_id, external_id, and backing wallet, and:
- resets the balance back to
starting_balance; - cancels any open pending orders and releases their reservations (the refund is then absorbed by the balance reset, so there’s no inflation);
- archives any open positions so post-reset equity equals the reset balance;
- re-activates the account (a previously
closedaccount is reopened by a reset).
The prior journey — opening deposit, every fill, earlier resets — is preserved; nothing is deleted. Takes no request body.
Response — 200 OK
Section titled “Response — 200 OK”The full account-state envelope (same shape as GET):
balance and equity are back to starting_balance, status is active, and
open_positions is [].
Status codes
Section titled “Status codes”| Status | error | When |
|---|---|---|
200 | — | Account reset |
403 | INSUFFICIENT_PERMISSION | Key lacks accounts:write |
404 | ACCOUNT_NOT_FOUND | Account doesn’t exist or belongs to another tenant |
409 | ORDERS_NOT_CANCELLED | Open orders couldn’t all be cancelled (over the bulk-cancel limit or a transient lock); retry to drain the remainder |
422 | INVALID_ACCOUNT_ID | account_id in the path is not a valid UUID |
Next Steps
Section titled “Next Steps”- Quickstart — Run the full lifecycle end-to-end
- Rate Limits & Errors — Limits, envelope, idempotency, cursors
- PolyChallenge onboarding — Your environment + hand-off