Authentication
PolySimulator uses two auth methods, scoped to different jobs:
Most users only ever see the API key — the dashboard at
polysimulator.com/api-keys
handles the Bearer-JWT bootstrap with your signed-in Supabase
session, so you click a button and copy the
ps_live_… value.
The Bearer-JWT API path exists for headless setups (CI, dev tooling)
where there’s no browser session.
The Polymarket-CLOB-compatible read endpoints (e.g. /v1/book,
/v1/midpoint, /v1/spread, /v1/markets-by-token) are public
and don’t require a key. For convenience, PolySimulator also accepts
the single-value header aliases POLY_API_KEY, Poly-API-Key, and
Authorization: Bearer ps_live_…, each carrying the whole ps_live_
key, on authenticated routes (X-API-Key takes precedence when
several are sent).
These aliases are a deliberate PolySimulator simplification — not a
literal match of Polymarket’s request shape. Real Polymarket L2 auth
attaches five
POLY_* headers per request — POLY_ADDRESS,
POLY_SIGNATURE (an HMAC-SHA256 of the request), POLY_TIMESTAMP,
POLY_API_KEY, POLY_PASSPHRASE — and py-clob-client /
@polymarket/clob-client never send a bare POLY_API_KEY or an
Authorization: Bearer <key> on their own. PolySimulator collapses
all of that to one value (your ps_live_ key) and ignores HMAC
signing because it’s a paper-trading backend. So porting a bot still
means pointing the SDK’s host at PolySimulator and feeding it the
ps_live_ key — the aliases just mean common HTTP clients that
default to Authorization: Bearer … or send POLY_API_KEY aren’t
rejected; they don’t make a real py-clob-client work unchanged.Key Format
Keys follow a predictable pattern for easy identification:ps_live_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2
Each key has a visible prefix (first 16 chars) used for identification without exposing the full key:
How It Works
When you send a request:- Your API key is SHA-256 hashed and looked up in the database
- The key’s
is_activeandexpires_atfields are validated - Rate limits are enforced based on your key’s tier
- The associated user account is loaded for trading operations
Permissions
Keys support granular permissions:Key management is gated by auth, not by the
trade scope.
Creating, listing, renaming, rotating, and revoking keys
(POST/GET/PATCH/DELETE /v1/keys, /v1/keys/{id}/rotate) only
require a valid credential for your account — any active ps_live_ key
(even a read-only one) or your dashboard Supabase JWT. You don’t need a
trade-scoped key to manage keys. (Whether a free key can trade
depends on when it was issued — on or after 2026-09-05 it can, earlier
it is read-only; see Open Beta below.)Security Best Practices
Store keys in environment variables
Store keys in environment variables
Never hardcode API keys in source code. Use environment variables or a secrets manager.
Rotate keys for short-lived CI/CD deployments
Rotate keys for short-lived CI/CD deployments
There is no
expires_at field on key creation — POST /v1/keys
and POST /v1/keys/bootstrap only accept name, tier, and
permissions. (expires_at is set server-side: it appears on a
rotated key’s old half during the 24h overlap window, and on
beta-issued keys as their beta_until cutoff.) For short-lived
deployments, rotate instead: POST /v1/keys/{id}/rotate mints a
replacement and schedules the old key to expire after a 24h overlap,
so you can roll a key without downtime and let the old one lapse on
its own.Principle of least privilege
Principle of least privilege
Create separate keys for different bots:
- Data-only bot:
["read"]permission - Trading bot:
["read", "trade"]permission
Rotate keys regularly
Rotate keys regularly
Create a new key, update your bot, then revoke the old key:
Maximum 5 keys per user
Maximum 5 keys per user
The system enforces a limit of 5 active keys per user account.
Revoke unused keys to free up slots.
Error Responses
All
/v1/* errors return a structured envelope with a stable machine code
in error and human-readable prose in message. Branch on error, never on
message. The identical code is also carried in X-Polysim-Code for clients
that centralize response handling around headers. Domain-specific codes include
INVALID_KEY, INSUFFICIENT_PERMISSION, RATE_LIMIT_EXCEEDED,
BOOK_UNAVAILABLE, and VALIDATION_FAILED; handlers without a domain code use
HTTP_<status> (for example, HTTP_400 or HTTP_500).
The X-Request-Id response header always echoes the request id for
log/support correlation.
error:
On
429 responses, check the Retry-After header for exact wait time in seconds.Venue outage — VENUE_UNAVAILABLE
PolySim fills your orders against Polymarket’s live order book. When
Polymarket itself is down, that book stops moving — so an order that has to
execute now could only be filled at a frozen price. Rather than invent a
fill, we pause those order shapes for the duration of the outage:
- Paused: market orders, and limit orders with
time_in_forceofIOC,FOKorFAK. They return503 VENUE_UNAVAILABLE. - Still accepted: ordinary limit orders (
GTC,GTD). They rest as usual and reporthold_reason: "venue_outage"inGET /v1/ordersuntil the venue recovers, at which point matching resumes on its own — you do not need to re-place them. - Unchanged: cancellation, listing, market data, and account reads. A resting order can still be cancelled during an outage, and time-bound (Up/Down) orders still expire at their window close exactly as they would otherwise.
retry_after is in the default body as well as the Retry-After header —
this is one of two documented exceptions to the single-field envelope (the
other is 402 UPGRADE_REQUIRED), because an outage can last hours and the
back-off interval is the one thing every caller needs.
Back off on retry_after rather than retrying immediately: this 503 is a
deliberate halt, not a transient failure, and it clears when Polymarket does.
The Python SDK raises a dedicated VenueUnavailableError (carrying
.retry_after) and deliberately does not consume its retry budget on it:
Bootstrap Flow (First-Time Setup)
The recommended path is the dashboard at polysimulator.com/api-keys. Sign in, click Create your first API key, and copy theps_live_… value shown once. The dashboard handles the Supabase
JWT exchange transparently.
Bootstrap from a script (headless / CI)
If you can’t open a browser and you have a Supabase access token in hand, callPOST /v1/keys/bootstrap directly:
Security boundary
- JWTs are verified using HS256 against the project’s Supabase
signing secret, with
audience="authenticated", signature, expiry, and thesubUUID all enforced server-side. Anon and service-role tokens are rejected. - Bearer is accepted on the dashboard surface only:
POST /v1/keys/bootstrap, key management (GET/POST/DELETE /v1/keys,/v1/keys/tiers,/v1/keys/ws-token),GET /v1/me,GET /v1/account/me/entitlements, and/v1/me/wallets/*— the routes the signed-in dashboard reads. Trading (/v1/orders,/v1/order,/v1/clob/order), market data (/v1/markets*,/v1/book,/v1/midpoint*, etc.), the account-trading reads (/v1/account/{balance,positions,portfolio, history,equity}), and the websocket connect URL all requireX-API-Keyand reject Bearer with 401 — short-lived JWTs cannot reach the trade engine or the account ledger. - Bootstrap is idempotency-bounded: if the JWT subject already has
any key, the endpoint returns 400
BOOTSTRAP_NOT_ALLOWEDand the caller must usePOST /v1/keys(withX-API-Key) for additional keys. - Bootstrap is rate-limited at 5 calls/hour and 1 call/minute per IP, on top of the global IP rate limit. Real users only bootstrap once per account; the limit caps abuse without breaking legitimate network-error retries.
Open Beta
The public API is in open beta. Anyone who can sign in can mint a key. The default path is self-serve:
The cutoff is the key’s
created_at (UTC). A free account whose paid grant
has expired stays read-only regardless of the date, and admin is never
self-issued at any tier. Free + trade is no longer rejected;
403 TIER_REQUIRES_UPGRADE is now returned only when a free caller asks for
a paid tier, for admin, or for trade on an expired paid grant. Paying
subscribers are not blocked by a waitlist or API_PRO_COMING_SOON on the
default path.
CLOSED_BETA and API_PRO_COMING_SOON remain valid X-Polysim-Code
values for a residual emergency close or a leftover cohort grant, but
they are not the default outcome. Branch on the header, not the
body prose.
Residual runtime access — ACCESS_RESTRICTED
Authenticated /v1/* requests can still return
403 ACCESS_RESTRICTED if an already-issued key’s account is
flagged or under review. The stable machine code appears in both the body and
the X-Polysim-Code response header:
Legacy beta-issued keys
Older cohort keys may still carry abeta_until cutoff. After that
cutoff the key is auto-downgraded to free-tier limits and read-only
permissions. Responses on a downgraded key include
X-API-Beta-Cutoff: expired so SDKs can pivot without a separate
round-trip: