Polymarket API: The Complete Developer Guide (2026, CLOB V2)

2026-04-26

Polymarket is the largest decentralized prediction market by open interest, settling binary contracts on Polygon with USDC-backed collateral. Its API is split across three independent surfaces — Gamma (discovery), Data (analytics), and CLOB (orderbook + trading) — and the trading half just shipped a breaking V2 upgrade. This guide is a faithful walkthrough of the post-upgrade API as it stands in late April 2026, verified against docs.polymarket.com after the CLOB V2 cutover. If you're working from any blog post that mentions nonce, feeRateBps, USDC.e, or @polymarket/clob-client (without the -v2 suffix), skim §0 first — those references stopped working at the cutover and your code will silently fail.

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:

TopicOld (V1, retired 2026-04-28)Current (V2)
Order struct removednonce, feeRateBps, taker(removed — don't include)
Order struct addedtimestamp (ms), metadata (bytes32), builder (bytes32)
FeesEmbedded in the signed orderSet by the protocol at match time, not signed
EIP-712 Exchange domainversion: "1", V1 verifyingContractversion: "2", V2 verifyingContract (different per neg-risk flag)
Builder attributionSeparate SDK + 4 POLY_BUILDER_* headersSingle builder field on the order (bytes32)
Collateral tokenUSDC.e (bridged)pUSD (1:1 USDC-backed)
TypeScript SDK@polymarket/clob-client@polymarket/clob-client-v2
Python SDKpy-clob-clientpy-clob-client-v2
ConstructorPositional args, chainIdOptions 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, and most of EU are still fully blocked from trading.

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}'
# one-time install, then point your MCP client at the parlay server
npx parlay-mcp@latest

# in your MCP-aware client (Claude, Cursor, etc.)
>>> polymarket.list_markets(limit=5)

Same query — direct HTTP vs through Parlay's prediction-market MCP server.

