Onchain Data API
The Onchain Data API serves indexed DEX data over REST: token prices (current and historical), batch prices, liquidity pools, OHLCV candles, trades, holder rankings and a lightweight portfolio. It is a separate service from RPC: it has its own endpoint type, its own key and its own quota.
https://api.blockreq.com/v1/dexSupported chains: Ethereum (ethereum), Base (base) and Arbitrum (arbitrum). BSC and Polygon will be added when their historical data is complete.
Get a key
- In the Dashboard, open Endpoints and click + Add Endpoint.
- Choose the Onchain Data API card (labelled Ethereum / Base / Arbitrum).
- Create it. You get the URL
https://api.blockreq.com/v1/dexand a dedicated key that belongs to this endpoint only. It is never the shared key of your RPC endpoints.
The endpoint list shows the Onchain Data API endpoint with your account's usage in the current period, for example 120 / 20k.
Send the key in the X-API-Key header:
export BLOCKREQ_DATA_API_KEY="your Onchain Data API key"
curl -s "https://api.blockreq.com/v1/dex/ethereum/tokens/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/price" \
-H "X-API-Key: $BLOCKREQ_DATA_API_KEY"Authorization: Bearer <key> works too. A key anywhere else (a query parameter, a cookie, another header or a path segment) is refused with 401 invalid_key, and an invalid key never falls back to anonymous access.
Key scope
- An Onchain Data API key can call only
/v1/dex. It cannot call any RPC endpoint. - An RPC key is for RPC. During a transition period that ends at 2026-12-03 00:00:00 UTC, an RPC key can still call
/v1/dex; those calls count against the same account's Onchain Data API quota. After that time,/v1/dexaccepts only Onchain Data API keys, so create an Onchain Data API endpoint and switch yourX-API-Keybefore then.
Without a key
/v1/dex also answers anonymous requests, limited per IP address (1 request per second). Anonymous use is for trying the API; use a key for anything that runs continuously.
Plans and quota
The Onchain Data API quota is separate from RPC Requests: data API calls never use your RPC Requests, Request Packs or wallet balance, and RPC traffic never uses the data API quota.
| Plan | Price per 30 days | Calls per 30 days | Sustained / burst (requests per second) | Availability |
|---|---|---|---|---|
| Free | $0 | 100,000 | 1 / 1 | With your first Onchain Data API endpoint |
| Starter | $19 | 1,000,000 | 25 / 50 | Coming soon |
| Pro | $79 | 10,000,000 | 50 / 100 | Coming soon |
| Business | $249 | Unlimited | 100 / 200 | Coming soon |
- Free comes with your first Onchain Data API endpoint. An account without one has no data API plan. During the RPC-key transition (until 2026-12-03 00:00:00 UTC), an RPC key of such an account is still served at the Free limits.
- Paid plans are not on sale yet. When the quota of any plan is used up, calls answer
429 quota_exceededuntil the period ends; there is no overage billing. - A 30-day period starts with the first call of the period. The first call after it ends starts a new period from zero.
- Existing accounts: a Free period keeps the quota it started with until it ends. A period that started before 2026-11-04 00:00:00 UTC has 100,000 calls, one that started from then 10,000, one that started from 2026-11-06 00:00:00 UTC 20,000, and one that starts from 2026-11-08 00:00:00 UTC 100,000.
- Business has no period quota, only its rate limit.
- An account whose RPC usage is paused for running out of Requests can still use the data API. A suspended account cannot.
How calls are counted
| Request | Counted calls |
|---|---|
Any GET /v1/dex/... request | 1 |
POST /v1/dex/prices | 1 per 10 tokens in the body, rounded up (10 tokens = 1, 50 = 5) |
Every response to a request with a valid key carries the number of calls it counted. It is 0 on any answer that is not a success (4xx, 5xx, 429), which counts nothing. Anonymous requests and invalid keys get no such header.
X-BlockReq-Data-Api-Units: 5Limits and 429
When you exceed the rate, or the period's quota is used up, the API answers 429 Too Many Requests. The error.code in the body tells the two apart:
error.code | Meaning | Headers |
|---|---|---|
rate_limited | Over the rate: the plan's rate with a key (Free: 1 request per second), the per-IP window without a key | Retry-After: 1 with a key |
quota_exceeded | The quota of the current 30-day period is used up | Retry-After: seconds left in the period; X-BlockReq-Data-Api-Quota-Reset: period end, Unix seconds |
{
"error": {
"code": "rate_limited",
"message": "Rate limit reached for this API key. See blockreq.com for higher limits."
}
}{
"error": {
"code": "quota_exceeded",
"message": "Data API quota reached for this period; it resets at 2026-12-01T08:00:00Z. See blockreq.com for higher limits."
}
}The rate counts requests and the quota counts calls: a POST /v1/dex/prices with 50 tokens takes one request from the rate but 5 calls from the quota. The quota is marked used up shortly after it runs out, so a few calls past it may still succeed. Wait for Retry-After before sending again; for quota_exceeded, retrying before the reset time only gets 429 again.
Errors
Errors are JSON {"error": {"code": "...", "message": "..."}} with English messages. Branch on code, not on message.
| Status | error.code | When |
|---|---|---|
| 400 | invalid_request | A parameter or the body is invalid; the message says which |
| 401 | invalid_key | The key is unknown or revoked, or was sent somewhere other than X-API-Key / Authorization: Bearer |
| 403 | forbidden | The account is suspended; the key's Onchain Data API endpoints are expired or do not allow your IP; or an RPC key after the transition ends (2026-12-03) |
| 404 | not_found | The chain, token or pool is not indexed, or no such route |
| 429 | rate_limited, quota_exceeded | See above |
| 502 | upstream_failed, response_too_large | The data service failed or answered too much; send again after a growing pause |
| 503 | unavailable | Briefly unavailable; try again shortly |
| 504 | timeout | Ran out of time; narrow the range or try again |
The CoinGecko-compatible paths (/v1/dex/onchain/...) answer their own data errors in CoinGecko's format ({"errors": [{"status": "404", "title": "..."}]}); the key, rate and quota errors above keep the {"error": ...} format there too.
OpenAPI reference
Every route, parameter, response field and error is described in an OpenAPI 3.1 document:
- Interactive reference (Swagger UI): browse every route, then Authorize with your key and Try it out. The key stays in the page and is sent only to
api.blockreq.com. - OpenAPI JSON: import it into Postman, Insomnia or a client generator.
Routes
{chain} is ethereum, base or arbitrum. Addresses are 0x followed by 40 hex digits; a pool id is 0x followed by 40 (pool contract) or 64 (pool key) hex digits. Responses are JSON with a data field; errors are {"error": {"code": "...", "message": "..."}}.
Tokens
| Method and path | Query parameters |
|---|---|
GET /v1/dex/{chain}/tokens/{address} | none: token metadata, current USD price, 24h change and volume |
GET /v1/dex/{chain}/tokens/{address}/price | at: Unix seconds for a historical price (default now) |
GET /v1/dex/{chain}/tokens/{address}/pools | limit: 1–100 (default 20) |
GET /v1/dex/{chain}/tokens/{address}/ohlcv | interval: 1m, 5m, 15m, 1h (default), 4h, 1d; from, to: Unix seconds; limit: 1–1000 (default 300) |
GET /v1/dex/{chain}/tokens/{address}/holders | limit: 1–5000 (default 5000); available for tracked tokens |
GET /v1/dex/{chain}/accounts/{address}/portfolio | tokens: up to 50 comma-separated ERC-20 addresses to read balances for |
Pools and trades
| Method and path | Query parameters |
|---|---|
GET /v1/dex/{chain}/pools/top | sort: volume (default) or liquidity; limit: 1–100 (default 50) |
GET /v1/dex/{chain}/pools/new | limit: 1–100 (default 50); min_liquidity_usd (default 0) |
GET /v1/dex/{chain}/pools/{id} | none |
GET /v1/dex/{chain}/pools/{id}/ohlcv | as token OHLCV, plus currency: token (default) or usd; side: token0 (default) or token1 |
GET /v1/dex/{chain}/pools/{id}/trades | limit: 1–100 (default 50); cursor: the next_cursor of the previous page |
Batch prices
POST /v1/dex/prices takes 1 to 50 tokens, on any supported chains, in a JSON body of at most 64 KiB:
{ "tokens": [{ "chain": "ethereum", "address": "0x..." }] }CoinGecko-compatible paths
/v1/dex/onchain/... serves CoinGecko onchain (GeckoTerminal) compatible paths and response formats, for example /v1/dex/onchain/networks/eth/tokens/{address} and /v1/dex/onchain/simple/networks/base/token_price/{addresses}. Network ids are eth, base and arbitrum. Each request counts 1 call.
Examples
Current price of WETH on Ethereum:
curl -s "https://api.blockreq.com/v1/dex/ethereum/tokens/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/price" \
-H "X-API-Key: $BLOCKREQ_DATA_API_KEY"Token details of WETH on Base:
curl -s "https://api.blockreq.com/v1/dex/base/tokens/0x4200000000000000000000000000000000000006" \
-H "X-API-Key: $BLOCKREQ_DATA_API_KEY"Hourly candles of WETH on Arbitrum (last 24):
curl -s "https://api.blockreq.com/v1/dex/arbitrum/tokens/0x82aF49447D8a07e3bd95BD0d56f35241523fBab1/ohlcv?interval=1h&limit=24" \
-H "X-API-Key: $BLOCKREQ_DATA_API_KEY"Top Ethereum pools by liquidity:
curl -s "https://api.blockreq.com/v1/dex/ethereum/pools/top?sort=liquidity&limit=10" \
-H "X-API-Key: $BLOCKREQ_DATA_API_KEY"Latest trades of the Uniswap v3 WETH/USDC pool on Ethereum:
curl -s "https://api.blockreq.com/v1/dex/ethereum/pools/0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640/trades?limit=20" \
-H "X-API-Key: $BLOCKREQ_DATA_API_KEY"WETH on all three chains in one request (3 tokens, counted as 1 call); -i shows the X-BlockReq-Data-Api-Units header:
curl -si -X POST "https://api.blockreq.com/v1/dex/prices" \
-H "X-API-Key: $BLOCKREQ_DATA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"tokens":[
{"chain":"ethereum","address":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"},
{"chain":"base","address":"0x4200000000000000000000000000000000000006"},
{"chain":"arbitrum","address":"0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"}
]}'A price response looks like:
{
"data": {
"chain": "base",
"address": "0x4200000000000000000000000000000000000006",
"at": 1793735964,
"price_usd": "2289.33461727787809",
"price_time": 1793735961,
"source": "current",
"low_liquidity": false,
"stale": false
}
}WebSocket subscriptions
Instead of polling, open one WebSocket and subscribe to live prices, trades, new pools and candles. Every push carries the same object the matching REST route returns, so one parser serves both. The full protocol is an AsyncAPI 3.1 document: AsyncAPI YAML (its payloads reference the schemas of the OpenAPI JSON).
Connect
Use the same Onchain Data API key as for REST. Send it in a header on the upgrade request, or, where headers cannot be set (browsers), in the path:
wss://api.blockreq.com/v1/dex/ws # key in X-API-Key or Authorization: Bearer
wss://api.blockreq.com/v1/dex/ws/{key} # key in the pathwebsocat wss://api.blockreq.com/v1/dex/ws -H "X-API-Key: $BLOCKREQ_DATA_API_KEY"
websocat "wss://api.blockreq.com/v1/dex/ws/$BLOCKREQ_DATA_API_KEY"A key in a query parameter is refused (401), as on REST. Connecting beyond your plan's connection limit is refused with 429. Anonymous WebSocket access is not available.
One socket carries many subscriptions. Send subscribe; the server answers ack (or error) with the id you chose:
{"op":"subscribe","id":"1","channel":"prices","params":{"chain":"base","addresses":["0x4200000000000000000000000000000000000006","0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"],"interval":5,"min_change_bps":10}}{"op":"ack","id":"1","subscription":"s1","seq":28311551,"interval":5,"min_change_bps":10,"heartbeat":0}Every push of that subscription carries "subscription":"s1". For prices, the first push follows the ack at once and carries every listed token. A prices subscription is never resumed (see below), so do not use its seq as a resume point. To stop it:
{"op":"unsubscribe","id":"2","subscription":"s1"}{"op":"ack","id":"2","subscription":"s1"}A rejected request gets an error with a machine-readable code (branch on it, not on message):
{"op":"error","id":"3","code":"channel_not_in_plan","message":"channel trades is not in this plan"}code | When |
|---|---|
invalid_request | Malformed message or parameter |
not_found | The token or pool is not indexed |
pool_not_listed | trades: the pool is known but not listed |
unsupported_chain | The chain is not served |
subscription_limit | The connection would exceed the plan's subscriptions per connection |
channel_not_in_plan | The channel is not in your plan (Free has only prices) |
interval_not_in_plan | interval is below your plan's minimum |
quota_exhausted | The period's quota is used up; the server then closes the socket (code 1008) |
rate_limited | Too many subscribe / unsubscribe / ping messages (on Free, more than one a second) |
internal | Server error; subscribe again after a pause |
On Free, send at most one subscribe / unsubscribe / ping a second; a faster one gets rate_limited. The server sends WebSocket pings every 30 seconds. Clients that cannot see them may send {"op":"ping","id":"9"} and get {"op":"pong","id":"9","time":...}. A connection with no subscription is closed after 60 seconds.
Channels
| Channel | params | Push (op) | data is the same object as |
|---|---|---|---|
prices | chain, addresses (1–50 tokens), interval, min_change_bps, heartbeat | prices | data[] of POST /v1/dex/prices (an array: the tokens that qualified) |
trades | chain, pool | trade | an item of GET /v1/dex/{chain}/pools/{id}/trades |
pools | chain | pool | GET /v1/dex/{chain}/pools/{id} (listed or not; see indexed) |
candles | chain, interval (1m … 1d), and either token or pool | candle | an item of GET /v1/dex/{chain}/tokens/{address}/ohlcv or GET /v1/dex/{chain}/pools/{id}/ohlcv |
Every push has subscription, chain and seq around data.
prices is never pushed per trade. Every interval seconds (1–3600; default and minimum set by your plan) the server checks each listed token and sends one push with the tokens whose price moved by at least min_change_bps (0–1000, default 10 = 0.1%) from the value last pushed to you, whose new price has a stale flag other than the one last pushed, or, if heartbeat (0 = off, or 60–86400 seconds) is set, that were not pushed for that long. When no token qualifies, nothing is sent. The first push after subscribe carries every listed token. Comparing with the last pushed value means a slow drift is pushed once it adds up to the threshold. A token that turns stale only because time passed (no trade for 7 days, so no new price) is not pushed for that, and a heartbeat push resends the last price unchanged, stale included: check whether price_time is more than 7 days old yourself, or ask REST.
{"op":"prices","subscription":"s1","chain":"base","seq":28311551,"data":[{"chain":"base","address":"0x4200000000000000000000000000000000000006","price_usd":"2289.33461727787809","price_block":37901234,"price_time":"2026-11-06T09:16:16.582Z","reference_liquidity_usd":"1000000","low_liquidity":false,"stale":false}]}trades pushes every trade of one pool; the envelope also names the pool. Amounts are raw token units, as in REST:
{"op":"subscribe","id":"4","channel":"trades","params":{"chain":"ethereum","pool":"0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640"}}{"op":"trade","subscription":"s2","chain":"ethereum","seq":9120334,"pool":"0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640","data":{"block_number":23712345,"log_index":112,"time":"2026-11-06T09:18:16.581Z","tx_hash":"0x…","trader":"0x…","amount0":"-1520000000","amount1":"663412000000000000","price":"2291.18","amount_usd":"1520.00","price0_usd":"1","price1_usd":"2291.18"}}pools pushes each newly registered pool of a chain ({"channel":"pools","params":{"chain":"base"}}), listed or not (indexed tells which; blocked pools are left out), with data as GET /v1/dex/{chain}/pools/{id} returns it. Unlike GET /pools/new, which lists indexed pools only, a new pool is almost never indexed yet when it is created (screening runs every few minutes).
candles pushes the open candle when it changes (at most once per your plan's minimum interval) and always the final one when it closes; closed tells which:
{"op":"subscribe","id":"5","channel":"candles","params":{"chain":"arbitrum","token":"0x82aF49447D8a07e3bd95BD0d56f35241523fBab1","interval":"1m"}}{"op":"candle","subscription":"s3","chain":"arbitrum","seq":31188201,"interval":"1m","closed":false,"data":{"time":1793735940,"open":"2290.10","high":"2291.40","low":"2289.95","close":"2291.02","volume_usd":"48211.77","trades":37}}Trades by token are not offered (a token such as WETH trades almost every block); use prices or candles for a token and trades for pools. The CoinGecko-compatible paths have no WebSocket version.
Resume, gaps and reorganisations
For trades, pools and candles, seq numbers changes per chain: it is strictly increasing along a subscription (not contiguous) and the same on every connection, so keep the last seq you processed for each chain. After a disconnect, subscribe again with since:
{"op":"subscribe","id":"6","channel":"trades","params":{"chain":"ethereum","pool":"0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640"},"since":9120334}-
Within 5 minutes,
tradesandpoolsreplay what you missed (pushes carry"replay": true), then continue live.candlesnever replays: unless nothing changed on the chain aftersince, it starts with asnapshotof the open candle (dataisnullwhen its bucket has no trade yet). -
pricesdoes not resume and takes nosince: every newpricessubscription starts with a push of all listed tokens (counted as usual), then pushes changes. Itsseqis per subscription (a heartbeat push can carry an older one), not a resume point. -
Beyond 5 minutes,
poolsstarts with asnapshotof the newest 20 pools andcandleswith asnapshotof the open candle; pushes after itsseqfollow.tradesgets agapinstead, then live pushes:{"op":"gap","subscription":"s1","chain":"base","from_seq":2,"to_seq":66060287,"from_block":null,"to_block":30000062,"reason":"replay_window"}Page back through
GET /v1/dex/{chain}/pools/{id}/tradesfromto_blockto the last trade you stored (from_block, when notnull, is the first block missed). -
A
gapcan also come withreason: "slow_consumer"(your client read too slowly and trade pushes were dropped; the socket is closed if this lasts 30 seconds),"indexer_lag"(our indexer is catching up),"stream_lag"(our stream service fell behind) or"read_failed"(the server could not read the missed changes). Fill it the same way. Oncandles, aslow_consumergap (both block fieldsnull) means closed candles were dropped: re-read them with REST.../ohlcv. -
Pushes are sent before finality. On a reorganisation you get
{"op":"rollback","chain":"base","seq":…,"from_block":37901230}: drop every trade of that chain at or abovefrom_block; the replacing trades follow, and changed prices and candles are pushed again. -
warningmessages are advisory:slow_consumer,quota_low(90% of the quota used),closing,indexer_lag.
JavaScript (Node 22+, browsers: the global WebSocket; browsers must put the key in the path):
const key = process.env.BLOCKREQ_DATA_API_KEY;
const lastSeq = {}; // chain -> last processed seq
const pool = "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640";
let trades = []; // your store
function connect() {
const ws = new WebSocket(`wss://api.blockreq.com/v1/dex/ws/${key}`);
ws.onopen = () => {
const since = lastSeq.ethereum;
ws.send(JSON.stringify({ op: "subscribe", id: "t1", channel: "trades",
params: { chain: "ethereum", pool }, ...(since !== undefined && { since }) }));
};
ws.onmessage = ({ data }) => {
const m = JSON.parse(data);
switch (m.op) {
case "trade":
trades.push(m.data);
lastSeq[m.chain] = m.seq;
break;
case "rollback":
trades = trades.filter((t) => t.block_number < m.from_block);
lastSeq[m.chain] = m.seq;
break;
case "gap":
console.warn(`gap ${m.reason}: read trades up to block ${m.to_block} with REST`);
lastSeq[m.chain] = m.to_seq;
break;
case "error":
console.error(m.code, m.message);
break;
}
};
ws.onclose = () => setTimeout(connect, 1000 + Math.random() * 2000);
}
connect();Python (pip install websockets):
import asyncio, json, os, random
import websockets
KEY = os.environ["BLOCKREQ_DATA_API_KEY"]
TOKENS = ["0x4200000000000000000000000000000000000006"]
prices = {} # address -> price_usd
async def run():
while True:
try:
async with websockets.connect(
"wss://api.blockreq.com/v1/dex/ws",
additional_headers={"X-API-Key": KEY},
) as ws:
sub = {"op": "subscribe", "id": "p1", "channel": "prices",
"params": {"chain": "base", "addresses": TOKENS, "interval": 5}}
# prices never resume: each new subscription first pushes every token
await ws.send(json.dumps(sub))
async for raw in ws:
m = json.loads(raw)
if m["op"] == "prices":
for p in m["data"]:
prices[p["address"]] = p["price_usd"]
elif m["op"] == "error":
print("error", m["code"], m["message"])
except (websockets.ConnectionClosed, OSError):
await asyncio.sleep(1 + random.random() * 2)
asyncio.run(run())Billing and limits
Pushes use the same Onchain Data API quota as REST calls:
| Message | Counted calls |
|---|---|
trade, pool, candle push | 1 |
prices push | 1 per 10 tokens it carries, rounded up (as POST /v1/dex/prices) |
ack, error, pong, rollback, gap, snapshot, warning | 0 |
| A push dropped for a slow consumer, or never written to the socket | 0 |
The first prices push after subscribe carries every listed token and is counted like any other.
A subscription never costs more than polling the same thing. Polling POST /v1/dex/prices for n tokens every interval seconds counts ceil(n/10) per poll; the prices subscription sends at most one push per interval, counted ceil(tokens that qualified/10) ≤ ceil(n/10), and nothing when no token moved past the threshold. For example, 50 tokens every second is 432,000 calls a day by polling and at most that many by subscription; with the default 0.1% threshold, a token such as WETH typically moves past it a few hundred to about a thousand times a day. To spend less, raise interval or min_change_bps. trades of a busy pool is the expensive channel (a top Base pool trades several times a second); use candles or prices if you do not need every trade.
| Plan | Connections | Subscriptions per connection | Channels | Minimum prices / candles interval |
|---|---|---|---|---|
| Free | 1 | 5 | prices | 5 s |
| Starter | 2 | 25 | all | 1 s |
| Pro | 5 | 100 | all | 1 s |
| Business | 20 | 100 | all | 1 s |
- Connections are counted per account across all of our servers. Each token of a
pricessubscription counts as one subscription; any other subscription counts one. subscribe,unsubscribeandpingshare the plan's REST request rate (they are not counted against the quota).- At 90% of the quota you get
warningquota_low; when it is used up,errorquota_exhaustedand the socket closes.
Usage, statistics and logs
In the Dashboard, Statistics and Logs show Onchain Data API traffic next to RPC traffic; filter by service (All / RPC / Onchain Data API). Data API calls are grouped by route, for example GET /v1/dex/{chain}/tokens/{address}/price or POST /v1/dex/prices, and each log row shows the calls it counted.