21 KiB
Polymarket CLOB Order Signing Cookbook (py-clob-client)
A copy-pasteable reference for signing and submitting Polymarket CLOB orders from
Python. Verified against py-clob-client v0.34.6 (released 2026-02-19).
All citations point at Polymarket/py-clob-client@main on GitHub. Update the pin
when bumping.
1. Install
Pin the exact version your executor was tested against. As of April 2026 the latest published release is 0.34.6.
pip install py-clob-client==0.34.6
Source: https://pypi.org/project/py-clob-client/0.34.6/ /
setup.py L7-L25
Transitive deps it pulls in (from setup.py):
eth-account>=0.13.0eth-utils>=4.1.1poly_eip712_structs>=0.0.1py-order-utils>=0.3.2py-builder-signing-sdk>=0.0.2httpx[http2]>=0.27.0python-dotenv
Requires Python 3.9.10+.
The HTTP client under the hood is httpx (sync). All client.* methods are
blocking. See the asyncio pattern in section 7.
2. One-Time Setup: Derive L2 API Credentials
L2 (HMAC) creds — api_key, api_secret, api_passphrase — are deterministic
for a given (wallet, nonce). You generate them once and store them. The
create_or_derive_api_creds() helper tries POST /auth/api-key first, and falls
back to GET /auth/derive-api-key if the key already exists.
Reference:
py_clob_client/client.py L211-L260
# scripts/bootstrap_clob_creds.py
"""
Run ONCE per wallet to mint L2 API credentials, then store the three
strings (api_key, api_secret, api_passphrase) in your secret manager.
"""
import os
from py_clob_client.client import ClobClient
from py_clob_client.constants import POLYGON
HOST = "https://clob.polymarket.com"
PRIVATE_KEY = os.environ["POLY_PK"] # 0x-prefixed hex
CHAIN_ID = POLYGON # 137 (mainnet) or AMOY=80002
# L1 client = host + chain + key. No creds needed yet.
client = ClobClient(HOST, key=PRIVATE_KEY, chain_id=CHAIN_ID)
# Idempotent: creates if missing, derives if existing. Returns ApiCreds.
creds = client.create_or_derive_api_creds()
print("CLOB_API_KEY =", creds.api_key)
print("CLOB_SECRET =", creds.api_secret)
print("CLOB_PASS_PHRASE =", creds.api_passphrase)
ApiCreds is a dataclass with three string fields:
clob_types.py L19-L23.
Polymarket prints a giant warning that creds cannot be recovered if lost — store them in your secrets backend immediately. See
constants.pyL7-L10.
3. Client Init
import os
from py_clob_client.client import ClobClient
from py_clob_client.clob_types import ApiCreds
from py_clob_client.constants import POLYGON
client = ClobClient(
host="https://clob.polymarket.com",
key=os.environ["POLY_PK"], # private key of the *signer* EOA
chain_id=POLYGON, # 137
creds=ApiCreds(
api_key=os.environ["CLOB_API_KEY"],
api_secret=os.environ["CLOB_SECRET"],
api_passphrase=os.environ["CLOB_PASS_PHRASE"],
),
signature_type=2, # see table below
funder="0xYourPolymarketProxyAddress", # USDC-holding address
)
Constructor signature:
client.py L116-L165.
signature_type
The integer is forwarded to OrderBuilder.__init__ and stamped into the EIP-712
order payload as signatureType
(order_builder/builder.py L40-L49,
L143).
signature_type |
Wallet Model | When to use |
|---|---|---|
0 (default, EOA) |
Plain EOA / MetaMask / hardware | Signer EOA is the funder. USDC and CTF tokens sit on the same address that signs. |
1 (POLY_PROXY) |
Polymarket proxy (Magic / email) | Signer EOA is the session key; funds live in a Polymarket-deployed proxy contract. |
2 (POLY_GNOSIS_SAFE) |
Gnosis Safe / browser proxy | Signer EOA is an owner; funds live in a Safe / proxy contract. |
Default if omitted is EOA (0). See EOA constant in
py_order_utils.model re-exported via builder.py L4.
funder
The address that holds USDC and conditional tokens. This goes into the
maker field of the signed order
(builder.py L132-L144),
while signer is set to the address derived from your private key.
- If
funderis omitted, it defaults tosigner.address(). - For arbitrage from a Polymarket UI account,
funder= your visible Polymarket proxy address (look it up on polygonscan or in the UI), andkey= the session/EOA key Polymarket gave you. - For a pure EOA setup, leave
funder=None(or pass the same address as the signer) and usesignature_type=0.
Read-only mode
Drop creds, key, signature_type, funder for L0 (public endpoints only):
client = ClobClient("https://clob.polymarket.com")
client.get_order_book(token_id)
4. Place an Order
4a. Limit order — GTC (resting)
Source pattern:
examples/order.py L1-L36.
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY, SELL
order_args = OrderArgs(
token_id="71321045679252212594626385532706912750332728571942532289631379312455583992563",
price=0.42, # USD per share, between 0.0 and 1.0
size=100.0, # shares
side=BUY, # or SELL
)
signed = client.create_order(order_args) # builds EIP-712 + signs
resp = client.post_order(signed, OrderType.GTC)
# resp == {"success": True, "orderID": "0x...", "status": "matched"|"live"|...}
OrderArgs fields (from
clob_types.py L42-L83):
token_id, price, size, side, fee_rate_bps=0, nonce=0,
expiration=0, taker=ZERO_ADDRESS.
create_order automatically:
- fetches and caches the market's
tick_sizeandneg_riskflag (client.pyL402-L448, L492-L535), - validates your price against the tick,
- picks the correct exchange contract for
neg_riskmarkets, - signs an EIP-712 order with
maker=funder,signer=EOA,signatureType=....
4b. Limit order — GTD (good-till-date)
Source: examples/GTD_order.py.
order_args = OrderArgs(
token_id="...",
price=0.50,
size=100.0,
side=BUY,
expiration="1000000000000", # unix seconds; must be > now+60s
)
signed = client.create_order(order_args)
resp = client.post_order(signed, OrderType.GTD)
4c. FOK (Fill-Or-Kill)
FOK requires the entire size to fill immediately at the limit price or better,
otherwise the whole order is cancelled. Polymarket uses FOK for market buys
priced in dollars (MarketOrderArgs.amount).
Source: examples/market_buy_order.py.
from py_clob_client.clob_types import MarketOrderArgs, OrderType
mo = MarketOrderArgs(
token_id="...",
amount=100.0, # BUY: USDC to spend. SELL: shares to sell.
side=BUY,
)
signed = client.create_market_order(mo)
resp = client.post_order(signed, orderType=OrderType.FOK)
MarketOrderArgs defaults order_type=OrderType.FOK
(clob_types.py L86-L122).
4d. FAK / IOC (Fill-And-Kill, a.k.a. Immediate-Or-Cancel)
OrderType.FAK is Polymarket's IOC variant — fills as much as possible
immediately, cancels the unfilled remainder. Use this for arbitrage legs where
partial fills are acceptable.
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY
order_args = OrderArgs(token_id="...", price=0.42, size=100.0, side=BUY)
signed = client.create_order(order_args)
resp = client.post_order(signed, OrderType.FAK) # IOC behavior
Enum values:
clob_types.py L11-L16`:
class OrderType(enumerate):
GTC = "GTC" # resting limit
FOK = "FOK" # all-or-nothing immediate
GTD = "GTD" # resting limit with expiry
FAK = "FAK" # IOC: fill what you can, cancel rest
TL;DR for an arbitrage executor: use
FAKfor legs where you want IOC semantics on a limit order, andFOKfor $-denominated market-sweep buys where you only want the trade if the full notional clears.
post_only=True is only legal with GTC / GTD
(client.py L623-L628).
5. Cancel Orders
Source: examples/cancel_order.py,
examples/cancel_orders.py.
# single
client.cancel(order_id="0xabc...")
# batch
client.cancel_orders(["0xabc...", "0xdef..."])
# all open orders for this API key
client.cancel_all()
# all orders on a market or token
client.cancel_market_orders(market="0x...condition_id...", asset_id="")
client.cancel_market_orders(market="", asset_id="<token_id>")
Implementations:
client.py L663-L748.
6. Get Positions / Open Orders / Fills
py-clob-client does not ship a get_positions() method — Polymarket exposes
"positions" via the Data-API (separate service). Within py-clob-client you use
balance/allowance for current token holdings, get_orders for open
orders, and get_trades for fills.
Balance / position per token
Source: examples/get_balance_allowance.py.
from py_clob_client.clob_types import BalanceAllowanceParams, AssetType
# USDC balance + exchange allowance
usdc = client.get_balance_allowance(
BalanceAllowanceParams(asset_type=AssetType.COLLATERAL)
)
# Conditional-token (outcome share) balance for one token_id
shares = client.get_balance_allowance(
BalanceAllowanceParams(
asset_type=AssetType.CONDITIONAL,
token_id="71321045679252212594626385532706912750332728571942532289631379312455583992563",
)
)
Note: BalanceAllowanceParams.signature_type defaults to -1 and is auto-filled
from the client.
Open orders
from py_clob_client.clob_types import OpenOrderParams
orders = client.get_orders(OpenOrderParams()) # all
orders = client.get_orders(OpenOrderParams(market="0x...condition")) # one market
orders = client.get_orders(OpenOrderParams(asset_id="<token_id>")) # one token
get_orders paginates internally with next_cursor
(client.py L750-L769).
Fills (trades)
Source: examples/get_trades.py.
from py_clob_client.clob_types import TradeParams
trades = client.get_trades(
TradeParams(
maker_address=client.get_address(),
market="0x5f65177b394277fd294cd75650044e32ba009a95022d88a0c1d565897d72f8f1",
)
)
7. Multi-Leg / Parallel Order Submission
py-clob-client is synchronous (it uses httpx in blocking mode). For
arbitrage you have two good options:
Option A — Server-side batch (preferred when atomicity matters less)
post_orders ships N orders in one HTTP round-trip. Lower latency than N
parallel calls, but the server processes them serially.
Source: examples/orders.py.
from py_clob_client.clob_types import OrderArgs, PostOrdersArgs, OrderType
from py_clob_client.order_builder.constants import BUY, SELL
resp = client.post_orders([
PostOrdersArgs(
order=client.create_order(OrderArgs(
token_id="...YES_TOKEN_ID...",
price=0.50, size=100, side=BUY)),
orderType=OrderType.FAK,
postOnly=False,
),
PostOrdersArgs(
order=client.create_order(OrderArgs(
token_id="...NO_TOKEN_ID...",
price=0.51, size=100, side=BUY)),
orderType=OrderType.FAK,
postOnly=False,
),
])
Implementation: client.py L592-L621.
Option B — asyncio.gather over a thread pool (true parallel HTTP)
When you want each leg to be a separate request fired concurrently (useful for
hitting the matching engine at the same time across markets), wrap the sync
client with loop.run_in_executor:
import asyncio
from concurrent.futures import ThreadPoolExecutor
from py_clob_client.clob_types import OrderArgs, OrderType
from py_clob_client.order_builder.constants import BUY
# One executor for the whole process is fine. Size = max parallel legs.
_EXECUTOR = ThreadPoolExecutor(max_workers=16)
async def submit_leg(client, order_args: OrderArgs, order_type: OrderType):
loop = asyncio.get_running_loop()
# create_order signs (CPU + 1 cached HTTP call for tick/neg_risk),
# post_order does the actual order POST.
signed = await loop.run_in_executor(_EXECUTOR, client.create_order, order_args)
return await loop.run_in_executor(
_EXECUTOR, client.post_order, signed, order_type
)
async def execute_arb(client, legs: list[tuple[OrderArgs, OrderType]]):
return await asyncio.gather(
*(submit_leg(client, args, ot) for args, ot in legs),
return_exceptions=True, # don't let one failure cancel the others
)
# Usage
legs = [
(OrderArgs(token_id=YES, price=0.50, size=100, side=BUY), OrderType.FAK),
(OrderArgs(token_id=NO, price=0.51, size=100, side=BUY), OrderType.FAK),
]
results = asyncio.run(execute_arb(client, legs))
Notes:
- The
ClobClientinstance is safe to share across threads for read paths and forpost_order. Each call constructs its ownhttpxrequest. - Pre-warm tick/
neg_riskcaches by callingclient.get_tick_size(token_id)andclient.get_neg_risk(token_id)once at startup so the hot path skips two HTTP round trips percreate_order. Caches live on the client (client.pyL156-L160, L402-L448). - The tick-size cache TTL is configurable via the
tick_size_ttlctor arg (default 300s).
8. NegRisk Markets
NegRisk ("negative-risk") markets are Polymarket's multi-outcome markets where the YES tokens of all outcomes sum to ~$1. They use a different exchange contract than vanilla binary markets, but the SDK handles the routing for you.
What you do NOT need to do
OrderArgs is identical for negRisk and vanilla tokens — you still pass
token_id, price, size, side. There is no neg_risk field on
OrderArgs.
What the SDK does behind the scenes
When you call client.create_order(...), it:
- Calls
GET /neg-risk?token_id=...to learn whether the token belongs to a negRisk market (cached forever pertoken_id—client.pyL441-L448). - Calls
get_contract_config(chain_id, neg_risk=True/False)to pick the right exchange address (builder.pyL146-L154). - Signs the EIP-712 payload against that exchange's domain separator.
When you DO need to override
If you already know the market is negRisk and want to skip the lookup, pass
PartialCreateOrderOptions:
from py_clob_client.clob_types import PartialCreateOrderOptions
signed = client.create_order(
order_args,
options=PartialCreateOrderOptions(neg_risk=True, tick_size="0.01"),
)
(definition:
clob_types.py L165-L172.)
Allowances (one-time, per signer)
For EOAs (signature_type=0) you must approve both the vanilla CTF Exchange
and the NegRisk CTF Exchange + NegRisk Adapter on Polygon mainnet.
Magic/proxy wallets (signature_type=1 or 2) have allowances set
automatically. From the README:
| Token | Approve for |
|---|---|
USDC (0x2791Bca1...A84174) |
0x4bFb41d5...8B8982E (CTF Exchange) |
Conditional Tokens (0x4D97DCd9...476045) |
0xC5d563A3...220f80a (NegRisk Exchange) |
0xd91E80cF...0DA35296 (NegRisk Adapter) |
Reference allowance script (linked in the README): https://gist.github.com/poly-rodr/44313920481de58d5a3f6d1f8226bd5e
9. Error Handling
Exception hierarchy
All client exceptions live in
py_clob_client/exceptions.py:
class PolyException(Exception):
msg: str
class PolyApiException(PolyException):
status_code: int | None # HTTP status from the failed response
error_msg: dict | str # parsed JSON or raw text body
PolyApiException is raised inside http_helpers/helpers.py for any non-2xx
HTTP response. httpx exceptions (httpx.RequestError,
httpx.TimeoutException, httpx.HTTPError) can leak through on network/DNS
failures.
Recommended catch ladder
import httpx
from py_clob_client.exceptions import PolyApiException, PolyException
try:
resp = client.post_order(signed, OrderType.FAK)
except PolyApiException as e:
# API-layer failure: invalid price, insufficient balance, market closed, 4xx/5xx
if e.status_code in (429, 502, 503, 504):
# rate-limited or transient — retry with backoff
...
elif e.status_code in (400, 422):
# client error — DO NOT retry; surface to operator
...
else:
...
except (httpx.TimeoutException, httpx.RequestError) as e:
# Network-layer failure — safe to retry idempotently if you used a fresh nonce
...
except PolyException as e:
# Local SDK validation (e.g., invalid tick size, bad side)
...
Retry guidance for an arbitrage executor
- Idempotency: Polymarket assigns the order ID on the server side from the
EIP-712 hash, so re-posting the exact same signed payload is naturally
idempotent for
GTC/GTD. ForFAK/FOK, the hash includes the salt so a freshcreate_ordercall generates a new order — only retry if you confirmed viaget_orders/get_tradesthat the original did not fill. - Cap retries at 1-2 for any order-placement call; latency-sensitive arbitrage prefers fast failure over duplicated risk.
- Pre-flight checks before any loop:
client.get_balance_allowance(...)for both legs,client.get_tick_size(token_id)to warm the cache. - Heartbeat-based dead-man switch:
client.post_heartbeat(heartbeat_id)cancels all your orders if no heartbeat arrives within 10s — useful as a safety net while the executor is running (client.pyL713-L727).
Common API-side rejection reasons
not enough balance / allowance— top up USDC or re-run the allowance script.min size not met—OrderBookSummary.min_order_size.tick size invalid— yourpricedoesn't fit the market's tick. Useclient.get_tick_size(token_id)and round.market not active— market is paused, resolved, or closed.order expired— forGTD,expirationmust be >now + 60s.
10. Constants Cheat Sheet
from py_clob_client.constants import POLYGON, AMOY # 137, 80002
from py_clob_client.order_builder.constants import BUY, SELL # "BUY", "SELL"
from py_clob_client.clob_types import OrderType, AssetType
# OrderType.GTC | FOK | GTD | FAK
# AssetType.COLLATERAL | CONDITIONAL
POLYGON = 137, AMOY = 80002 (testnet) — see
constants.py.
Default CLOB host: https://clob.polymarket.com.