The Polymarket API is the developer interface to the largest decentralized prediction market, where binary contracts settle on Polygon in pUSD — an ERC-20 collateral token backed 1:1 by USDC. Polymarket exposes three independent base URLs: gamma-api.polymarket.com for discovery (markets, events, tags, search), data-api.polymarket.com for analytics (positions, trades, leaderboards), and clob.polymarket.com for real-time pricing and trading. All read endpoints are unauthenticated and available from any country; trading requires EIP-712 wallet signing once to derive credentials, then HMAC-SHA256 on every request, and is geo-blocked from the US, UK, France, Germany, Italy, the Netherlands, Belgium, and several other jurisdictions. Rate limits are Cloudflare-throttled per 10-second window with no account-tier system. This guide walks through the post-2026-04-28 CLOB V2 API verified against docs.polymarket.com on 2026-05-16.
If you're carrying over V1 code that uses nonce, feeRateBps, USDC.e, or the un-suffixed @polymarket/clob-client package, skim §0 — those references stopped working at the V2 cutover and your code will fail silently.
0. What changed at the V2 cutover
Polymarket moved the CLOB to a V2 contract on 2026-04-28 (the migration page first announced 4/22; the changelog entry written 4/17 confirmed the actual cutover at 4/28 — verify against status.polymarket.com if you're shipping a guide). Roughly half the order-struct fields changed, the collateral token changed from USDC.e to pUSD, and the SDK packages got new names. Here's the diff:
| Topic | Old (V1, retired 2026-04-28) | Current (V2) |
|---|---|---|
| Order struct | nonce, feeRateBps, taker required | Those fields removed; timestamp/metadata/builder added (see §6.2) |
| Fees | Embedded in the signed order | Set by the protocol at match time, not signed |
| EIP-712 Exchange domain | version: "1", V1 verifyingContract | version: "2", V2 verifyingContract (different per neg-risk flag) |
| Builder attribution | Separate SDK + 4 POLY_BUILDER_* headers | Single builder field on the order (bytes32) |
| Collateral token | USDC.e (bridged) | pUSD (1:1 USDC-backed) |
| SDK packages | @polymarket/clob-client, py-clob-client | @polymarket/clob-client-v2, py-clob-client-v2 |
| Constructor | Positional args, chainId | Options object, chain |
Three things did not change at the cutover and trip people up: the L1 ClobAuthDomain stays at version: "1" forever (it's the auth domain, separate from the Exchange domain), POLY_TIMESTAMP headers stay in seconds (only the Order struct's timestamp is in milliseconds), and the geo-blocks didn't shift — the US, UK, France, Germany, Italy, the Netherlands, Belgium, and several other countries remain blocked from placing orders.
If any of those rows surprise you, this guide is for you.
1. 30 seconds: your first Polymarket API response
The fastest possible Polymarket call is unauthenticated. The Gamma API publishes every market and event without a key, a signature, or a wallet — pull any market's metadata with a single request. From there you can decide whether you actually need to authenticate at all, because only trading requires a signature; reading the orderbook, prices, history, positions, and trades does not.
curl -sS 'https://gamma-api.polymarket.com/markets/keyset?limit=5' \
| jq '.markets[] | {slug, question, outcomes, last_trade_price}'
Connect Parlay MCP:
https://mcp.parlay.run/mcp
Ask your AI client:
Search Polymarket for 5 active markets and include source links.
Expect:
Structured market results from Parlay's read-only MCP tools, not raw Gamma JSON.
Direct API read vs hosted Parlay MCP research prompt.
Two notes worth knowing before you go further:
- Trading is geo-blocked from the United States, United Kingdom, France, Germany, Italy, the Netherlands, Belgium, and many other jurisdictions. Singapore, Poland, Thailand, and Taiwan are close-only (you can exit positions but not open new ones). Read endpoints are not blocked. If you're in a blocked region, you can still use this entire API for research, dashboards, aggregation, or watching market data in real time. You just can't place orders.
- Old tutorials reference V1 fields that no longer work. Anything mentioning
nonce,feeRateBps,taker, USDC.e, or the@polymarket/clob-clientpackage (without-v2) is V1 and stopped working on 2026-04-28. This guide is V2 throughout.
The rest of this guide assumes you want to do something more interesting than read a single market's metadata.
2. Concepts you need first
Polymarket's data model is different from most exchange APIs and from Kalshi specifically, and a lot of confusion in the first hour comes from skipping past the model and going straight to authentication. Three concepts pay back the time: how events relate to markets to tokens, why there are three separate APIs, and what "negative risk" means on a market.
2.1 Events, markets, and tokens
A real-world question — "Who will win the 2028 US presidential election?" — is an event in Polymarket's vocabulary. Each candidate gets their own market under that event ("Will Trump win?", "Will Newsom win?", and so on), and each market is a binary YES/NO contract. Each side of each market is a separate ERC-1155 token with its own asset_id (also called token_id). So a single event might have eight markets and sixteen tokens.
Event ("2028 presidential election")
├── Market ("Will Trump win?", condition_id 0xabc…)
│ ├── Token YES, asset_id 102936…
│ └── Token NO, asset_id 102937…
├── Market ("Will Newsom win?", condition_id 0xdef…)
│ ├── Token YES, asset_id 203847…
│ └── Token NO, asset_id 203848…
└── …
The orderbook lives at the token level, not the market level. When you want to "buy YES", you're buying the YES asset_id; the matching engine treats the YES and NO tokens of the same market as separate books. The condition_id is the on-chain identifier for the market (used by the CTF Exchange contract); the asset_id is the per-side token identifier you'll use against /book, /price, and POST /order.
2.2 Three APIs, one universe
Polymarket exposes three independent base URLs:
| API | Base URL | Used for | Auth |
|---|---|---|---|
| Gamma | gamma-api.polymarket.com | Discovery: markets, events, tags, series, comments, sports, search, profiles | Public |
| Data | data-api.polymarket.com | Analytics: positions, trades, activity, holders, leaderboards, open interest | Public |
| CLOB | clob.polymarket.com | Real-time pricing + trading: orderbook, prices, midpoints, history, orders | Public read; L1+L2 to trade |
The three APIs share no SDK, no auth surface, and no rate-limit budget. Pick the right one per call:
bridge.polymarket.com (funding deposits via the fun.xyz proxy), isn't covered in this guide.Most third-party tutorials describe Polymarket as if it had a single API. It doesn't. If you're listing markets, that's Gamma. If you're computing somebody's PnL, that's Data. If you're reading the orderbook or placing an order, that's CLOB. The three have different rate limits, different pagination semantics, and (for CLOB-trading) different authentication requirements.
2.3 Negative-risk markets
Multi-outcome events that are mutually exclusive — exactly one of N can resolve YES — get a special treatment called "negative risk." The 2028 presidential election is the canonical example: there are dozens of candidate markets, but at most one of them will be YES. Without negative-risk handling, an arbitrageur shorting all of them simultaneously would have to post collateral for each one independently, and the protocol would over-collateralize the event.
You don't need to understand the mechanism to use the API, but you do need to know one thing: markets with neg_risk: true use a different verifyingContract for the Exchange EIP-712 domain. The address is published in the docs/resources/contracts page. If you sign an order against the wrong verifyingContract — for example, you assume every market is non-negRisk — the signature is technically valid for the wrong domain and the matching engine rejects it as invalid signature. Always read neg_risk from the market or orderbook response and pick the corresponding contract before signing. The CLOB V2 SDK handles this for you; if you're rolling your own signing, you have to do it explicitly.
3. Authentication
Polymarket CLOB authentication has two layers. L1 is an EIP-712 wallet signature, made once per key, that authenticates POST /auth/api-key and returns three credentials (apiKey, secret, passphrase). L2 is an HMAC-SHA256 signature over timestamp_seconds + METHOD + path + body, made on every authenticated CLOB request, using the L1-derived secret. Both layers carry five POLY_* headers; both timestamps are in Unix seconds (not milliseconds — the Order struct's timestamp field is the only Polymarket value that uses ms). Gamma and Data are entirely unauthenticated; only CLOB trading and private CLOB reads (/data/orders, /data/trades) require L2 headers. The conceptual breakdown below covers the layering and the four common failure modes; the mechanics are not complicated, but every detail has to be right and the failure mode is always the same opaque 401.
Auth — Step 1: How L1 and L2 fit together
L1 (EIP-712, run once per key) is wallet-level identity. You sign a fixed message with your wallet's private key — Polymarket calls the typed-data domain ClobAuthDomain, version "1", chainId 137 (Polygon mainnet). The signature is sent with five POLY_* headers and authenticates a single sensitive request: the one that creates or derives your API credentials. After that, you don't sign with the wallet again for normal trading — you use the credentials L1 issued you.
L2 (HMAC-SHA256, run on every request) is request-level identity. The credentials L1 gave you are an apiKey (UUID), a secret (base64), and a passphrase (random string). On every authenticated CLOB request, you HMAC-sign the request itself (timestamp + method + path + body) with the secret. Five POLY_* headers carry the API key, the passphrase, the timestamp, the address, and the HMAC signature. No wallet involvement — pure symmetric crypto, fast.
Three things about the layering surprise people:
ClobAuthDomainalways usesversion: "1", even after the V2 cutover. The V2 upgrade only touched the Exchange domain (used when you sign orders), not the Auth domain (used when you create credentials).- L1 and L2 timestamps are both Unix seconds, not milliseconds. (The Order struct's
timestampis the only field on Polymarket that uses milliseconds — see §6.) If you've come from Kalshi where header timestamps are in ms, this trips you up immediately. signerandfunderare not the same address. With proxy, Safe, or deposit-wallet flows, the wallet you sign with can differ from the address that holds the funds. Order rejections likethe order owner has to be the owner of the API KEYalmost always trace to confusing the two. SetsignatureTypecorrectly:0for a standalone EOA,1for the existing Polymarket proxy-wallet flow,2for existing Gnosis Safe users, and3(POLY_1271) for the deposit-wallet flow that Polymarket recommends for new API users.
Auth — Step 2: Minimal raw signing reference
Below is a minimal raw implementation for both layers. Polymarket recommends the official SDKs for production because they handle the same signing paths and the current wallet flows, but the raw version is useful when you need to debug a 401 by hand. It authenticates against POST /auth/api-key, then uses the resulting credentials on GET /data/orders, an L2-protected CLOB endpoint.
import base64
import hashlib
import hmac
import time
import requests
from eth_account import Account
PRIVATE_KEY = "0x..." # your wallet
ADDRESS = "0x..." # the wallet's address
BASE_URL = "https://clob.polymarket.com"
# --- L1: derive credentials, run once ---
def derive_credentials():
ts = str(int(time.time())) # seconds, not ms
domain = {"name": "ClobAuthDomain", "version": "1", "chainId": 137}
types = {"ClobAuth": [
{"name": "address", "type": "address"},
{"name": "timestamp", "type": "string"},
{"name": "nonce", "type": "uint256"},
{"name": "message", "type": "string"},
]}
value = {
"address": ADDRESS,
"timestamp": ts,
"nonce": 0,
"message": "This message attests that I control the given wallet",
}
signed = Account.sign_typed_data(PRIVATE_KEY, domain, types, value)
headers = {
"POLY_ADDRESS": ADDRESS,
"POLY_SIGNATURE": signed.signature.hex(),
"POLY_TIMESTAMP": ts,
"POLY_NONCE": "0",
}
resp = requests.post(BASE_URL + "/auth/api-key", headers=headers)
resp.raise_for_status()
return resp.json() # {apiKey, secret, passphrase}
# --- L2: sign every authenticated request ---
def l2_headers(creds, method, path, body=""):
ts = str(int(time.time())) # seconds
msg = f"{ts}{method.upper()}{path}{body}"
sig = hmac.new(
base64.urlsafe_b64decode(creds["secret"]),
msg.encode("utf-8"),
hashlib.sha256,
).digest()
return {
"POLY_ADDRESS": ADDRESS,
"POLY_API_KEY": creds["apiKey"],
"POLY_PASSPHRASE": creds["passphrase"],
"POLY_TIMESTAMP": ts,
"POLY_SIGNATURE": base64.urlsafe_b64encode(sig).decode("utf-8"),
}
creds = derive_credentials()
print(requests.get(
BASE_URL + "/data/orders",
headers=l2_headers(creds, "GET", "/data/orders"),
).json())
import { Wallet } from 'ethers';
import { createHmac } from 'node:crypto';
const PRIVATE_KEY = '0x...';
const ADDRESS = '0x...';
const BASE_URL = 'https://clob.polymarket.com';
const wallet = new Wallet(PRIVATE_KEY);
// --- L1: derive credentials, run once ---
async function deriveCredentials() {
const ts = String(Math.floor(Date.now() / 1000));
const domain = { name: 'ClobAuthDomain', version: '1', chainId: 137 };
const types = {
ClobAuth: [
{ name: 'address', type: 'address' },
{ name: 'timestamp', type: 'string' },
{ name: 'nonce', type: 'uint256' },
{ name: 'message', type: 'string' },
],
};
const value = {
address: ADDRESS,
timestamp: ts,
nonce: 0,
message: 'This message attests that I control the given wallet',
};
const signature = await wallet.signTypedData(domain, types, value);
const res = await fetch(`${BASE_URL}/auth/api-key`, {
method: 'POST',
headers: {
POLY_ADDRESS: ADDRESS,
POLY_SIGNATURE: signature,
POLY_TIMESTAMP: ts,
POLY_NONCE: '0',
},
});
return res.json() as Promise<{ apiKey: string; secret: string; passphrase: string }>;
}
// --- L2: sign every authenticated request ---
function l2Headers(
creds: { apiKey: string; secret: string; passphrase: string },
method: string,
path: string,
body = '',
) {
const ts = String(Math.floor(Date.now() / 1000));
const msg = ts + method.toUpperCase() + path + body;
const secret = Buffer.from(creds.secret, 'base64url');
const sig = createHmac('sha256', secret).update(msg).digest('base64url');
return {
POLY_ADDRESS: ADDRESS,
POLY_API_KEY: creds.apiKey,
POLY_PASSPHRASE: creds.passphrase,
POLY_TIMESTAMP: ts,
POLY_SIGNATURE: sig,
};
}
const creds = await deriveCredentials();
const res = await fetch(`${BASE_URL}/data/orders`, {
headers: l2Headers(creds, 'GET', '/data/orders'),
});
console.log(await res.json());
# L2 only — assumes you already have apiKey/secret/passphrase from L1.
API_KEY="..."
SECRET_B64="..." # base64url-encoded secret
PASSPHRASE="..."
ADDRESS="0x..."
METHOD="GET"
PATH_PART="/data/orders"
TS=$(date +%s)
PAYLOAD="${TS}${METHOD}${PATH_PART}"
# base64url HMAC-SHA256
SIG=$(printf '%s' "$PAYLOAD" \
| openssl dgst -sha256 -hmac "$(printf '%s' "$SECRET_B64" | base64 -d -i)" -binary \
| base64 | tr '+/' '-_' | tr -d '=')
curl -sS "https://clob.polymarket.com${PATH_PART}" \
-H "POLY_ADDRESS: ${ADDRESS}" \
-H "POLY_API_KEY: ${API_KEY}" \
-H "POLY_PASSPHRASE: ${PASSPHRASE}" \
-H "POLY_TIMESTAMP: ${TS}" \
-H "POLY_SIGNATURE: ${SIG}"
If POST /auth/api-key returns a 200 with three fields, L1 is working. If a subsequent L2 call returns 200, L2 is working too. If you get a 401 anywhere, jump to Step 3.
Auth — Step 3: Four real traps
Most signing failures fall into one of four buckets. The error message is always one of Unauthorized/Invalid api key or Invalid L1 Request headers — the cause is what differs.
Trap A — signer versus funder confusion. With proxy wallets, Gnosis Safe, or deposit wallets, the address that signs can differ from the address that holds funds. The L1/L2 headers identify the signer, but order placement also requires the funder address. If you treat them as the same, you get the order owner has to be the owner of the API KEY on every order. Set the correct signatureType (1 proxy, 2 Safe, 3 deposit wallet) and pass the correct maker/funder addresses; the SDK does this automatically.
Trap B — confusing the two EIP-712 domains. ClobAuthDomain (used for L1) is fixed at version: "1" forever, including post-V2. The Exchange domain (used for signing actual orders) moved to version: "2" on 2026-04-28 with a new verifyingContract. Mixing them up — for example, signing your L1 auth message with the Exchange domain because you copy-pasted from an order example — produces a valid signature for the wrong domain and a 401 you can't debug from the error message alone.
Trap C — milliseconds versus seconds. Both POLY_TIMESTAMP headers (L1 and L2) are Unix seconds. The Order struct's timestamp field, which you'll meet in §6, is Unix milliseconds. They look identical in code (a string of digits), and the matching engine cheerfully accepts the wrong unit on the wrong field with no error. POLY_TIMESTAMP=1713398400 is correct; POLY_TIMESTAMP=1713398400000 looks valid but will eventually fail validation as the server-side timestamp window closes.
Trap D — key management in production. Polymarket does not custody your wallet's private key — it cannot. If you lose the key, you lose access to whatever the wallet controls; there is no recovery path. Treat the key like an SSH key: in production, use AWS Secrets Manager, Google Secret Manager, or HashiCorp Vault, and prefer a deposit wallet or Safe over a hot EOA so you can rotate signers without losing the funder address. Rotate L2 API credentials separately from the wallet: DELETE /auth/api-key revokes a key, POST /auth/api-key creates a new one, and GET /auth/derive-api-key derives existing credentials for the same nonce.
Auth — Step 4: Which client layer to use
The signing block above is undifferentiated infrastructure; the official @polymarket/clob-client-v2 SDK encapsulates both L1 and L2 and is the right tool for CLOB auth, order signing, and trading. Gamma catalogue calls (/markets/keyset, /events/keyset, /public-search) are unauthenticated HTTP; call them directly rather than routing through the SDK. Parlay's Model Context Protocol server covers cross-venue research over Polymarket plus Kalshi, Manifold, and Limitless — useful only when your application spans more than Polymarket.
4. Endpoints that actually matter across three APIs
Polymarket's three APIs are large; the day-to-day surface area is much smaller. Here's the working subset, grouped by API. Sample requests use cURL — the auth requirements (none for Gamma and Data, L2 for CLOB trading) are noted in the cheat sheet at the end.
4.1 Gamma — discovery (no auth)
Gamma is the catalogue layer. You'll use it to find markets by tag, search by keyword, and walk events down to their child markets.
GET /markets/keyset— cursor-paginated list of markets (default 20, max 100 as of the 2026-05-14 changelog). Filter bytag_id,closed,condition_ids,clob_token_ids,liquidity_num_min,liquidity_num_max,volume_num_min, andvolume_num_max. Use the keyset endpoint for any new code; the offset-paginated/marketsis on a deprecation path.GET /events/keyset— same shape, for events.GET /markets/slug/{slug}andGET /markets/{id}— single-market metadata (includes condition ID, token IDs, neg-risk flags, and tick-size fields).GET /events/slug/{slug}— event with its child markets.GET /tags,GET /tags/{id}, andGET /tags/slug/{slug}— tag discovery; usetag_idon/markets/keysetto list markets for a tag.GET /public-search— text search across markets, events, and profiles.GET /sports— sport-specific schedule data (a separate vertical with extra fields).GET /comments— public market discussion (rate-limited tighter than the rest of Gamma).GET /profiles/{address}— public profile data for a wallet.
4.2 CLOB — pricing and orderbook (no auth for read)
CLOB is the live-data and trading layer. The read endpoints don't require auth; they're indexed by token_id (the YES or NO asset_id of a market).
GET /book?token_id=...— full orderbook for one token, both sides. The four fields annotated below are the ones every signed-order code path needs:
/book that every signed-order code path needs to read before constructing or signing an order. Sizes and prices are strings; tick_size and neg_risk vary per market and determine which Exchange contract to use.POST /books— batched orderbook fetch for up to 50 tokens at a time (cheaper on the Cloudflare-throttled budget than 50 single requests).GET /price?token_id=...&side=BUY— best price for one side.GET /midpoint?token_id=...— midpoint price.GET /spread?token_id=...— current spread.GET /last-trade-price?token_id=...— most recent matched trade.GET /prices-history?market=...&startTs=...&endTs=...&interval=1h&fidelity=60— historical price series for one token.intervalis one ofmax,all,1m,1w,1d,6h, or1h;fidelityis an integer number of minutes.POST /batch-prices-history— same historical-price shape for up to 20 token IDs.GET /tick-size?token_id=...andGET /fee-rate?token_id=...— per-market constants. Read these before signing an order; a price that violatestick_sizeis rejected.GET /server-time— server's current Unix-second timestamp; useful for clock-drift checks.
4.3 CLOB — trading (L2 auth)
Trading endpoints all require L2 headers; POST /order additionally requires that the order body contain a separately-signed EIP-712 Order payload (see §6).
POST /order— place a single order.POST /orders— batched place, up to 15 orders per call.DELETE /order— cancel one order by sending{ "orderID": "..." }in the JSON body.DELETE /orders— cancel many by sending an array of order IDs in the JSON body.DELETE /cancel-all— cancel every live order on the account. Rate-limited tighter than per-order cancels.DELETE /cancel-market-orders— cancel everything for a condition ID and optional token by JSON body.POST /heartbeat— opt-in dead-man's-switch: if you don't beat for N seconds, the server cancels all your open orders. Useful for market-makers.GET /data/orders— your live orders.GET /data/trades— your fills.
4.4 Data — analytics (no auth)
The Data API lives at its own hostname. Most endpoints accept a user (wallet address) query parameter and return JSON arrays.
data-api.polymarket.com/positions?user=0x...— open positions, by market./closed-positions?user=0x...— settled positions./activity?user=0x...— chronological activity feed (deposits, trades, settlements)./trades?user=0x...— your fills, with optionalmarketandbeforefilters./holders?market=0x...— top holders by token balance for a market./leaderboardand/builder-leaderboard— global rankings by PnL or volume./open-interest— protocol-wide open interest, snapshot.
4.5 Endpoint cheat sheet
| Endpoint | API | Method | Auth | Notes |
|---|---|---|---|---|
/markets/keyset | Gamma | GET | — | Cursor pagination; replaces /markets |
/events/keyset | Gamma | GET | — | Cursor pagination; replaces /events |
/markets/slug/{slug} | Gamma | GET | — | Returns condition and token metadata |
/events/slug/{slug} | Gamma | GET | — | Event with child markets |
/public-search | Gamma | GET | — | Text search across catalogue |
/book | CLOB | GET | — | Bids and asks; read tick_size and neg_risk here |
/books | CLOB | POST | — | Batch up to 50 tokens |
/price /midpoint /spread | CLOB | GET | — | Cheap pricing endpoints |
/prices-history /batch-prices-history | CLOB | GET/POST | — | Historical price series; interval enum + minute fidelity |
/order | CLOB | POST | L2 + EIP-712 | Single order; see §6 |
/orders | CLOB | POST | L2 + EIP-712 | Batched, max 15 |
/cancel-all | CLOB | DELETE | L2 | Tighter rate limit than per-order cancels |
/heartbeat | CLOB | POST | L2 | Dead-man's-switch |
/auth/api-key | CLOB | POST/DELETE | L1 | Create or revoke L2 credentials |
/auth/derive-api-key | CLOB | GET | L1 | Derive existing L2 credentials |
/positions /trades /activity | Data | GET | — | Per-wallet analytics |
/leaderboard /open-interest | Data | GET | — | Global rankings |
If you need cross-API aggregation in production, building it yourself means reasoning about three independent rate-limit budgets and three different auth surfaces. Parlay's MCP server hides this behind a single object — but as a developer learning the API, it's worth doing the cross-API tour by hand at least once.
5. Pagination and rate limits
Polymarket paginates via cursor (/markets/keyset, /events/keyset) — pass after_cursor and limit (default 20, max 100 since the 2026-05-14 changelog); the response returns next_cursor until the last page omits it. Passing offset to a keyset endpoint returns 422. Rate limits are Cloudflare-driven and global across all accounts (no tier system), expressed per 10-second window (and per 10-minute window for trading bursts): 15,000 req/10s globally, 4,000/10s on Gamma, 1,000/10s on Data, 9,000/10s on CLOB, with tighter sub-budgets per endpoint. Cloudflare throttles before it rejects — requests slow down and queue rather than returning 429 immediately — and the eventual 429 carries no Retry-After or X-RateLimit-* headers. Default exponential-backoff against Polymarket can actually slow you down further; treat slow-but-eventually-200 as success, not failure.
Rate — Step 1: How pagination and Cloudflare throttling work
Pagination is keyset/cursor since 2026-04-10. The new endpoints are /markets/keyset and /events/keyset; the response includes next_cursor (opaque), and the last page omits it. Parameters are limit (default 20; /markets/keyset max 100 after the 2026-05-14 changelog) and after_cursor. Passing offset to a keyset endpoint returns 422 — you cannot mix the two. The legacy /markets and /events endpoints still accept offset for now but are on a deprecation path; new code should use keyset.
Rate limits are Cloudflare-throttled with no tier system. Every account shares the same budget; there's no "Basic / Advanced / Premier" hierarchy like Kalshi. Limits are expressed per 10-second window (and for trading, also per 10-minute window for sustained burst protection). Highlights:
| Scope | Endpoint | Limit |
|---|---|---|
| Global | All | 15,000 req / 10s |
| Gamma | General | 4,000 / 10s |
| Gamma | /events | 500 / 10s |
| Gamma | /markets | 300 / 10s |
| Gamma | /markets + /events (combined) | 900 / 10s |
| Data | General | 1,000 / 10s |
| Data | /trades | 200 / 10s |
| Data | /positions | 150 / 10s |
| CLOB | General | 9,000 / 10s |
| CLOB | /book | 1,500 / 10s |
| CLOB | /books (batch POST) | 500 / 10s |
| CLOB | /prices-history | 1,000 / 10s |
| CLOB | API-key endpoints | 100 / 10s |
| Trading | POST /order (burst) | 5,000 / 10s |
| Trading | POST /order (sustained) | 48,000 / 10 min |
| Trading | DELETE /order (burst) | 5,000 / 10s |
| Trading | POST /orders (burst/sustained) | 1,500 / 10s, 21,000 / 10 min |
| Trading | DELETE /orders (burst/sustained) | 1,000 / 10s, 15,000 / 10 min |
| Trading | DELETE /cancel-market-orders (burst/sustained) | 1,500 / 10s, 21,000 / 10 min |
| Trading | DELETE /cancel-all | 250 / 10s, 6,000 / 10 min |
When you exceed any of these, Cloudflare returns 429 with a body of Too Many Requests and no Retry-After or X-RateLimit-* headers. More commonly though, requests just slow down — Cloudflare throttles before it rejects, and the slow response can be mistaken for a hung client. Your retry library should distinguish slow but eventually 200 from 429.
Two practical surprises:
- There is no tier system, no per-account quota. Everyone competes for the same 10-second budget. If a popular Polymarket Twitter account drives a flash crowd, your reads slow down too.
- Trading has burst and sustained limits. You can fire 5,000 single-order submissions in a 10-second burst, but you can't keep that pace through a 10-minute window. Market-makers in particular need to track sustained budget.
Rate — Step 2: A production-grade keyset paginator
Below is the kind of paginator you want for any "fetch all markets matching a filter" job: cursor-aware, treats Cloudflare's slow responses as success, exponential-backoff for genuine 429s, and persists the cursor so a crash doesn't restart from zero.
import json
import random
import time
from pathlib import Path
from typing import Iterator
import requests
CURSOR_FILE = Path(".poly_cursor.json")
def paginate(
base_url: str,
path: str,
*,
item_key: str,
job_id: str,
params: dict | None = None,
) -> Iterator[dict]:
"""Yield every item from a Polymarket keyset endpoint."""
state = json.loads(CURSOR_FILE.read_text()) if CURSOR_FILE.exists() else {}
cursor = state.get(job_id)
while True:
q = dict(params or {})
q["limit"] = q.get("limit", 100)
if cursor:
q["after_cursor"] = cursor
backoff = 0.5
for attempt in range(8):
# Long timeout: Cloudflare may queue rather than reject.
resp = requests.get(base_url + path, params=q, timeout=30)
if resp.status_code == 429:
# No Retry-After. Exponential backoff + jitter.
time.sleep(min(backoff + random.uniform(0, backoff), 30))
backoff *= 2
continue
if 500 <= resp.status_code < 600:
time.sleep(backoff)
backoff = min(backoff * 2, 30)
continue
if resp.status_code == 422:
raise RuntimeError(
"422 — did you pass `offset` to a keyset endpoint? "
"Use `after_cursor` only."
)
resp.raise_for_status()
break
else:
raise RuntimeError(f"giving up on {path} after 8 retries")
data = resp.json()
for item in data.get(item_key, []):
yield item
cursor = data.get("next_cursor")
state[job_id] = cursor
CURSOR_FILE.write_text(json.dumps(state))
if not cursor:
return
The 30-second timeout is deliberate. Cloudflare's throttle layer can stall a request for 10–20 seconds before it eventually returns a 200, and you don't want a 5-second client timeout to retry into the throttle and make it worse. If a request actually hangs for 30 seconds, that's a real failure and worth bubbling up.
Rate — Step 3: Three real traps
Trap A — passing offset to a keyset endpoint. The endpoint returns 422 with a generic message; you spend 20 minutes thinking your code is broken before noticing the parameter mismatch. New code should never use offset against /markets/keyset or /events/keyset.
Trap B — treating Cloudflare slowdown as failure. Many retry libraries assume "no response in 5 seconds" means "the request failed, retry." Against Polymarket, that's the throttle layer doing its job — retrying immediately just queues another slow request. Set generous client timeouts and only retry on 429 or 5xx.
Trap C — exhausting the trading sustained budget. Market-making bots happily fire 1,000 cancels in a burst, fine, then fire another 1,000 thirty seconds later, also fine. But over 10 minutes the count adds up to 6,000+ and Cloudflare starts queueing. Track sustained budget separately from burst.
6. Placing orders
Placing an order on Polymarket V2 means constructing an Order struct (salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp in ms, metadata, builder — no nonce, no feeRateBps, no taker), EIP-712-signing it under the Exchange domain (name: "Polymarket CTF Exchange", version: "2", chainId: 137, verifyingContract: standard or neg-risk depending on the market's neg_risk flag), and POSTing it to clob.polymarket.com/order with L2 HMAC headers. The four order types are GTC, FOK, GTD, and FAK (added 2025-05-28). Before signing, always read tick_size and neg_risk from /book — they vary per market and determine both price validity and the correct verifyingContract. This section is V2 throughout; every V1 example written before 2026-04-28 is wrong on at least one struct field.
6.1 Four order types
Polymarket supports four order types since 2025-05-28. Choose by the desired fill behaviour:
| Type | Full name | Behaviour |
|---|---|---|
GTC | Good-Til-Canceled | Default; rests on the book until matched or cancelled. Use for market-making. |
FOK | Fill-Or-Kill | Must fill the entire size immediately; otherwise the whole order is cancelled. Use for arbitrage where partial fills are useless. |
GTD | Good-Til-Date | Rests until the order's expiration (Unix seconds) passes, then auto-cancels. Use for time-sensitive bets. |
FAK | Fill-And-Kill | Fills whatever it can immediately, cancels the rest. Use for taking liquidity without resting. |
FAK was added in 2025-05-28 and is the right default for "scalp the spread without leaving an order on the book." Many tutorials still describe only GTC/FOK/GTD because they predate the addition.
6.2 V1 → V2 order-struct diff
If you're porting V1 code, here's the list of fields that changed. Every V1 example you find online (anything written before 2026-04-28) is wrong on at least one row.
| Field | V1 | V2 |
|---|---|---|
nonce | required (uint) | removed |
feeRateBps | required (uint) | removed — fees are protocol-set at match time |
taker | required (address, often zero) | removed |
timestamp | not present | added (Unix ms; replaces nonce conceptually) |
metadata | not present | added (bytes32, zero-filled by default) |
builder | not present (separate POLY_BUILDER_* headers) | added (bytes32, builder code or zero) |
EIP-712 Exchange version | "1" | "2" |
Exchange verifyingContract | V1 contract address | V2 contract address (different per neg_risk) |
If your code still constructs nonce or feeRateBps, the matching engine returns Invalid order payload with no further detail. The first hour of debugging V2 migration is almost always grep-ing for V1 field names you forgot to delete.
6.3 The V2 order wire body
A signed V2 order looks like this, posted to POST https://clob.polymarket.com/order with L2 headers. One V2 nuance matters: expiration remains in the REST wire body for GTD/order-expiry handling, but it is not part of the EIP-712 signed Order struct.
{
"order": {
"salt": "12345",
"maker": "0x...",
"signer": "0x...",
"tokenId": "102936...",
"makerAmount": "1000000",
"takerAmount": "2000000",
"side": "BUY",
"expiration": "0",
"signatureType": 3,
"timestamp": "1713398400000",
"metadata": "",
"builder": "0x0000000000000000000000000000000000000000000000000000000000000000",
"signature": "0x..."
},
"owner": "<api-key UUID>",
"orderType": "GTC",
"deferExec": false
}
Conventions: makerAmount and takerAmount are 6-decimal integers; the REST body uses side: "BUY" / "SELL" while the EIP-712 payload encodes side as 0 / 1; signatureType is 0 (standalone EOA), 1 (existing proxy wallet), 2 (existing Gnosis Safe), or 3 (POLY_1271 deposit wallet, recommended for new API users). The signature field is an EIP-712 signature over the signed Order struct using the Exchange domain (name: "Polymarket CTF Exchange", version: "2", chainId: 137, verifyingContract: either the standard or the neg-risk contract — read neg_risk from /book first). builder is bytes32; pass zero unless you have a builder code.
The full signing routine in Python with py-clob-client-v2 (recommended unless you have a strong reason to roll your own):
from py_clob_client_v2 import ClobClient, OrderArgs, PartialCreateOrderOptions
from py_clob_client_v2.order_builder.constants import BUY
client = ClobClient(
host="https://clob.polymarket.com",
chain_id=137,
key="0x...",
creds=api_creds, # generated by create_or_derive_api_key()
signature_type=3, # POLY_1271 deposit wallet
funder="0x...", # deposit wallet address
)
# Build, sign, submit.
resp = client.create_and_post_order(
OrderArgs(token_id="102936...", price=0.45, size=100, side=BUY),
options=PartialCreateOrderOptions(tick_size="0.01", neg_risk=False),
)
# Inspect status: live, matched, or delayed.
if resp["status"] == "matched":
print("Filled in:", resp["transactionsHashes"])
elif resp["status"] == "live":
print("Resting on book, id:", resp["orderID"])
else:
print("Delayed (matching engine queued):", resp)
The three statuses are: live (resting on the book), matched (filled, with transactionsHashes listing the on-chain settlements), and delayed (the matching engine queued the order — usually transient).
6.4 Three production traps
Trap A — assuming a uniform tick_size. Tick size varies per market: liquid markets often allow 0.001, less-liquid ones use 0.01. A price of 0.501 is valid in a 0.001 market but rejected in a 0.01 market with Price ({price}) breaks minimum tick size rule. Always read tick_size from /book or /tick-size before constructing makerAmount/takerAmount.
Trap B — wrong verifyingContract for negative-risk markets. A neg-risk market signed with the standard verifyingContract produces a syntactically valid signature for the wrong domain. The matching engine rejects with invalid signature and you have no path back from the error to the cause. Read neg_risk from /book, then pick the corresponding contract.
Trap C — maker is the funder, not always the signer. With Safe (signatureType: 2) and deposit-wallet (signatureType: 3) flows, signer is the key/session signer while maker is the funded wallet address. The Order struct's maker is what holds the position, not necessarily what signs the message. Mixing them up gives the order owner has to be the owner of the API KEY.
6.5 Pre-trade cross-venue check
Order execution stays on the Polymarket CLOB or the official SDK; Parlay MCP is read-only and doesn't sign or submit orders. Before a trade, a useful pattern is to ask an AI client connected to https://mcp.parlay.run/mcp to compare the Polymarket market with similar markets on Kalshi, Limitless, and Manifold — current prices, settlement wording, and source links — so you can size and time the order with full cross-venue context.
7. Real-time data via WebSocket
Polymarket's CLOB WebSocket lives at wss://ws-subscriptions-clob.polymarket.com and exposes two channels: /ws/market (public market data — orderbook snapshots, price_change deltas, last-trade prints, tick-size changes) and /ws/user (private order and trade events). The market channel is unauthenticated and accepts an {"assets_ids": [...], "type": "market"} subscribe message; the user channel carries L2 credentials in the subscribe message itself (auth: {apiKey, secret, passphrase}), not on the WebSocket upgrade. Both channels require a client-driven heartbeat — send {} every 10 seconds, the server replies {}, and missing heartbeats for ~30 seconds drops the connection. The official docs also list sports and RTDS channels; this section covers the two CLOB channels.
7.1 Market channel (no auth)
The market channel is fully public. Connect, send a single subscribe message, and you start receiving events for every token in the subscription list. You can also subscribe dynamically without reconnecting.
- URL:
wss://ws-subscriptions-clob.polymarket.com/ws/market - Subscribe payload:
{"assets_ids": ["...", "..."], "type": "market"} - Dynamic subscribe (after connect):
{"operation": "subscribe", "assets_ids": [...]} - Heartbeat is client-driven — every 10 seconds, the client sends
{}and the server replies with{}. If you skip heartbeats for ~30 seconds the server drops you. (This is the opposite of Kalshi, which sends server-driven Pings.) - Event types:
book(full snapshot on subscribe),price_change(delta updates),last_trade_price,tick_size_change,best_bid_ask,new_market,market_resolved*. Asterisked types requirecustom_feature_enabled: truein the subscribe payload.
A complete Python listener looks like:
import asyncio, json, websockets
async def stream(token_ids):
url = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
async with websockets.connect(url, ping_interval=None) as ws:
await ws.send(json.dumps({"assets_ids": token_ids, "type": "market"}))
async def heartbeat():
while True:
await asyncio.sleep(10)
await ws.send("{}")
asyncio.create_task(heartbeat())
async for msg in ws:
data = json.loads(msg)
if data == {}:
continue # heartbeat ack
print(data)
asyncio.run(stream(["102936...", "102937..."]))
Two things changed in 2025: the previous 100-token subscription cap was removed, so you can subscribe to as many tokens as fit your bandwidth. And the price_change payload structure was tweaked on 2025-09-15 — old parsers that read data.changes instead of data.price_changes silently drop updates.
7.2 User channel (L2 auth)
The user channel delivers events about your own orders and fills. Authentication is carried in the subscribe message, not on the WebSocket upgrade — pass your L2 credentials in an auth field:
async def stream_user(creds):
url = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
async with websockets.connect(url, ping_interval=None) as ws:
await ws.send(json.dumps({
"auth": {
"apiKey": creds["apiKey"],
"secret": creds["secret"],
"passphrase": creds["passphrase"],
},
"type": "user",
}))
async def heartbeat():
while True:
await asyncio.sleep(10)
await ws.send("{}")
asyncio.create_task(heartbeat())
async for msg in ws:
print(json.loads(msg))
Event types: order (placement, update, cancellation) and trade (matched, then confirmed). Heartbeat is the same 10-second client-driven interval.
7.3 Three real traps
Trap A — forgetting the client heartbeat. Most WebSocket libraries enable a server-side keepalive by default; that's not what Polymarket wants. Set ping_interval=None and run your own 10-second {} send. Without it, the connection drops silently around the 30-second mark.
Trap B — parsing the old price_change shape. Code written before 2025-09-15 reads msg["changes"]; the field is now msg["price_changes"]. Symptom: book stays static after the initial snapshot.
Trap C — putting auth on the upgrade. It doesn't go in headers or query string. The /ws/user connection itself is unauthenticated; auth is the first message. If you try to attach L2 headers to the upgrade, the server accepts the upgrade and then returns no events.
8. Common errors and how to fix them
Polymarket's failure modes cluster in four categories: authentication errors (401 Invalid api key, 401 Invalid L1 Request headers, the order owner has to be the owner of the API KEY), order-placement errors (Invalid order payload from leftover V1 fields, Price breaks minimum tick size rule, Size lower than the minimum, not enough balance / allowance, post-only crossing, FOK / FAK no-fill), matching-engine and rate-limit errors (425 Too Early during V2 cutover restarts, 503 cancel-only or disabled during incidents, 429 with no headers from Cloudflare), and address-state errors (banned addresses, close-only jurisdictions). Each entry below is the symptom you'll see, the root cause, and the smallest fix.
Authentication failures
Order-placement failures
Matching-engine and rate-limit failures
Address-state failures
9. Beyond Polymarket: querying across Kalshi, Limitless, and Manifold
If your application only ever talks to Polymarket, the official clob-client-v2 already does everything in this guide and it's well-maintained. There's no real differentiation in writing your own EIP-712 wrapper.
The case for a different approach starts when your application needs to span multiple prediction-market venues. Polymarket is one of Parlay's primary venue sources alongside Kalshi (CFTC-regulated, US-only, RSA-PSS signing), Limitless, and Manifold (play-money, REST-oriented). Opinion.trade is tracked as a limited-liquidity status source rather than a primary venue in the current MVP. Each venue has its own auth scheme, pagination semantics, rate-limit model, and market-identifier format. Polymarket alone has three APIs to coordinate; building multi-venue unification on top is real work.
To make that concrete:
- Auth: Polymarket EIP-712 + HMAC, Kalshi RSA-PSS, and different venue-specific auth models elsewhere. Multiple implementations, multiple edge cases, multiple rotation procedures.
- Schema: Polymarket uses
asset_id(a 256-bit token ID) andcondition_id(an on-chain market ID); Kalshi uses stringtickers; other venues use their own identifiers. - Rate limits: Polymarket Cloudflare-throttles, Kalshi token-buckets with five tiers, and other venues use their own models. A unified rate manager has to reason about all of them.
- Price representation: Polymarket uses 0–1 strings, Kalshi uses fixed-point dollar strings, and other venues expose different price units.
That multi-venue research layer is what Parlay's MCP server provides. The read-only tools — market search, comparison, discrepancy scans, platform inspection, and source-aware briefs — work across the primary venues with normalized identifiers and a single client-facing schema. Polymarket-specific details like neg-risk markets, V2 order signing, and the three-API split still matter for execution, but they don't have to leak into every research prompt.
If you're building a single-venue Polymarket tool, you don't need Parlay; the official SDK is the right answer. If you're building anything that asks "what does this market price across venues?" or "where is the arbitrage opportunity right now?", Parlay is the layer you'd otherwise have to build yourself. The companion Kalshi API guide walks through Kalshi's equivalent pieces.
10. Frequently asked questions
11. Next steps
If you're building on Polymarket specifically, the next reads worth your time are the official rate-limits page and the V2 migration guide — both contain edge cases this article didn't cover. If you're building a multi-venue tool, the Kalshi API guide walks through the equivalent pieces for Kalshi's CFTC-regulated REST surface. For the broader venue comparison behind that integration choice, read Polymarket vs Kalshi for regulation, fees, liquidity, APIs, and cross-venue arbitrage tradeoffs.