# We rebuilt our Polymarket CLOB API starter from its own prompt: 3 of the top 20 order books had only one side

> Source: <https://dev.to/botcookery/we-rebuilt-our-polymarket-clob-api-starter-from-its-own-prompt-3-of-the-top-20-order-books-had-5c7e>
> Published: 2026-10-28 14:02:38+00:00

*Originally published on [BotCookery](https://botcookery.com/recipes/polymarket-clob-api-starter/), free build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every output below is from real runs on public Polymarket data on Oct 25, 2026. The code holds no private key and can't place an order, and nothing here is financial advice.*

Our Polymarket CLOB API starter is a prompt. Paste it into your AI coding tool in an empty folder and you get a small Python toolkit for the international Polymarket order book: Gamma market lookups, the CLOB `/book` read the right way round, per-market fees, an offline walk-through of L1 and L2 auth, and a paper broker that fills against the live book. No wallet, no API key, no order code.

On Oct 25 we tested it the only way that counts. We pasted the published prompt into a clean folder, let it build, and ran `book` on each of the top 20 markets by 24-hour volume, every ~50 seconds for about 6 minutes, plus a paper round trip and the auth demo.

A lot of it held. The sorted book's mid matched both the SDK's `PublicClient().get_midpoint()` and raw `/midpoint` on all 5 markets we checked. The fee formula matched Polymarket's fee docs. The L1 typed data and L2 HMAC matched the SDK's own helpers on throwaway keys. The paper ledger rebuilt correctly across separate processes.

Then a Sunday morning of esports, tennis and elections broke the rest of it. Here's what went wrong and how the prompt changed.

International Polymarket splits its public API across three hosts. **Gamma** (`https://gamma-api.polymarket.com`) has markets and events: questions, slugs, and `clobTokenIds`, which are JSON-encoded strings you have to `json.loads`. The **CLOB** (` https://clob.polymarket.com`) has order books, midpoints and, for authenticated users, orders. The **Data API** (`https://data-api.polymarket.com`) has trades, positions and price history. Reads need no key.

The classic gotcha is still there: raw `/book` lists bids ascending and asks descending, so the best price on each side is the **last** element. Our original prompt already said to sort. That part was fine.

`book` crashed on 3 of the top 20 markets, every cycle
The original prompt said "best_bid = max bid price, best_ask = min ask price". Simple enough, until a side is empty. Here's the harness log from the original build, 8 cycles over the top 20 markets:

```
[10:12:07 UTC] cycle 1: book ok 17, crashed 3: dota2-liquid-og-2026-10-25-game1 will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026 cs2-fut-tl1-2026-10-25
[10:12:57 UTC] cycle 2: book ok 17, crashed 3: dota2-liquid-og-2026-10-25-game1 will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026 cs2-fut-tl1-2026-10-25
...
[10:17:53 UTC] cycle 8: book ok 17, crashed 3: dota2-liquid-og-2026-10-25-game1 will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026 cs2-fut-tl1-2026-10-25
```

Same three markets, every time:

```
== dota2-liquid-og-2026-10-25-game1 [0] rc=1
  File ".../polymarket-clob-starter/cli.py", line 41, in <module>
ValueError: max() iterable argument is empty
== dota2-liquid-og-2026-10-25-game1 [1] rc=1
ValueError: min() iterable argument is empty
```

These weren't dead markets. A Dota 2 game and a CS2 match had a result **proposed** through UMA, and a Russia–Ukraine diplomacy market had a result **disputed**. All three were still active and accepting orders, and all three sat in the top 20 by 24h volume. Their books were one-sided: `0.999` bids with nothing on the ask, or `0.001` asks with nothing on the bid, for hundreds of thousands of shares.

If you're writing a scanner, a dashboard or a bot loop, this is the case that kills it: the busiest markets of the morning are exactly the ones that just finished.

The fix in the prompt: either side can be empty, so `best_bid`/` best_ask` become `None`, and so do `mid` and `spread`. Never call `max()`/` min()` on an empty list. `book` prints `no bids` / `no asks`. `markets` now reads `uma_resolution_status` and `accepting_orders` and labels each market, and the paper broker refuses anything that isn't open.

The rebuilt version has a `scan` command: one line per market per loop, a summary line, and an error on one market never stops the loop. We ran it for 8 loops on the top 20 (06:14–06:22 ET). Here's loop 1, trimmed:

```
  open                   two-sided bid 0.787  ask 0.788  spr 0.001  Yes                will-flvio-bolsonaro-win-the-2026-brazilian-presidential-election
  open                   two-sided bid 0.21   ask 0.22   spr 0.01   Yes                will-luiz-incio-lula-da-silva-win-the-2026-brazilian-presidential-election
  resolving (proposed)   one-sided bid -      ask 0.001  spr -      Team Liquid        dota2-liquid-og-2026-10-25-game1
  ...
  resolving (disputed)   one-sided bid 0.999  ask -      spr -      Yes                will-russia-and-ukraine-hold-any-diplomatic-meeting-by-october-31-2026
  resolving (proposed)   one-sided bid 0.999  ask -      spr -      FUT Esports        cs2-fut-tl1-2026-10-25
[10:14:27 UTC] loop 1: 17 two-sided, 3 one-sided, 0 errors
```

After four loops, the CS2 market finished resolving (it closed at 10:18:20 UTC), and its book disappeared:

```
  ERROR cs2-fut-tl1-2026-10-25: HTTP 404 from https://clob.polymarket.com/book: {"error":"No orderbook exists for the requested token id"}
[10:19:10 UTC] loop 5: 17 two-sided, 2 one-sided, 1 errors
...
[10:22:28 UTC] loop 8: 18 two-sided, 2 one-sided, 0 errors
```

Two things happen at resolution, and both look like bugs in your code if you don't know about them:

`/book` returns `/markets?slug=` returns an `closed=true`.
So between two scan loops, a lookup by slug went from "one-sided" to "no such market". The rebuilt prompt treats the 404 as "closed, or wrong token", retries Gamma with `closed=true`, and reports the close time, `umaResolutionStatus` and final prices instead of a traceback. The 8-loop scan logged the 404 and kept going: 0 crashes.

It's tempting to read `bestBid`, `bestAsk` or `outcomePrices` straight off Gamma, since it's already in the market object. During a live CS2 match, Gamma said **0.79/0.80** while the book and `/midpoint` said **0.85/0.86**. Gamma's `orderPriceMinTickSize` also said `0.01` on a market whose book had already moved to a `0.001` tick (tick size tightens as prices approach 0 or 1).

The prompt now takes prices, tick size and minimum order size from the book only, and never shows Gamma's numbers as live prices. If your "price check" compares your book against Gamma, you'll see phantom mismatches whenever something is actually happening.

One more API quirk we checked again: `GET /price?side=BUY` returned the **best bid** on Oct 22 and again on Oct 25, while the side naming in the docs reads the other way. We don't use `/price` for the top of book at all. Derive bid and ask from the sorted book.

Copy a slug out of a `polymarket.com/event/...` URL and you often have an **event** slug, not a market slug. `book brazil-presidential-election` failed with "no market with slug", even though that event has 32 markets.

The rebuilt `gamma_market()` checks closed markets first (many single-match events share their slug with the main market), then tries `/events?slug=`, and if it finds an event, lists its first 10 market slugs so you can pick one.

International Polymarket's taker fee is `shares × rate × p × (1 − p)`, rounded to 5 decimals in USDC, with the rate in each market's own `feeSchedule`. The docs have a category table, so it's tempting to hardcode it. Live rates that morning: **0** on the Brazil election and Fed-rate markets, **0.05** on tennis and esports, and **0.03** on the Ballon d'Or and an NFL total. The docs' table has no 0.03 category.

Our prompt already said to read `feeSchedule` per market. The run is the reason it now says, in capitals, never to hardcode a category rate.

The paper broker only simulates **taker** orders: a BUY walks the live asks up to your limit, a SELL walks the bids down. In the original build, a limit below the ask came back as "no liquidity". That's wrong. There was plenty of liquidity; your order just wouldn't fill now. It now says the order "would rest on the book; paper mode only simulates orders that fill now", and records partial fills as partial instead of pretending they filled in full.

Here's the paper ledger from the rebuilt version: a round trip on the Rybakina vs Fernandez tennis market (fee rate 0.05), with each command in a separate process so the ledger rebuild gets exercised. Token IDs trimmed:

```
{"slug": "wta-rybakin-fernand-2026-10-25", "outcome": "Elena Rybakina", "side": "BUY", "limit": 0.94, "requested": 5.0, "shares": 5.0, "partial": false, "avg_price": 0.94, "fee": 0.0141, "fee_rate": 0.05, "realized": 0.0, "book_hash": "51d853fc36f03b3aebad567dc2f345a1cc1f5947"}
{"slug": "wta-rybakin-fernand-2026-10-25", "outcome": "Elena Rybakina", "side": "BUY", "limit": 0.94, "requested": 5.0, "shares": 5.0, "partial": false, "avg_price": 0.94, "fee": 0.0141, "fee_rate": 0.05, "realized": 0.0, "book_hash": "37f85fb8ac9934546d742510c8bb7be122dfd48b"}
{"slug": "wta-rybakin-fernand-2026-10-25", "outcome": "Elena Rybakina", "side": "SELL", "limit": 0.93, "requested": 5.0, "shares": 5.0, "partial": false, "avg_price": 0.94, "fee": 0.0141, "fee_rate": 0.05, "realized": -0.0282, "book_hash": "388d4952d707caa33d13768bf663b8c1e577b782"}
```

The fee checks out: 5 × 0.05 × 0.94 × 0.06 = 0.0141. The sell had a 0.93 limit but filled at 0.94, because the best bid moved up a tick (new book hash) in the second between the two calls. And the realized −$0.0282 is nothing but the two taker fees on an unchanged price. That's the point of paper mode: a round trip that "breaks even" on price still loses money.

In between those fills, the broker refused a non-crossing limit ("would rest on the book"), an order over the $10 per-order cap, a sell larger than the paper position (no shorting), an order on a resolving market, and every order while a `KILL` file existed.

This is the bit people search for ("how do I get a Polymarket API key"), and the starter only explains it, offline:

`ClobAuthDomain`, version `1`, chain 137) proving you control a wallet. It's used once, to create or derive API credentials: key, secret, passphrase.`timestamp + METHOD + path + body`, keyed with the urlsafe-base64-` POLY_ADDRESS`, `POLY_SIGNATURE`, `POLY_TIMESTAMP`, `POLY_API_KEY` and `POLY_PASSPHRASE` headers on every trading call.
`auth_explain.py` builds both with a key made in memory by `eth_account.Account.create()` and obviously fake L2 credentials, and the tests check both against the SDK's own helpers. Nothing is sent. No real key ever touches the project.

Paste this follow-up prompt into the same project:

```
Update my Polymarket CLOB starter:
1. parse_book: either side can be empty (markets whose result is
   "proposed" or "disputed" often have only 0.999 bids or 0.001 asks).
   best_bid/best_ask None, mid/spread None, never max()/min() on an empty
   list. Add Quote.book_hash and a "two-sided"/"one-sided"/"empty" label.
   book prints "no bids"/"no asks" and "-" for missing numbers.
2. get_book: a 404 "No orderbook exists" means the market closed (books
   are deleted at resolution) or the token is wrong; raise PMError saying so.
3. gamma_market: if /markets?slug= is empty, retry with closed=true and
   report "closed at <closedTime> (<umaResolutionStatus>), final prices";
   else try /events?slug= and list the event's market slugs.
4. list_markets: add status from m.resolution.uma_resolution_status and
   m.state.accepting_orders ("resolving (proposed)", "not accepting
   orders", "open"). Paper refuses non-open markets.
5. Take tick_size and min_order_size from the book, never from Gamma, and
   never show Gamma bestBid/bestAsk/outcomePrices as live prices.
6. Paper: if the limit doesn't cross, reject with "would rest on the book;
   paper mode only simulates orders that fill now"; record partial fills;
   fee per filled level, rounded to 5 decimals.
7. Add `scan [--n 20] [--loops 1] [--interval 60]`: one line per market
   per loop, a "[HH:MM:SS UTC] loop N: X two-sided, Y one-sided, Z errors"
   summary, flush=True, and an error on one market never stops the loop.
8. The CLI catches PMError, prints one line and exits 1 (no tracebacks).
Add tests for each change. Run them and show me the diff. Still no keys
and no order placement.
```

Starting fresh? The full prompt is on the recipe page. After it builds:

```
python3 -m venv .venv && source .venv/bin/activate
pip install polymarket-client requests python-dotenv pytest eth-account
python3 -m pytest -q
python3 cli.py markets
python3 cli.py book <slug-from-the-list> 0
python3 cli.py scan --n 20 --loops 5 --interval 60 | tee scan.log
python3 cli.py paper <slug> 0 <best-ask> 5
python3 auth_explain.py
```

The page's verified line, word for word: *Verified working on live data in read-only and paper mode, Oct 25, 2026.* Its stated limit: public market data and paper fills only, including an 8-minute scan of the top 20 markets. L1/L2 signing was checked offline against the SDK; no API key was created and no order was sent.

The toolkit has no private key and no order placement, and a test greps the codebase to keep it that way. It prints books, fees and paper fills. It doesn't rank, score or suggest anything. If you ever take something like this live, use a dedicated hot wallet with a small balance, keep the API credentials as carefully as the key, and check Polymarket's geoblock endpoint from the machine that will trade.

The full recipe, with the complete prompt, step-by-step setup, the FAQ and links to every Polymarket doc we checked, is at **[botcookery.com/recipes/polymarket-clob-api-starter](https://botcookery.com/recipes/polymarket-clob-api-starter/)**. If you'd rather stream the same books than poll them, see our [Polymarket WebSocket starter](https://botcookery.com/recipes/polymarket-websocket-starter/), which ran into the same one-sided books from the other direction.
