# How to pull historical prices from the Polymarket CLOB

> Most empty responses from Polymarket's price history endpoint come from one cause: the parameter named `market` doesn't want a market ID. The second most common is a query shape that doesn't suit the range you're asking for.

- Source: https://papaproxy.net/blog/polymarket-historical-data.php
- Published: 2026-08-09
- Author: Alex Young
- Category: API & data · PapaProxy.net Blog

---

## Key takeaways

- The `market` parameter on `/prices-history` wants the **asset (CLOB token) ID**, not a market ID or condition ID.
- Distinguish the failure modes: invalid filters are rejected with `400`, while a well-formed query for a token that never traded returns `{"history": []}` with a 200.
- Each market has two tokens with two separate series; get them from Gamma's `clobTokenIds`, which arrives as a JSON-encoded string and must be decoded first.
- `interval` and `startTs`/`endTs` are mutually exclusive — for backfills use the explicit range and verify coverage, rather than falling back to coarse fidelity when a query comes back thin.
- `fidelity` on `/prices-history` is an integer in minutes; a separate `GET /ohlc` with a `1m`–`1w` fidelity enum appears in the error reference but isn't yet in the main API index, so verify its contract before relying on it.
- The price series is `{t, p}` only — no volume, no OHLCV — so candles built from it describe the observed series, not the trade tape; trade-level data comes from the Data API's `/trades` at 200 requests per 10 seconds.
- Depth history is unreliable: the legacy `/orderbook-history` sits outside the supported API surface and has been reported returning nothing for windows after February 2026 — record the WebSocket feed or use an archival provider.
- For bulk work use `POST /batch-prices-history` with up to 20 asset IDs per call; `/prices-history` allows 1,000 requests per 10 seconds and Gamma's `/markets` only 300, so batch at both ends.

## What the prices-history endpoint accepts

The price history endpoint is `GET https://clob.polymarket.com/prices-history`, public and unauthenticated like the rest of the CLOB's read surface. It takes four parameters, and the first one is where nearly everything goes wrong:

| Parameter | Type | Meaning |
| --- | --- | --- |
| `market` | string, required | **The asset ID** — the CLOB token, not the market |
| `startTs` | number | Unix timestamp, items after this point |
| `endTs` | number | Unix timestamp, items before this point |
| `interval` | enum | `max`, `all`, `1m`, `1w`, `1d`, `6h`, `1h` |
| `fidelity` | integer | Resolution in **minutes** |

Read the first row again, because the naming is genuinely misleading and the prices-history documentation says it outright: the parameter called `market` wants the asset id. In Polymarket's data model a market has two outcome tokens — Yes and No — each with its own CLOB token ID, and price history belongs to a token, not to the market. There is no single price series for a market, because the market has two sides.

The response is deliberately minimal:

JSONCopy code

```json
{"history": [{"t": 1754400000, "p": 0.63}, {"t": 1754403600, "p": 0.65}]}
```

A Unix timestamp and a price between 0 and 1, where the price reads as an implied probability. Note what isn't there: no volume, no open, high or low, no trade count. This is a series of price observations, not OHLCV — a distinction that shapes the whole next section.

Two more mechanics worth knowing before you write a loop. `interval` and the timestamp pair are alternatives rather than companions: use `interval` for a relative window ending now, or `startTs`/`endTs` for an absolute range. And `fidelity` is expressed in minutes, so `fidelity=60` means hourly points and `fidelity=1440` daily — not "number of points", which is how several third-party wrappers mislabel it.

## Two reasons the response comes back empty

The historical prices endpoint can fail in two different ways, and telling them apart saves time. Malformed or invalid filters are rejected: the error reference documents validation errors for `market`, `startTs`, `endTs` and `fidelity`, returned as `400 Bad Request`. But a request that is *syntactically* fine and simply names a token that never traded — or a window with nothing in it — comes back as `{"history": []}` with a 200. So an empty array isn't a guaranteed signal of a wrong identifier; it's the answer to a well-formed question that has no data behind it.

