API Notes

200 OK, zero rows

I have been running a price-alert bot on Polymarket's public API since October 2025. Here is every place it gave me a wrong answer without ever returning an error.

Gent Blaku·x.com/_GentB·linkedin.com/in/gentblaku

slug = will-joe-biden-get-coronavirus-before-the-election

GET /markets?slug=…                    200 OK   []
GET /markets?slug=…&closed=false       200 OK   []        ← same as the default
GET /markets?slug=…&closed=true        200 OK   [ { … } ]  ← there all along

The default query is not unfiltered. It is silently closed=false. So the API has no way to express “give me this market, whichever state it is in”. You have to already know whether it resolved in order to write a query that finds it. Ask wrongly and you are told, in the only vocabulary available for it, that a market which settled in November 2020 was never there.

That is the pattern in all six of the following. None of them throws. None returns a 4xx you would notice, or a message explaining itself. Each one hands back a response that is shaped exactly like a correct answer, and is wrong. I found all six by measuring something else and noticing the numbers were impossible.

They are ordered by what they cost me.

Cost: a rewritten subsystem

Resolved markets are invisible unless you already know they are resolved

AskGET /markets?slug=<a market that has settled>
Get200 OK
Body[]
MeansThe default is closed=false. There is no query for “either state.”

I tried to check whether the markets my bot had flagged were eventually right. Of 14,090 markets I had alerted on, 13,312 came back empty. I concluded the historical record was being deleted, and built an entire second subsystem to capture outcomes at the moment markets settle, rather than depending on being able to look them up later.

That subsystem was the correct thing to build for other reasons. But the premise was wrong. Measuring it properly, by sampling my own alert history and asking for each market both ways:

markets flagged inplain querywith closed=truegenuinely gone
Oct 20250%69%31%
Nov 20250%80%20%
Dec 20250%72%28%
Jan 20260%93%7%
Jul 202641%55%5%
Aug 202610%90%0%

Zero. Not “a low number”. Zero percent of the markets I flagged between October 2025 and January 2026 are visible to a default query today. They are all still there. Every one of them is one parameter away.

There is a real decay underneath it too: roughly 20–30% of ten-month-old markets are unreachable by slug even with closed=true. That is the genuine data-availability problem, and it was completely hidden behind the bigger, dumber one.

There is a second-order cost to this one. Because the default silently excludes resolved markets, a market that is still open but whose end date has long passed is also easy to misread in the other direction, and there are a lot of those. 6,643 of the 36,476 markets Polymarket currently lists as active have an end date in the past. A Japanese baseball game scheduled for 4 July is still flagged open on 22 August. Whatever “active” means on this exchange, it is not “has not finished yet.”

The fix is a status code, and a third option. A slug that has never existed and a slug that resolved last year are different answers, and the API currently has one word for both. Return 404 for the first. And let closed take an "any" value, so a client that does not already know a market's state can still ask about it.

Cost: two months of missed alerts

The instruction for how to paginate exists only inside the error

AskGET /markets?offset=2100
Get422 Unprocessable Entity
Body{"error":"offset too large, use /markets/keyset for deeper pagination"}

The offset cap sits just above 2,000. My pagination loop treated a non-200 as the end of the data and stopped, which is a normal thing for a pagination loop to do. So the bot silently narrowed to about 1,100 markets when roughly 3,600 qualified. It kept running. It kept posting. It was simply blind to two-thirds of the exchange, and the only symptom was that it had less to say.

The message naming the fix was in the response body the entire time. I was reading status codes and not bodies. That part is my fault and I would rather say so than not. But a cap that announces itself only to whoever thinks to print the body of a failure is a cap most people meet the hard way.

The fix is a header. Send the cap and the successor endpoint in the response headers of successful calls, before anyone hits the wall, the way rate limits are conventionally advertised.

Cost: two days of corrupt catalogue snapshots

A deep walk gets rate limited, and a partial walk looks like a complete one

AskGET /markets/keyset × 365 pages
Get429 at around page 25
Bodyno Retry-After, no documented limit

I take a daily census of the whole catalogue. My own code kept whatever it had collected when a page failed. That is correct for alerting, where a short list just means fewer candidates. For recording, it is poison. The census wrote down that Polymarket had 2,400 markets on one day and 2,400 again the next, against a real catalogue of 36,464. Sports appeared to fall from 4,758 markets to 45 overnight.

Nothing had happened. One request in a 365-page walk had been throttled. Two days of a table whose entire purpose is measuring change over time recorded a change that never occurred.

The fix is a documented limit and a Retry-After. A walk that must be 365 pages long to see the whole catalogue will meet the limiter; it should be told what the limit is rather than discovering it as an outage.

Cost: an afternoon, and a silent zero

Two endpoints for the same resource, two different response shapes

AskGET /markets
Geta bare JSON array
AskGET /markets/keyset
Get{ $schema, markets, next_cursor }

The endpoint the first one redirects you to wraps its rows under markets. If you migrate and reach for data, the more common convention and the one I assumed, you get undefined, then an empty array, then a loop that exits immediately having found nothing. No error at any step.

The fix is consistency, or failing that, documentation. The two endpoints are presented as alternatives for the same job; they should return the same envelope.

Cost: a wasted dataset

Keyset pagination walks oldest-first, so a page cap lands in 2020

AskGET /markets/keyset?closed=true
Get200 OK
BodyWill Joe Biden get Coronavirus before the election? (2020-11-04)

Walking resolved markets to build an outcome dataset returns 2020 and 2021 first. Any sane safety limit on the page count (mine was 200 pages) terminates the walk while it is still years away from anything recent. You end up with a large, clean, perfectly useless dataset that joins to none of your current markets.

The fix is a sort parameter. Newest-first is the common case for anyone building on live data.

Cost: an hour

The default Python user agent is blocked

AskGET /markets User-Agent: Python-urllib/3.14
Get403 Forbidden
AskGET /markets User-Agent: axios/1.7
Get200 OK

Identical request, identical parameters. The 403 carries no explanation, so it reads as an auth problem or a block, not a user-agent filter. Python's standard library is a reasonable guess at the first thing a newcomer reaches for.

The fix is a reason in the body. If the filter is deliberate, say which header is the problem.

The common thread is that none of these are outages. Every one returned a response that a reasonable client accepts. Empty arrays, valid JSON with the rows under a different key, a truncated walk that looks like a finished one, a 200 that means no. A developer who hits any of them does not file a bug, because from where they are standing nothing has broken. They build something quietly wrong, get confused by their own numbers, and drift away.

I only found them because I was measuring the exchange and kept arriving at figures that could not be true: a catalogue that shrank 94% overnight, a bot that went quiet with no error in its logs, sports markets that vanished and came back. Every impossible number turned out to be my instrument, and every instrument turned out to be reading a response that said it was fine.

Fixing the first item alone, a 404 where there is currently an empty list, removes the single most expensive misconception I have had about this platform in ten months.

Every example above re-verified against the live API on 22 August 2026.
Gent Blaku · x.com/_GentB · linkedin.com/in/gentblaku
The bot is @p0lywhales. Not affiliated with or endorsed by Polymarket.