Authentication
PolySimulator uses two auth methods, scoped to different jobs:
Most users only ever see the API key — the developer console at
polysimulator.com/console
handles the Bearer-JWT bootstrap with your signed-in Supabase
session, so you click a button and copy the
ps_live_… value.
The documented first-key setup requires one browser sign-in. The
bootstrap API accepts an existing session token; it does not issue one.
See Headless and CI setups.
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 developer console at polysimulator.com/console. Sign in, click Create a free API key, and copy theps_live_… value shown once. The dashboard handles the Supabase
JWT exchange transparently. (Once you hold a key, the console’s
button reads New key.)
Headless and CI setups
Your first key needs one browser sign-in. There is no published programmatic sign-in:POST /v1/keys/bootstrap takes a Supabase
access token, and the sign-in that issues one (Google, an emailed
code, or a password) runs in the web app. Sign in once from any
device (a phone works), create a key in the
developer console, and store it
as your CI secret.
After that, no browser is needed. Any active key for your account
can create, list, rotate and revoke your keys (see
Permissions). On the free plan a job can mint a
short-lived key from the stored one and revoke it when it finishes:
tierdefaults to your plan’s tier. A paid plan allows one live key per paid tier, so on Pro or Pro+ the mint above is refused with400 TIER_KEY_LIMIT_EXCEEDED. Use the stored key directly, or replace it without downtime withPOST /v1/keys/{id}/rotate.- An account holds at most 5 active keys
(
400 KEY_LIMIT_EXCEEDED). Revoke what each run mints. - Keys are per deployment: a key from the production console does not work on any other host.
Headless and CI setups
Use a browser once to obtain your first key. Sign in on a device you control, open the developer console, create a key, and save it in your secret store. Configure your client or CI job to read that secret and send it inX-API-Key.
There is no documented device-code approval flow or browserless
sign-in procedure in this API. Do not treat POST /v1/keys/bootstrap
as a login endpoint. An email-send response alone does not authenticate
an account. If you cannot complete browser sign-in, first-key setup is
not available through the documented non-browser flow.
Later key management can use the API. With a valid credential
and the required account access, use POST /v1/keys, GET /v1/keys,
POST /v1/keys/{id}/rotate, or DELETE /v1/keys/{id}.
Account access checks, permissions, and key limits still apply.
Creating another key does not grant a new tier or bypass an access denial.
New keys are not automatically short-lived. A job that creates a key
must revoke it when finished, including after a failed run. A finite
access grant can restrict its lifetime separately. Store a replacement
key before retiring the credential that your jobs still use.
If no usable key remains, return to browser sign-in and manage your keys
in the console. An account that still has key records receives
400 BOOTSTRAP_NOT_ALLOWED. Revocation deletes a key’s record. When no
key records remain, bootstrap can run again with a valid session token,
subject to account access checks and the rate limit.
Bootstrap with an existing session token
An advanced client that already holds a valid Supabase access token can callPOST /v1/keys/bootstrap with
Authorization: Bearer <supabase_access_token> and a JSON body such as
{"name": "my-first-bot"}. A successful response has status 201
and contains raw_key, which is shown only once.
This documents key issuance, not how to obtain a session token without
a browser. Do not extract browser cookies or refresh tokens as a CI
setup step. Do not use a service-role secret or an operator credential
to impersonate the account.
Use the session token’s actual expiry. This API does not extend it.
Missing, invalid, or expired credentials return 401; access denials
return 403. An account with existing keys must use key management
instead. A 429 response requires waiting before another attempt.
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 defaults to 5 calls/hour and 1 call/minute per IP, in addition to the global IP rate limit. The bootstrap limiter allows requests if Redis fails, so it is not a fail-closed security boundary. Authentication and account access checks still apply. The issued key belongs to the verified JWT subject, not an account selected in the request body.
- API keys are bearer credentials. Anyone who obtains one can act with its permissions while account access remains valid. Do not put keys or session tokens in URLs, source control, shell history, or logs. Revoke an exposed key. Test revocation with that key alone; a second valid credential can mask its rejection on routes that accept both.
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: