# How to build a trading bot on the Polymarket API

> Placing one order on Polymarket takes an afternoon. Keeping a bot alive for months is a different job: credentials that must match your account type, a settlement step that happens after the match, a heartbeat that cancels your book if it stops, and restarts that return HTTP 425 instead of an error you recognise.

- Source: https://papaproxy.net/blog/polymarket-trading-bot.php
- Published: 2026-08-09
- Author: Alex Young
- Category: Automation · PapaProxy.net Blog

---

## Key takeaways

- Gamma, the Data API and the CLOB's market-data endpoints need no authentication; CLOB trading needs L1-derived credentials, L2 headers **and** a signature over the order payload itself.
- Install the current unified SDK for TypeScript or Python — the original `py-clob-client` and `py-clob-client-v2` both point to it; the official Rust client is still the previous generation.
- New accounts use a deposit wallet with pUSD collateral; proxy and Safe types are legacy, and the modern client resolves the account type from your wallet address.
- The order heartbeat is a dead-man switch: miss a valid heartbeat for 10 seconds and every open order on those credentials is cancelled, with the check running every five seconds — send one every five seconds, echoing the previous `heartbeat_id`.
- Matching-engine restarts return `HTTP 425`, then two minutes of post-only mode; `503` signals cancel-only or post-only. Back off and change what you send, don't retry harder.
- Guard every market order: estimate the fill level first, then send `max_price` (or `min_price` for SELL) plus `max_spend`, which caps the total including fees — without it, fees are charged on top of `amount`.
- A match isn't a settled trade: settlement happens on-chain afterwards, so don't treat "matched" as final. And with no client-supplied order ID on this API, persist the signed intent before sending and reconcile against open orders and recent trades when an outcome is ambiguous.
- On reconnect treat the local book as invalid, resubscribe, wait for the fresh snapshot, then apply increments — there's no sequence number for splicing a REST snapshot into a stream gap.
- Order rate limits follow the signer, so more addresses don't buy more order throughput; read limits follow the IP, which is the only side that scales horizontally.

## What a bot needs beyond an order call

