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/dex

Supported chains: Ethereum (ethereum), Base (base) and Arbitrum (arbitrum). BSC and Polygon will be added when their historical data is complete.

Get a key

  1. In the Dashboard, open Endpoints and click + Add Endpoint.
  2. Choose the Onchain Data API card (labelled Ethereum / Base / Arbitrum).
  3. Create it. You get the URL https://api.blockreq.com/v1/dex and 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/dex accepts only Onchain Data API keys, so create an Onchain Data API endpoint and switch your X-API-Key before 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.

PlanPrice per 30 daysCalls per 30 daysSustained / burst (requests per second)Availability
Free$0100,0001 / 1With your first Onchain Data API endpoint
Starter$191,000,00025 / 50Coming soon
Pro$7910,000,00050 / 100Coming soon
Business$249Unlimited100 / 200Coming 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_exceeded until 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

RequestCounted calls
Any GET /v1/dex/... request1
POST /v1/dex/prices1 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: 5

Limits 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.codeMeaningHeaders
rate_limitedOver the rate: the plan's rate with a key (Free: 1 request per second), the per-IP window without a keyRetry-After: 1 with a key
quota_exceededThe quota of the current 30-day period is used upRetry-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.

Statuserror.codeWhen
400invalid_requestA parameter or the body is invalid; the message says which
401invalid_keyThe key is unknown or revoked, or was sent somewhere other than X-API-Key / Authorization: Bearer
403forbiddenThe 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)
404not_foundThe chain, token or pool is not indexed, or no such route
429rate_limited, quota_exceededSee above
502upstream_failed, response_too_largeThe data service failed or answered too much; send again after a growing pause
503unavailableBriefly unavailable; try again shortly
504timeoutRan 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 pathQuery parameters
GET /v1/dex/{chain}/tokens/{address}none: token metadata, current USD price, 24h change and volume
GET /v1/dex/{chain}/tokens/{address}/priceat: Unix seconds for a historical price (default now)
GET /v1/dex/{chain}/tokens/{address}/poolslimit: 1–100 (default 20)
GET /v1/dex/{chain}/tokens/{address}/ohlcvinterval: 1m, 5m, 15m, 1h (default), 4h, 1d; from, to: Unix seconds; limit: 1–1000 (default 300)
GET /v1/dex/{chain}/tokens/{address}/holderslimit: 1–5000 (default 5000); available for tracked tokens
GET /v1/dex/{chain}/accounts/{address}/portfoliotokens: up to 50 comma-separated ERC-20 addresses to read balances for

Pools and trades

Method and pathQuery parameters
GET /v1/dex/{chain}/pools/topsort: volume (default) or liquidity; limit: 1–100 (default 50)
GET /v1/dex/{chain}/pools/newlimit: 1–100 (default 50); min_liquidity_usd (default 0)
GET /v1/dex/{chain}/pools/{id}none
GET /v1/dex/{chain}/pools/{id}/ohlcvas token OHLCV, plus currency: token (default) or usd; side: token0 (default) or token1
GET /v1/dex/{chain}/pools/{id}/tradeslimit: 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 path
websocat 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"}
codeWhen
invalid_requestMalformed message or parameter
not_foundThe token or pool is not indexed
pool_not_listedtrades: the pool is known but not listed
unsupported_chainThe chain is not served
subscription_limitThe connection would exceed the plan's subscriptions per connection
channel_not_in_planThe channel is not in your plan (Free has only prices)
interval_not_in_planinterval is below your plan's minimum
quota_exhaustedThe period's quota is used up; the server then closes the socket (code 1008)
rate_limitedToo many subscribe / unsubscribe / ping messages (on Free, more than one a second)
internalServer 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

ChannelparamsPush (op)data is the same object as
priceschain, addresses (1–50 tokens), interval, min_change_bps, heartbeatpricesdata[] of POST /v1/dex/prices (an array: the tokens that qualified)
tradeschain, pooltradean item of GET /v1/dex/{chain}/pools/{id}/trades
poolschainpoolGET /v1/dex/{chain}/pools/{id} (listed or not; see indexed)
candleschain, interval (1m … 1d), and either token or poolcandlean 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, trades and pools replay what you missed (pushes carry "replay": true), then continue live. candles never replays: unless nothing changed on the chain after since, it starts with a snapshot of the open candle (data is null when its bucket has no trade yet).

  • prices does not resume and takes no since: every new prices subscription starts with a push of all listed tokens (counted as usual), then pushes changes. Its seq is per subscription (a heartbeat push can carry an older one), not a resume point.

  • Beyond 5 minutes, pools starts with a snapshot of the newest 20 pools and candles with a snapshot of the open candle; pushes after its seq follow. trades gets a gap instead, 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}/trades from to_block to the last trade you stored (from_block, when not null, is the first block missed).

  • A gap can also come with reason: "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. On candles, a slow_consumer gap (both block fields null) 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 above from_block; the replacing trades follow, and changed prices and candles are pushed again.

  • warning messages 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:

MessageCounted calls
trade, pool, candle push1
prices push1 per 10 tokens it carries, rounded up (as POST /v1/dex/prices)
ack, error, pong, rollback, gap, snapshot, warning0
A push dropped for a slow consumer, or never written to the socket0

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.

PlanConnectionsSubscriptions per connectionChannelsMinimum prices / candles interval
Free15prices5 s
Starter225all1 s
Pro5100all1 s
Business20100all1 s
  • Connections are counted per account across all of our servers. Each token of a prices subscription counts as one subscription; any other subscription counts one.
  • subscribe, unsubscribe and ping share the plan's REST request rate (they are not counted against the quota).
  • At 90% of the quota you get warning quota_low; when it is used up, error quota_exhausted and 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.

See also