**Cause one: you passed a market ID instead of a token ID.** This is the naming trap from the previous section. [Gamma's market object](/blog/polymarket-three-apis.php) gives you both: `id` (a short numeric string like `15345`), `conditionId` (a `0x…` hash), and `clobTokenIds` — a **JSON-encoded string** holding the two token IDs. Decode that field before use, then pass one of the two long numeric IDs:

PythonCopy code

```python
import json, requests

GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"

def token_ids(slug):
    r = requests.get(f"{GAMMA}/markets", params={"slug": slug}, timeout=15)
    r.raise_for_status()
    market = r.json()[0]
    outcomes = json.loads(market["outcomes"])          # e.g. ["Yes", "No"]
    tokens = json.loads(market["clobTokenIds"])        # JSON-encoded, not a list
    return dict(zip(outcomes, tokens)), market["closed"]

def price_history(token_id, start_ts=None, end_ts=None, fidelity=60, interval=None):
    params = {"market": token_id, "fidelity": fidelity}
    if interval:                                        # relative window ending now
        params["interval"] = interval
    else:                                               # absolute range — preferred for backfill
        params["startTs"], params["endTs"] = start_ts, end_ts
    r = requests.get(f"{CLOB}/prices-history", params=params, timeout=30)
    r.raise_for_status()                                # 400 on invalid filters
    return r.json().get("history", [])
```

**Cause two: the query shape doesn't fit the range.** `interval` and the timestamp pair are mutually exclusive, and for historical backfills the absolute range is the one to reach for. There's a reported case in Polymarket's client repository where a resolved market queried with `interval=max` returned data at coarse fidelity and an empty array at finer fidelity — with the reporter's own follow-up being to switch to explicit `startTs`/`endTs`. Treat that as a query-shape lesson rather than a retention rule: for closed markets, bound the range explicitly around the period the market was actually trading, then verify coverage by counting the points you got against the span you asked for. Don't hardcode a coarse fidelity as a blanket fallback — you'd be discarding detail that may well be available.

A third, simpler cause: a market that never traded has no history to return. Check `volumeNum` on the Gamma object before concluding something is broken.

## Building candles from a price series

Because the response carries only timestamps and prices, historical price data here isn't OHLCV and can't be turned into it faithfully. You can build candles, but you should know exactly what they mean.

Before building your own, check whether you still need to. Polymarket's error reference documents a `GET /ohlc` endpoint taking `asset_id`, `startTs`, `limit` and a `fidelity` enum of `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w` — a different parameter style from `/prices-history`, where fidelity is an integer in minutes. The caveat is that it isn't yet carried in the main API reference index the way the other market-data endpoints are, so treat its contract as unconfirmed: probe it, compare a window against `/prices-history`, and don't build production code on it until you've verified the response shape yourself.

If you do build candles from the price series, know exactly what they mean. With `fidelity` set fine enough you can group consecutive observations into buckets and take first, max, min and last within each. That yields open, high, low and close **of the observed series**, not of the trade tape: if two observations bracket a spike that happened between them, the spike never existed as far as your candles are concerned. Volume simply doesn't exist in this data; anything that needs it has to come from the trade level, covered next.

Two practical notes. Prices are probabilities in the 0–1 range, so returns and volatility computed on them behave differently from asset prices — a move from 0.02 to 0.04 is a doubling, and near the bounds the series compresses. And the two tokens of a market are near-complements: the Yes and No series should roughly sum to 1, and the gap between them is a spread artefact rather than a signal. Pulling both and checking that sum is a cheap sanity check on your identifiers — if it lands nowhere near 1, you're probably holding tokens from two different markets.

## Going below prices to individual trades

When aggregated prices aren't enough, historical trades are available — from a different host. The Data API's `/trades` endpoint returns executed trades and accepts filters including the market's condition ID, so the natural path is Gamma for the condition ID, then Data for the fills. Its budget is tighter than the CLOB's — 200 requests per 10 seconds — so trade-level backfills are the slowest part of any pipeline and belong in a background job. The [subgraph layer](/blog/polymarket-graphql-api.php) is the other route: the orderbook subgraph indexes on-chain fill events, which suits aggregate analysis better than per-market queries. One caveat there — the public subgraph manifest still lists the original CTF Exchange contracts, while Polymarket moved to CLOB V2 on new exchange contracts in 2026, so an older deployment may not carry complete recent fill history. Check which contracts a deployment indexes before treating it as authoritative.

Historical order book depth is the murkiest corner. A legacy `GET /orderbook-history` endpoint exists on the CLOB — it appears in Polymarket's error reference and accepts `asset_id`, `startTs`, `endTs`, `limit` and `offset` — but it isn't part of the currently supported main API surface, and a February 2026 bug report against a trading framework documents it returning `{"count": 0, "data": []}` for any window after roughly 20 February 2026, while older history still comes back normally. So it may serve archival depth, and it should not be relied on for anything recent. For dependable new depth history the options are unchanged: [record the WebSocket feed](/blog/polymarket-websocket-api.php) yourself going forward, or buy from a provider that has been recording. Discovering this after building a backtest that assumes historical depth is a costly lesson; knowing it in week one is a design constraint like any other.

## Bulk pulls across many markets

Historical data for backtesting means thousands of markets, and there the single-token endpoint is the wrong tool. The CLOB documents a batch variant: `POST https://clob.polymarket.com/batch-prices-history` with a JSON body of `markets` (a list of asset IDs, **maximum 20**), plus `start_ts`, `end_ts` and `fidelity` — note the snake_case here against the camelCase of the single endpoint, and that `fidelity` defaults to 1 minute.

PythonCopy code

```python
import requests

def batch_history(token_ids, start_ts, end_ts, fidelity=1440):
    assert len(token_ids) <= 20                    # documented ceiling
    r = requests.post(f"{CLOB}/batch-prices-history",
                      json={"markets": token_ids, "start_ts": start_ts,
                            "end_ts": end_ts, "fidelity": fidelity},
                      timeout=60)
    r.raise_for_status()
    return r.json()
```

That's a 20× reduction in requests for the same data, and it's the single most effective thing you can do for a historical data download at scale. A sweep of 5,000 markets — 10,000 tokens, since each market has two — drops from 10,000 requests to 500.

Sizing the rest of the job: `/prices-history` has its own line in [Polymarket's rate-limit table](/blog/polymarket-api-rate-limits.php) at 1,000 requests per 10 seconds — not the CLOB host's general 9,000 — and limits are enforced per IP with Cloudflare throttling rather than immediate rejection. The token IDs themselves come from Gamma, which is stricter still: `/markets` allows 300 requests per 10 seconds. So both ends of the pipeline are constrained, and batching matters at both. Worth noting for a common search: price history on Gamma doesn't exist as an endpoint. Gamma carries current `outcomePrices` and change-over-horizon fields on the market object, but the series itself lives only on the CLOB.

The practical order of operations, then: page Gamma once with the keyset endpoints to build a local table of markets and their token IDs, cache it (those IDs never change), then walk the batch history endpoint 20 tokens at a time at whatever fidelity your research actually needs — daily candles for a broad study, finer only where it matters. Where the remaining constraint is request volume across a large universe, budgets are counted per IP address, and our [proxies for crypto projects](/crypto-proxy.php) are dedicated IPv4 addresses, static for the full plan term, each subject to its own documented allowance. Verify scaling against your own workload rather than assuming it stays linear.