[Polymarket's API for automated trading](/blog/polymarket-three-apis.php) spans three hosts, and a bot touches all of them. Gamma answers what exists — markets, condition IDs, the token IDs you trade against. The CLOB answers what it costs and accepts your orders. The Data API answers what a wallet holds, which is how you reconcile after a restart.

Reading needs no ceremony: Gamma, the Data API and the CLOB's market-data endpoints — order book, prices, spreads — require no authentication at all. CLOB trading needs both levels of CLOB auth *plus* a signature over the order itself. Other flows on the platform, such as the relayer and the bridge, have their own separate mechanics — so "authenticated" isn't one uniform thing here. The practical upshot is that the market-data half of a bot can be built, tested and run before you touch a private key.

Trading automation on this venue decomposes into six layers that fail independently, and only the first is about placing orders:

1. **Authentication** — credentials tied to your account type and funding wallet.
2. **Order construction** — tick size, minimum size, the negative-risk flag, and a signature over the payload.
3. **Liveness** — the heartbeat, which cancels your resting orders if your process stops.
4. **Settlement** — a match is not yet an on-chain trade.
5. **Failure handling** — refusals, throttling, restart modes, and outcomes you can't determine.
6. **Recovery** — rebuilding book and account state after a disconnect.

Most bots that die in production die in layers three to six, long after layer one worked.

## Signing and submitting an order

Authentication has two levels. **L1** is a signature made with your wallet's private key over an EIP-712 message; it proves ownership and is used once to create or derive API credentials — `apiKey`, `secret`, `passphrase`. **L2** is those credentials, used as an HMAC-SHA256 signature on each authenticated request, carried in five headers: `POLY_ADDRESS`, `POLY_SIGNATURE`, `POLY_TIMESTAMP`, `POLY_API_KEY` and `POLY_PASSPHRASE`. The private key stays on your side and trading remains non-custodial.

Then the detail that saves the most debugging time, stated plainly in the documentation: *even with L2 authentication headers, methods that create user orders still require the user to sign the order payload.* L2 authenticates the request; the order is a separately signed object. A bot with valid headers that still gets rejected is usually missing that second signature rather than having broken credentials.

The account model has moved. New Polymarket accounts use a deposit wallet as their smart wallet, and the older proxy and Gnosis Safe types are legacy; collateral is pUSD. In the current unified SDK you pass the private key and the wallet address, and the client resolves the account type for you — the explicit `signature_type` juggling that older tutorials show belongs to the previous client generation. A trading bot in Python starts like this, following the official quickstart:

PythonCopy code

```python
import asyncio, os
from polymarket import AsyncSecureClient

async def main():
    client = await AsyncSecureClient.create(
        private_key=os.environ["POLYMARKET_PRIVATE_KEY"],
        wallet=os.environ["POLYMARKET_WALLET_ADDRESS"],   # your deposit wallet
    )

    market = await client.get_market(slug="some-market-slug")
    token_id = market.outcomes.yes.token_id
    assert token_id is not None

    # 1. Estimate the level this order would reach at current book depth.
    estimated = await client.estimate_market_price(
        token_id=token_id, side="BUY", amount="10", order_type="FAK",
    )

    # 2. Send it with both guards: a worst price and a hard spend cap.
    response = await client.place_market_order(
        token_id=token_id,
        side="BUY",
        amount="10",          # pre-fee notional
        max_spend="10",       # total including fees; omit and fees are charged on top
        max_price=estimated,  # worst acceptable price (min_price for SELL)
        order_type="FAK",
    )
    if not response.ok:
        raise RuntimeError(response.message)              # rejection carries a code and message

    print(response.order_id)

asyncio.run(main())
```

What to look at. The two guards are the point of the example. `max_price` is a worst-price limit rather than a target — it protects you from the book moving between your estimate and your submission — and the SELL equivalent is `min_price`. `max_spend` caps the total including fees: the SDK reduces the signed buy amount so the order and its fees fit the cap, and if you omit it, `amount` is a pre-fee figure and fees are charged on top. Both matter more for a bot than any settlement helper, because a bot places these unattended.

The order ID comes back from the server in `response.order_id`; there is no client-supplied order ID on this API to look up later — a point that matters for recovery, below. A market order never rests: whatever can't fill is cancelled rather than left open. And note what the response doesn't tell you — matching and on-chain settlement are separate steps, covered in the failure-modes section.

Two market-level details to read rather than assume. Tick size varies by market — the documented grid runs from `0.1` down to `0.0001` — so fetch it per market instead of hardcoding; a price off the grid is refused with `INVALID_ORDER_MIN_TICK_SIZE`. And some markets delay matching: sports markets apply a one-second placement delay to marketable orders and cancel resting limit orders outright when the game starts, while selected crypto and finance markets apply a 250 ms taker delay. During any such delay the order is pending and **cannot be cancelled**.

## Using an SDK instead of raw requests

You can implement L1, L2 and order signing yourself, and for an unusual language or an audited signing path that's reasonable. For everything else an SDK for trading bots is the pragmatic default, because the parts you'd reimplement are the parts that are easy to get subtly wrong: EIP-712 domain separators, HMAC canonicalisation, order struct field ordering, timestamp units.

Check which generation you're installing, because there are three and tutorials point at all of them. The original `py-clob-client` and its `py-clob-client-v2` successor both now carry notices recommending migration to the unified SDK, which combines the REST APIs and WebSockets in one package and is what the current documentation uses. The unified SDK currently covers TypeScript and Python; the official Rust client remains the previous-generation `polymarket_client_sdk_v2`. If a guide shows `ClobClient` with an explicit `signature_type` and a `funder`, it's describing an older generation — the code may still run, but you're inheriting its assumptions about account types.

One reported trap worth checking before you commit to an account type: in May 2026 a bug was filed against the v2 Python and Rust clients where the deposit-wallet signature type bound the derived API key to the signing EOA rather than the deposit wallet, producing `HTTP 400 — the order signer address has to be the address of the API KEY` on every order. Verify whether it's resolved in the version you install.

## Where bots usually break

Trading bot development on Polymarket fails in recognisable clusters. Knowing them turns hours of diagnosis into minutes.

**The heartbeat is a dead-man switch — and it cuts both ways.** The CLOB exposes an order heartbeat: once the first heartbeat is accepted, the account is expected to keep sending them, and if a valid heartbeat doesn't arrive within 10 seconds, **all open orders owned by those credentials are cancelled**. The cancellation check runs every five seconds, so the actual cancellation can land up to five seconds after the timeout. The documented pattern is to send one every five seconds, echoing back the `heartbeat_id` from the previous response, with an empty string on the first call; an invalid or expired ID returns `400` along with the correct ID to use. Adopt it deliberately: it's the best protection against a hung process leaving live quotes on the book, and it's also a way to lose your entire book to a garbage-collection pause if you set your interval too close to the limit.

**Matching engine restarts have their own protocol.** During a restart window the CLOB returns `HTTP 425 (Too Early)` on order-related endpoints. That is a temporary condition, not an error: back off exponentially starting at one to two seconds and resume. After every restart the engine enters **post-only mode for two minutes**, during which cancels are accepted and new orders must be post-only — non-post-only orders are rejected. You may also see `503` responses indicating cancel-only or post-only mode. None of these should be blind-retried; each requires changing what you send, not sending it again faster. Aggressive retries also risk hitting rate limits the moment the engine returns.

**A match is not a settled trade.** Orders match off-chain, then the operator submits the trade on-chain, where the exchange contract transfers pUSD and the trade reaches finality on Polygon. An accepted order response may arrive before any transaction hash exists. A state machine that treats "matched" as final will misreport positions during the window in between, and it needs an explicit path for the case where the on-chain leg doesn't complete as expected.

**Order rejections** cluster in preventable causes: price off the tick grid, size below the market minimum, insufficient balance or allowance, a post-only order that would cross, a fill-or-kill with no fill. Fetch tick size and minimum size from the market before sending, and refresh the exchange's cached view of your balance and allowance after any on-chain deposit or approval.

**Rate limits are two systems at once.** Cloudflare enforces IP-based burst and sustained windows and throttles before it rejects; on top sit per-signer token buckets with separate order and cancellation balances, scaled by volume tier, reporting state in `Poly-RateLimit-Remaining`, `-Reset` and `-Tier`. Read those headers, and don't try to solve an order-rate limit with more infrastructure — that limiter follows the signer, not the address you connect from. For the read-side numbers and how limits behave in practice, see our [Polymarket rate-limit test](/blog/polymarket-api-rate-limits.php).

**Ambiguous outcomes need intent records, not client order IDs.** Not every failure is ambiguous — a documented rejection tells you the order never reached the book, and a `503` naming cancel-only or post-only mode has known semantics. But a transport timeout or an unclassified server failure can leave the outcome genuinely unknown, and blind retrying there is how a bot ends up with two positions. The recovery here is shaped by the API: the order ID is issued by the server, so there is no client-supplied ID to query afterwards. Persist the signed order intent before you send it — token, side, price, size, timestamp — and on an ambiguous result, reconcile against your open orders and recent trades to decide whether it landed. (Don't carry over habits from venues, including Polymarket's own perpetuals API, where a client order ID does exist.)

**State after a reconnect.** Dropped [WebSocket connections](/blog/polymarket-websocket-api.php) are normal operation. The safe recovery is to treat your local book as invalid the moment the connection breaks: resubscribe, wait for the fresh full book snapshot the market channel sends on subscription, and only then start applying incremental price changes. There is no universal sequence number to stitch a REST snapshot to an unknown gap in the stream, so don't try to splice by timestamp. For private state, query open orders and recent trades before resuming the strategy. The user channel also expects an application-level heartbeat — a `PING` text frame roughly every ten seconds, answered with `PONG` — and a connection that never subscribes may be closed.

**Geography is checked at order time.** Polymarket restricts trading in certain jurisdictions, and orders from restricted locations are rejected. Verify your eligibility before wiring up an order path, and treat this as a legal constraint rather than a technical one.

## Strategies people automate

Two patterns dominate the questions, and both are more constrained than they look.

An arbitrage bot usually targets either the complementary relationship inside one market, where Yes and No should price to roughly 1, or a spread between Polymarket and another venue on the same real-world question. The first is mostly a fees-and-latency problem, and fees are no longer a rounding error: the platform applies taker fees on matched trades depending on market type and runs maker and taker rebate programmes, so read a market's fee configuration from the market object and treat rebates as part of the arithmetic rather than a bonus. The second adds settlement risk, because two venues can resolve the same question differently — different wording, sources and timing. Neither is a free lunch, and this article isn't advice about whether to trade them.

A copy trading bot faces a structural fact: Polymarket has no copy-trading endpoint. What exists is public read access — the Data API returns any wallet's [positions and activity](/blog/polymarket-data-api-positions.php) by address — plus your own authenticated order path. "Copying" is therefore a pipeline you own end to end: detect a change in a watched wallet, decide, place your own order at your own price. Everything in between is your latency and your slippage, and the wallet you follow has no obligation to stay profitable.

The read side of both patterns is where budgets bite. Watching many wallets or markets means polling endpoints whose limits are counted per IP address — `/positions` at 150 requests per 10 seconds, `/markets` at 300 — far tighter than the trading path. We measured that read side ourselves in a [separate test of Polymarket's rate limits](/blog/polymarket-api-rate-limits.php), including how the platform behaves past the limit and how throughput scales across addresses.

Which gives the one infrastructure conclusion worth drawing. Order throughput is bound to the signer and cannot be scaled by adding addresses. Read throughput is bound per IP, so a wide watchlist either slows down or spreads across egress addresses — and that's what our [proxies for crypto projects](/crypto-proxy.php) are for: dedicated IPv4 addresses, static for the full plan term, each subject to its own documented allowance. To be explicit about scope: that's stable egress and read scaling for a legitimate integration, not a way around Polymarket's geographic trading restrictions.