Two notes worth knowing before you go further:

  1. Trading is geo-blocked from the United States, United Kingdom, France, Germany, 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.
  2. Old tutorials reference V1 fields that no longer work. Anything mentioning nonce, feeRateBps, taker, USDC.e, or the @polymarket/clob-client package (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:

APIBase URLUsed forAuth
Gammagamma-api.polymarket.comDiscovery: markets, events, tags, series, comments, sports, search, profilesPublic
Datadata-api.polymarket.comAnalytics: positions, trades, activity, holders, leaderboards, open interestPublic
CLOBclob.polymarket.comReal-time pricing + trading: orderbook, prices, midpoints, history, ordersPublic read; L1+L2 to trade

There's also a fourth surface, bridge.polymarket.com, used for funding deposits via the fun.xyz proxy; we won't touch it 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's authentication has two layers, and the layering is the hardest part to internalize. Read the conceptual breakdown below before writing code; 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:

  • ClobAuthDomain always uses version: "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 timestamp is 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.
  • signer and funder are not the same address. With a Magic Link or Gnosis Safe proxy wallet (the most common setup), the wallet you sign with (the EOA signer) is different from the address that holds the funds (the proxy contract). Order rejections like the order owner has to be the owner of the API KEY almost always trace to confusing the two. Set signatureType correctly: 0 for plain EOA, 1 for Magic-Link/Google-login proxy, 2 for Gnosis Safe (the most common default in production).

Auth — Step 2: A complete working signing implementation

Below is the canonical implementation for both layers. The Python version uses eth-account (which Polymarket's docs use); the TypeScript port uses ethers v6. You can paste either into a fresh project and immediately authenticate against POST /auth/api-key, then use the resulting credentials to call any L2-protected endpoint.

import base64
import hashlib
import hmac
import time

import requests
from eth_account import Account
from eth_account.messages import encode_typed_data

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/positions",
    headers=l2_headers(creds, "GET", "/data/positions"),
).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/positions`, {
  headers: l2Headers(creds, 'GET', '/data/positions'),
});
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/positions"

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 (Magic Link, Google login, or Gnosis Safe), the address that signs is different from the address that holds funds. The L1/L2 headers identify the signer, but order placement also requires the funder address (often the proxy contract). 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 signatureType to 1 (Magic Link) or 2 (Gnosis Safe) 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 Gnosis Safe over a hot EOA so you can rotate signers without losing the funder address. Rotate the L2 API credentials separately from the wallet — that's a server-side DELETE /auth/api-key followed by a new POST /auth/api-key.

Auth — Step 4: Where this guide goes next

The signing block above is undifferentiated infrastructure. Polymarket's official clob-client-v2 SDK already encapsulates both L1 and L2, and it's well-maintained. The interesting part of a Polymarket integration starts after authentication: how to navigate three independent APIs, how to read the orderbook correctly given that there are real asks (not just bids), and how to sign V2 orders without tripping on the V1→V2 field changes. The next sections cover those.

Auth — Step 5: Same call, three layers of abstraction

Before moving on, it's worth seeing how the same query — list every active market matching a tag — looks at three layers: raw HTTP, Polymarket's official V2 SDK, and Parlay's MCP server. The point is not to argue Parlay against the official SDK; the official SDK is genuinely good and we won't pretend otherwise. The point is that Parlay is the only one of the three that also covers Kalshi, Manifold, and Opinion.trade behind the same call. If your application only ever touches Polymarket, the official SDK is the right answer. If it touches more than one venue, the case for Parlay is the cross-market unification.

import base64, hashlib, hmac, time, requests
from eth_account import Account

PRIVATE_KEY = "0x..."; ADDRESS = "0x..."
BASE = "https://clob.polymarket.com"
GAMMA = "https://gamma-api.polymarket.com"

# L1: derive credentials (see Step 2)
def derive():
    ts = str(int(time.time()))
    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)
    return requests.post(BASE + "/auth/api-key", headers={
        "POLY_ADDRESS": ADDRESS, "POLY_SIGNATURE": signed.signature.hex(),
        "POLY_TIMESTAMP": ts, "POLY_NONCE": "0",
    }).json()

creds = derive()
# paginate Gamma keyset (no auth required for read)
markets, cursor = [], None
while True:
    params = {"limit": 100, "tag_id": "trump-2028"}
    if cursor: params["after_cursor"] = cursor
    r = requests.get(GAMMA + "/markets/keyset", params=params)
    r.raise_for_status(); data = r.json()
    markets += data["markets"]
    cursor = data.get("next_cursor")
    if not cursor: break
print(f"{len(markets)} markets")
from py_clob_client_v2 import ClobClient
from py_clob_client_v2.constants import POLYGON

client = ClobClient(
    host="https://clob.polymarket.com",
    chain=POLYGON,
    key="0x...",
    signature_type=2,  # Gnosis Safe proxy
    funder="0x...",
)
client.derive_api_key()
markets = list(client.gamma.markets(tag_id="trump-2028"))
print(f"{len(markets)} markets")
from parlay import Parlay

p = Parlay()
markets = p.polymarket.list_markets(tag="trump-2028")
# the same shape across venues:
kalshi = p.kalshi.list_markets(series="KXPRES")

≈ 60 lines → 10 lines → 3 lines, and only the third covers other venues.

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 1,000). Filter by tag_id, event_id, closed, liquidity_min, volume_min. Use the keyset endpoint for any new code; the offset-paginated /markets is on a deprecation path.
  • GET /events/keyset — same shape, for events.
  • GET /markets/{slug} and GET /markets/{id} — single-market metadata (includes condition_id, tokens, neg_risk, tick_size).
  • GET /events/{slug} — event with its child markets.
  • GET /tags — all tags, plus /tags/{slug}/markets.
  • GET /search — text search across markets and events.
  • 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:

    {
      "market": "0xabc...",
      "asset_id": "102936...",
      "timestamp": "1713398400",
      "hash": "...",
      "bids": [{"price": "0.45", "size": "100"}],
      "asks": [{"price": "0.46", "size": "150"}],
      "min_order_size": "1",
      "tick_size": "0.01",
      "neg_risk": false,
      "last_trade_price": "0.45"
    }
    

    Note that bids and asks are returned — this is the opposite of Kalshi, which returns bids only and relies on YES/NO duality. Sizes and prices are strings; tick_size varies per market (commonly 0.01 or 0.001). Always read tick_size and neg_risk here before doing anything that signs an order.

  • 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=...&fidelity=1h — paginated historical price series. fidelity is one of 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w.

  • GET /ohlc?market=...&fidelity=1h — OHLC candles, same fidelities.

  • GET /tick-size?token_id=... and GET /fee-rate?token_id=... — per-market constants. Read these before signing an order; a price that violates tick_size is 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/{id} — cancel one.
  • DELETE /orders — cancel many by ID.
  • DELETE /cancel-all — cancel every live order on the account. Rate-limited tighter than per-order cancels.
  • DELETE /cancel-market-orders?market=... — cancel everything on one market.
  • 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 /orders — your live orders.
  • GET /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 optional market and before filters.
  • /holders?market=0x... — top holders by token balance for a market.
  • /leaderboard and /builder-leaderboard — global rankings by PnL or volume.
  • /open-interest — protocol-wide open interest, snapshot.

4.5 Endpoint cheat sheet

EndpointAPIMethodAuthNotes
/markets/keysetGammaGETCursor pagination; replaces /markets
/events/keysetGammaGETCursor pagination; replaces /events
/markets/{slug}GammaGETReturns condition_id, tokens, neg_risk, tick_size
/searchGammaGETText search across catalogue
/bookCLOBGETBids and asks; read tick_size and neg_risk here
/booksCLOBPOSTBatch up to 50 tokens
/price /midpoint /spreadCLOBGETCheap pricing endpoints
/prices-history /ohlcCLOBGETHistorical series; fidelity 1m–1w
/orderCLOBPOSTL2 + EIP-712Single order; see §6
/ordersCLOBPOSTL2 + EIP-712Batched, max 15
/cancel-allCLOBDELETEL2Tighter rate limit than per-order cancels
/heartbeatCLOBPOSTL2Dead-man's-switch
/auth/api-keyCLOBPOST/DELETEL1Derive or revoke L2 credentials
/positions /trades /activityDataGETPer-wallet analytics
/leaderboard /open-interestDataGETGlobal 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's rate limits are Cloudflare-driven, not application-driven. The practical consequence is that exceeding the limit doesn't return a 429 immediately — Cloudflare throttles your requests, slowing them down and queueing them, until you back off. This is the opposite of Kalshi (which returns 429s with no headers) and most retry libraries are tuned for the Kalshi-style behaviour. Default exponential-backoff against Polymarket can actually slow you down further.

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, max 1,000) 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:

ScopeEndpointLimit
GlobalAll15,000 req / 10s
GammaGeneral4,000 / 10s
Gamma/events500 / 10s
Gamma/markets300 / 10s
Gamma/markets + /events (combined)900 / 10s
DataGeneral1,000 / 10s
Data/trades200 / 10s
Data/positions150 / 10s
CLOBGeneral9,000 / 10s
CLOB/book1,500 / 10s
CLOB/books (batch POST)500 / 10s
CLOB/prices-history1,000 / 10s
CLOBAPI-key endpoints100 / 10s
TradingPOST /order (burst)3,500 / 10s
TradingPOST /order (sustained)36,000 / 10 min
TradingDELETE /order (burst)3,000 / 10s
TradingDELETE /cancel-all250 / 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:

  1. 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.
  2. Trading has burst and sustained limits. You can fire 3,500 orders in a 10-second burst, but you can't do that twice in 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.

Rate — Step 4: Where this goes next

The paginator above is correct and reusable, but the differentiating value of a Polymarket integration isn't writing your own keyset loop. Parlay's MCP server bakes in keyset pagination, throttle-aware retries, and per-API budget tracking — polymarket.list_markets(all=True) paginates and rate-limits transparently. That said, you only need this if you don't want to maintain it yourself; the code above works.

6. Placing orders

Order placement is the second hardest part of a Polymarket integration after authentication, and the most common place to trip on the V1→V2 transition. The order struct changed in three places, the EIP-712 Exchange domain version bumped, and the SDK packages got new names. This section is V2 throughout.

6.1 Four order types

Polymarket supports four order types since 2025-05-28. Choose by the desired fill behaviour:

TypeFull nameBehaviour
GTCGood-Til-CanceledDefault; rests on the book until matched or cancelled. Use for market-making.
FOKFill-Or-KillMust fill the entire size immediately; otherwise the whole order is cancelled. Use for arbitrage where partial fills are useless.
GTDGood-Til-DateRests until the order's expiration (Unix seconds) passes, then auto-cancels. Use for time-sensitive bets.
FAKFill-And-KillFills 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.

FieldV1V2
noncerequired (uint)removed
feeRateBpsrequired (uint)removed — fees are protocol-set at match time
takerrequired (address, often zero)removed
timestampnot presentadded (Unix ms; replaces nonce conceptually)
metadatanot presentadded (bytes32, zero-filled by default)
buildernot present (separate POLY_BUILDER_* headers)added (bytes32, builder code or zero)
EIP-712 Exchange version"1""2"
Exchange verifyingContractV1 contract addressV2 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 full V2 order body

A signed V2 order looks like this, posted to POST https://clob.polymarket.com/order with L2 headers:

{
  "order": {
    "salt": "12345",
    "maker": "0x...",
    "signer": "0x...",
    "tokenId": "102936...",
    "makerAmount": "1000000",
    "takerAmount": "2000000",
    "side": "BUY",
    "signatureType": 2,
    "timestamp": "1713398400000",
    "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
    "builder":  "0x0000000000000000000000000000000000000000000000000000000000000000",
    "signature": "0x..."
  },
  "owner": "<api-key UUID>",
  "orderType": "GTC"
}

Conventions: makerAmount and takerAmount are 6-decimal integers (USDC standard); side is BUY or SELL; signatureType is 0 (EOA), 1 (Magic Link / Google proxy), or 2 (Gnosis Safe — the default for the public web app). The signature field is an EIP-712 signature over the 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). metadata and builder are 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
from py_clob_client_v2.constants import POLYGON
from py_clob_client_v2.types import OrderArgs, OrderType

client = ClobClient(
    host="https://clob.polymarket.com",
    chain=POLYGON,
    key="0x...",
    signature_type=2,
    funder="0x...",
)
client.derive_api_key()

# Always read these per market before signing — both vary.
book = client.clob.get_book(token_id="102936...")
tick_size = float(book["tick_size"])
neg_risk = book["neg_risk"]

# Build, sign, submit.
order = client.create_order(OrderArgs(
    token_id="102936...",
    price=0.45,
    size=100.0,
    side="BUY",
    order_type=OrderType.GTC,
))
resp = client.post_order(order)

# 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 the signer. With Gnosis Safe (signatureType: 2), signer is your hot EOA and maker is the Safe's address (the funder). The Order struct's maker is what holds the position, not what signs the message. Mixing them up gives the order owner has to be the owner of the API KEY.

6.5 Same call, raw versus Parlay

# Skipping L1/L2 setup — see §3.2.
# Read tick_size and neg_risk first.
book = requests.get(BASE + "/book", params={"token_id": TOKEN}).json()
verifying_contract = NEGRISK_EXCHANGE if book["neg_risk"] else EXCHANGE
tick_size = float(book["tick_size"])

# Construct V2 order struct (no nonce, no feeRateBps, no taker).
order_data = {
    "salt": str(int(time.time() * 1000)),
    "maker": FUNDER, "signer": SIGNER,
    "tokenId": TOKEN,
    "makerAmount": str(int(100 * 1_000_000)),       # USDC 6-dec
    "takerAmount": str(int(100 / 0.45 * 1_000_000)),
    "side": "BUY", "signatureType": 2,
    "timestamp": str(int(time.time() * 1000)),       # ms!
    "metadata": "0x" + "00" * 32,
    "builder":  "0x" + "00" * 32,
}
domain = {
    "name": "Polymarket CTF Exchange",
    "version": "2",   # V2!
    "chainId": 137,
    "verifyingContract": verifying_contract,
}
types = { "Order": [
    {"name": "salt", "type": "uint256"},
    {"name": "maker", "type": "address"}, {"name": "signer", "type": "address"},
    {"name": "tokenId", "type": "uint256"},
    {"name": "makerAmount", "type": "uint256"}, {"name": "takerAmount", "type": "uint256"},
    {"name": "side", "type": "uint8"}, {"name": "signatureType", "type": "uint8"},
    {"name": "timestamp", "type": "uint256"},
    {"name": "metadata", "type": "bytes32"}, {"name": "builder", "type": "bytes32"},
]}
signed = Account.sign_typed_data(SIGNER_KEY, domain, types, order_data)
order_data["signature"] = signed.signature.hex()

body = {"order": order_data, "owner": creds["apiKey"], "orderType": "GTC"}
resp = requests.post(BASE + "/order",
    headers=l2_headers(creds, "POST", "/order", json.dumps(body)),
    json=body)
from parlay import Parlay

p = Parlay()
resp = p.polymarket.place_order(
    token_id="102936...",
    side="BUY", price=0.45, size=100.0, order_type="GTC",
)
# tick_size, neg_risk verifyingContract, V2 order struct,
# EIP-712 signing, L2 headers — all handled.

≈ 80 lines of EIP-712 plumbing → one call.

7. Real-time data via WebSocket

WebSocket gives you orderbook deltas, last-trade prints, and order/fill events without polling REST. Polymarket runs two channels at the same hostname but different paths: /ws/market for public market data and /ws/user for private order/trade events.

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 require custom_feature_enabled: true in 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

These are the failures every Polymarket integration runs into eventually, grouped by category. Each entry 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, Manifold, and Opinion.trade

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 four serious sources of binary-event prices, alongside Kalshi (CFTC-regulated, US-only, RSA-PSS signing), Manifold (play-money, REST-only, simple API key), and Opinion.trade (UK, REST + GraphQL, API key). Each one has its own auth scheme, its own pagination semantics, its own rate-limit model, its own market-identifier format. Polymarket alone has three APIs to coordinate; building four-way unification on top is real work.

To make that concrete:

  • Auth: Polymarket EIP-712 + HMAC, Kalshi RSA-PSS, Manifold API key, Opinion.trade API key. Four implementations, four sets of edge cases, four rotation procedures.
  • Schema: Polymarket uses asset_id (a 256-bit token ID) and condition_id (an on-chain market ID); Kalshi uses string tickers; Manifold uses question slugs; Opinion.trade uses GraphQL node IDs.
  • Rate limits: Polymarket Cloudflare-throttles, Kalshi token-buckets with five tiers, Manifold has a simple req-per-second cap, Opinion.trade has tiered API plans. A unified rate manager has to reason about all four models.
  • Price representation: Polymarket uses 0–1 strings, Kalshi uses fixed-point dollar strings, Manifold uses percentage doubles, Opinion.trade uses pence integers.

That four-way layer is what Parlay's MCP server provides. The same list_markets, get_orderbook, and place_order calls work across all four venues, with normalized identifiers and a single auth surface. The Polymarket-specific affordances — neg-risk verifyingContract switching, V2 order signing, the three-API split — are still reachable from the unified object, but you don't have to write them yourself, and you don't have to do it for the other three venues either.

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; the Polymarket vs Kalshi comparison covers the strategic side.

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, and the Polymarket vs Kalshi platform comparison covers the strategic differences between the two.