API documentation
Read-only access to the RH Intelligence Indexer — Robinhood Chain, chain id 4663. One credential per consumer. Everything documented on this page was called against the live API before it was written down.
Connect with Claude (MCP)
The fastest way in. An MCP client gets the tools below with no code.
claude mcp add --transport http rh-indexer \ https://int.nexus-aio.com/mcp/<YOUR-KEY>/
Claude Desktop — claude_desktop_config.json:
{
"mcpServers": {
"rh-indexer": {
"type": "http",
"url": "https://int.nexus-aio.com/mcp/<YOUR-KEY>/"
}
}
}Transport is MCP over Streamable HTTP, stateless — POST only. Your key is in the URL. That is a deliberate trade: an MCP client cannot set an arbitrary header on a connection, so the credential travels in the path and is lifted into the header the API reads. Treat the whole URL as the secret — it does not belong in a shared config file, a screenshot or a bug report. For direct REST the key goes in a header instead; see Authentication.
| tool | answers | scope |
|---|---|---|
rh_get_health | How far the index has reached: chain head, last indexed block, lag, per-collection counts. | health |
rh_get_recent_deploys | What launched recently, newest first, with deployer and explorer links. | signals |
rh_get_token_info | One token in full: symbol, status, age, deployer, labels, pools, signals, bytecode siblings. | token |
rh_get_wallet_activity | One wallet: score record, positions, who funded it, signals naming it. | wallet |
rh_get_new_pools | Recently created pools chain-wide. Not available on this deployment — see below. | — |
rh_get_swaps | Individual swaps by token or wallet. Not available on this deployment — see below. | — |
rh_get_new_pools and rh_get_swaps return an explicit unavailable error on this deployment rather than an empty page. They read the indexer's database directly, which would bypass the scope system entirely, so that path is switched off here. An empty list would have been a lie; the error is the truth.
Reading rules for this chain
- The index is forward-only from block 58347192 (first seen 2026-09-09 06:57 UTC). Anything earlier does not exist here, so an empty result never proves absence on-chain — it may mean the activity predates coverage.
rh_get_healthandGET /v1/healthreport the floor so you can check rather than assume. - Dollar figures are price-feed floats and say so. There is no USDC on this chain — the stablecoin is USDG and the wrapped native is WETH — so USD values are computed upstream through a feed and arrive as floats. Money fields carry
usd_exact: false. They are good for ranking and for judgement; they are not settlement-grade. - Unpriced is a real state, not an error. This node exposes no trace API, so value returned by an internal call — a sale into a native bonding curve — is not observable. Those appear as
direction: "unpriced"withusd: null, and raw amounts are carried as integer strings with an explicit decimals field beside them. Absent is never rendered as zero anywhere in this API;nullmeans unknown and0means measured zero. - A market cap can be enormous and still be arithmetically correct. One token declares
decimals: 0with a supply of 1e76, so its market cap really is 2.08e45. Sanity-check before reasoning about one.
Authentication
One key, two places it can go, and only one of them works per surface:
REST X-API-Key: <YOUR-KEY> header, on every /v1/ request MCP /mcp/<YOUR-KEY>/ in the path — MCP only WS ?key=<YOUR-KEY> query param — WebSocket only
?key= is valid on the WebSocket and invalid on HTTP routes. It exists because a browser cannot set a header on a WebSocket handshake. On an HTTP route it is half-honoured in the worst possible way — it satisfies the rate limiter and the request log, and is then refused 401 by the authorizer — so a /v1/ call must use the header.
Keep it secret. The key is shown once at issue and only a SHA-256 digest is stored, so a lost key cannot be recovered — it is replaced. If you think it has leaked, say so and it will be revoked; revocation takes effect within 30 seconds and needs no restart of anything.
Scopes
Every route requires one. A consumer key normally carries token wallet candles signals leaderboard health — all read-only. Stream families are their own scopes: trades deploys clusters rugs. early_buyers is separate and is not granted by default: it joins a token's entry order to a per-wallet track record, which is the most valuable derived product here, so it is a decision rather than a default.
401 and 403 mean different things. 401 is "this key is unknown, or revoked". 403 is "this key is fine, and it does not carry the scope this route needs" — and it names which, so you never have to guess:
$ curl -s -H "X-API-Key: <YOUR-KEY>" \
"https://int.nexus-aio.com/v1/token/0x39dbed3a2bd333467115de45665cc57f813c4571/early-buyers?n=3"
{
"error": "missing scope",
"required_scope": "early_buyers",
"key": "<your-key-id>",
"scopes": ["candles","health","leaderboard","signals","token","wallet"]
}Through MCP the same refusal arrives as tool content rather than a crash: {"error":"auth_error","upstream_status":403,"upstream":{…}} carrying the identical required_scope. A suspended or over-limit key fails inside the tool exactly as it would over REST — the MCP service holds no credential of its own and grants nothing.
Rate limits
- Fixed 60-second window, counted per key — not per IP, and not sliding.
- rate_per_min is per key. A consumer key is normally 120/min (≈ 2 requests a second); keys that set nothing get 300.
- Every response carries the headers, not just refusals, so you can watch yourself approach the ceiling instead of discovering it:
X-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset. - Over the ceiling:
429withRetry-Afterand a body naminglimit_per_minandretry_after_seconds. Nothing is queued — retry after it. - WebSocket connections are capped separately by
ws_max_connections, default 3 concurrent sockets per key. The slot is released when the socket closes, including on a crash. - No HTTP concurrency cap is enforced today. Please do not open large fan-outs — the rate limit is the only thing between an enthusiastic client and a shared box.
Read API
| method | path | scope | returns |
|---|---|---|---|
GET | /v1/token/{address} | token | status, labels[], age_seconds, deployer, initial/last mcap, liquidity, pools[] |
GET | /v1/token/{address}/detail | token | the token plus its pools, liquidity (with the instant it was priced), bytecode siblings, signals, deployer funding |
GET | /v1/token/{address}/cluster | token | distinct_buyers, sum_quote_usd, buyers[], funding_cluster, window_start |
GET | /v1/token/{address}/early-buyers?n= | early_buyers | first N distinct buyers in chain order, entry_mcap_usd each, coverage{}. NOT on consumer keys — see the 403 example |
GET | /v1/wallet/{address}/score | wallet | tier, hit_rate, realized_pnl_usd, closed_positions, median_multiple, weights_version, scored |
GET | /v1/wallet/{address}/detail | wallet | class and its evidence, flags, tx_count, funding edges both ways, positions (capped at 100), score history, signals |
GET | /v1/candles?pool=|token=&interval=&from=&to=&limit= | candles | OHLC + volume, quote-asset stamped. interval: 1m 5m 15m 1h 4h 1d. limit ≤ 1000, default 200 |
GET | /v1/leaderboard?window=&limit= | leaderboard | ranked by realized PnL, closed positions only, scam-excluded. window: 7d | all — those two are built, nothing else |
GET | /v1/signals?type=&token=&wallet=&limit= | signals | signal history with full state snapshots. limit ≤ 500, default 60 |
GET | /v1/health | health | ingest lag, stream lengths, collection counts, gap counts, last reconciliation |
There is no cursor pagination. Every list endpoint takes limit and nothing else — no cursor, no offset, no next_cursor in any response. To walk further back, narrow with the filters the endpoint offers (from/to on candles, token/wallet/type on signals) rather than paging. Said explicitly because the opposite is the common assumption.
Answers come from precomputed Redis state and target < 5 ms server-side. If an answer is not cached the response is {"status":"not_indexed"} with HTTP 200 — the API never falls through to the database on the read path, which is what keeps consumer traffic off the pipeline. not_indexed is an answer, not a failure: it means this layer has never described that id. Retrying it in a loop will not change it. /v1/signals is the one documented exception — signal history is the record, not a cached answer.
Example
$ curl -s -H "X-API-Key: <YOUR-KEY>" \
"https://int.nexus-aio.com/v1/wallet/0x32096290ea8b1d92e7ee018c96eea3c741db2dd6/score"
{"address":"0x32096290ea8b1d92e7ee018c96eea3c741db2dd6","tier":0.0,"hit_rate":0.333333,
"realized_pnl_usd":-11.417941,"closed_positions":6.0,"median_multiple":1.068386,
"weights_version":1.0,"computed_ts":1789588669.0,"scored":true,"server_us":593}scored travels beside tier deliberately. tier: null, scored: false means this wallet has never been scored; tier: 0, scored: true means scored and in the lowest band. They are different statements and this API does not conflate them.
$ curl -s -H "X-API-Key: <YOUR-KEY>" \
"https://int.nexus-aio.com/v1/leaderboard?window=7d&limit=2"
{"window":"7d","entries":[
{"wallet":"0xb35084b05285fcea9b7718f689980f28d1054b3e","realized_pnl_usd":388049.3038822},
{"wallet":"0x62cc49d34520f821f851f7f7073e8d6e4184675c","realized_pnl_usd":349703.14523355156}],
"server_us":492}window accepts 7d and all. Those are the two that are built; asking for a window that is not built answers not_indexed rather than an empty list, because "nobody qualifies" and "that window was never computed" are different facts.
$ curl -s -H "X-API-Key: <YOUR-KEY>" \
"https://int.nexus-aio.com/v1/candles?token=0x39dbed3a2bd333467115de45665cc57f813c4571&interval=1h&limit=2"
{"pool":"0x1cfe977181c8980d80834caa8c5b326ac897a34b","interval":"1h",
"quote_asset":"0x5fc5360d0400a0fd4f2af552add042d716f1d168",
"candles":[{"_id":"0x1cfe…:1h:1789592400","o":…,"h":…,"l":…,"c":0.5617444917001556,…}],
"count":2,"server_us":…}Pass pool for one pool, or token to have the busiest pool resolved for you — the answer stamps which pool and which quote_asset it used, so a series is never ambiguous about what it is priced in. from and to are unix seconds.
Stream API
wss://int.nexus-aio.com/v1/stream?key=<YOUR-KEY> # or, where your client can set headers on the handshake: wss://int.nexus-aio.com/v1/stream X-API-Key: <YOUR-KEY>
This is the one surface where ?key= is valid, because a browser cannot set a header on a WebSocket handshake. It is not valid on /v1/ HTTP routes — see Authentication. Either form works here; the header wins if both are present.
Up to ws_max_connections sockets per key, default 3. The slot is claimed before the upgrade — a connection refused at the handshake is one the server never has to hold — and released when the socket closes, including on a crash, so a slot cannot leak. Over the cap the handshake is refused rather than the socket being opened in order to say it should not be open.
The subscribe ack reports wallet_sets — how many wallets each named list you filtered on actually holds. null there means the list does not exist, so nothing will ever match, which is otherwise indistinguishable from a quiet chain.
Two event families on one socket. A subscribe frame selects one by its event_types; mixing them is rejected at subscribe time, because half the cluster fields have no value to compare against for a single trade. Open two subscriptions if you want both.
| family | event types | payload |
|---|---|---|
| cluster | cluster_buy fresh_cluster serial_deployer_launch rug_warning insider_distribution narrative_cluster token_deployed first_liquidity | aggregate over a 30-minute window |
| trade | buy sell swap | one swap, every classified trade on the chain |
Subscribe with named fields and operators only. There are no expressions and nothing from client input is evaluated beyond these schemas. Predicates are checked server-side, one pass per event. An unknown field is rejected at subscribe time rather than ignored — a filter you mistyped must fail loudly, not silently firehose you. A cluster-shaped field on a trade subscription is rejected with an error that names it and says which schema it belongs to.
Trade frame
{
"type": "buy",
"id": "1789995244123-0", // Redis stream id — monotonic, and the replay cursor
"wallet": "0x...",
"tx": "0x...",
"block": 48123456,
"token_in": { "address": "0x...", "symbol": "MOON",
"amount": "1842300.550000000000000000", "mcap_usd": 145200 },
"token_out": { "address": "0x...", "symbol": "USDG",
"amount": "127.500000", "mcap_usd": null },
"value_usd": 127.50,
"priced": true,
"venue": "v4",
"quote_asset": "USDG",
"ts_block": 1789995243, // the chain's clock
"ts_detected": 1789995244 // ours, stamped at emission — the latency receipt
}token_inis what the wallet received;token_outis what it sent. A sell is symmetric — the registry leg arrives intoken_in.mcap_usdlives inside each token object and isnullon the registry-side asset. A market cap for USDG or ETH is noise; the null marks the money side.amountis a lossless fixed-point decimal string with exactly the token's decimals — never rounded, never trimmed. Multiply by 10decimals and you have the raw integer back.mcap_usd= pool price × cached supply, both from the same observation: the post-tradesqrtPriceX96for V3/V4 (>99% of volume here), the trade's executed price for V2. Supply is cached per token and invalidated on any mint or burn. 98.1% of priced frames carry one; the rest arenullrather than guessed.- A swap where neither leg is a registry asset streams as
type: "swap"withpriced: falseand null value and market caps. Two refusals: we will not route it through an assumed path to a stablecoin and print the product as a dollar figure, and we will not call it a buy or a sell — buy and sell are defined relative to a registry asset and there isn't one. Both legs are described in full (token_inreceived,token_outsent) so you can price it and orient it yourself.["buy","sell"]is therefore not “all trades” — ask for"swap"too, or send an emptyevent_types. ~9.5% of frames.
Trade predicate schema
| field | type | operators | notes |
|---|---|---|---|
wallet_set | string | in / == / != | named server-side list |
min_value_usd | number | >= <= > < == != | the trade’s USD value; an unpriced trade never satisfies it |
wallet_class | string or list | == != in | trader | bot | router | contract_wallet | bridge | contract | unknown |
token_age_seconds | number | >= <= > < == != | since first sighting |
mcap_usd | number | >= <= > < == != | evaluated on the NON-registry side — the subject of the trade |
venue | string | == != in | v2 | v3 | v4 |
quote_asset | string | == != in | symbol, e.g. USDG / WETH |
priced | boolean | == != | false = neither leg is a registry asset; value and mcap are null |
min_wallet_tier | number | >= <= > < == != | ONLY with "enrich": ["wallet"] — a trade has one wallet |
Side selection happens via event_types, not via a filter. A field the server cannot supply does not match — an unpriced trade never satisfies min_value_usd >= 50, an unknown market cap never satisfies a ceiling, an unclassified wallet is not a trader.
Subscribe frame — trades
{
"action": "subscribe",
"id": "my-bot-1",
"event_types": ["buy", "sell", "swap"],
"from_id": "1789995243000-7", // optional: resume strictly AFTER this id
"enrich": ["wallet", "token"], // optional, per subscription
"filters": [
{ "field": "wallet_set", "op": "in", "value": "list:test" },
{ "field": "min_value_usd", "op": ">=", "value": 50 }
]
}The ack reports the size of every named list: "wallet_sets":{"test":4}. A null there means the list does not exist and the filter will match nothing — without which “I subscribed and received nothing” is indistinguishable from a quiet chain.
Ordering
- Delivery order is chain order at the head. One writer, each batch sorted by (block, log_index) before publishing, monotonic stream ids.
idis monotonic and is the replay cursor — the Redis stream entry id, so there is only one ordering. Sort by it to recover the exact sequence.- There is no
log_indexin the payload. It would be a second ordering key that agrees with the first until the day it does not. - Honest exception: a swap classified late (a backfill, or a registry addition making an old row priceable) is published when it is classified, so its
idis later than its block.blockandts_blockalways tell the truth about when it happened.
Replay on reconnect
from_id resumes strictly after the id you give — the field means “the last one I saw”. You receive the gap once each, in order, then the live feed. If the cursor has aged out:
{"replay": "truncated", "earliest": "1789980000000-0",
"note": "the requested from_id is older than the stream's retention; replay begins
at `earliest` and the gap before it is not recoverable from this stream"}Replay then begins at earliest. We do not silently start from the head: told “truncated, earliest is X” you can fill the hole from the Read API, whereas a silent reposition leaves a gap you cannot see. The window is 400,000 frames — measured at 611 bytes each, ≈ 244 MB, ≈ 2 h 51 m at the sustained 39 trades/s. It is a count, not a duration, so it shrinks under a burst: working a backlog the emitter has sustained 857 frames/s, which is the same 400,000 frames in under eight minutes.
Opt-in enrichment
The base frame stays lean. "enrich": ["wallet", "token"] appends, per subscription. Enrichment only ever adds keys — the base frame's types never change, whatever you ask for, which is why the wallet block is wallet_info and not wallet. wallet is the plain address string on every frame we send.
"wallet_info": { "address", "class", "tier", "hit_rate", "nonce", "funder",
"funder_tier", "is_first_buy", "in_lists", "weights_version" },
"token": { "address", "age_seconds", "labels", "deployer", "deployer_flags",
"deploy_to_liquidity_seconds", "bytecode_match", "liquidity_usd" },
// on a SELL, with wallet enrichment, one more block:
"position": { "pct_sold": 25.0, "realized_pnl_usd_so_far": 5.5 }pct_soldis a percentage, 0–100, capped at 100 — a wallet can sell tokens it acquired outside the index, and that is not 300% of a position.- Anything not knowable is
null, never0and never"". is_first_buycomes from position state server-side.
Cluster predicate schema
| field | type | operators | notes |
|---|---|---|---|
wallet_set | string | in / == / != | named server-side list |
min_wallet_tier | number | >= <= > < == != | satisfied by the BEST wallet in the cluster |
min_quote_usd_each | number | >= <= > < == != | satisfied by the SMALLEST buy — every buy must clear it |
sum_quote_usd | number | >= <= > < == != | total quote value across the window, counted wallets only |
distinct_buyers | number | >= <= > < == != | distinct wallets, not trades — bots and routers excluded |
token_age_seconds | number | >= <= > < == != | since first sighting |
mcap_usd | number | >= <= > < == != | derived from pool price × supply |
funding_cluster | boolean | == != | two or more buyers share a funding parent |
wallet_tx_count_max | number | >= <= > < == != | satisfied by the BUSIEST wallet; from the on-chain nonce |
deployer_flag | boolean | == != | |
venue | string | == != in | v2 | v3 | v4 |
quote_asset | string | == != in | registry address |
distinct_buyers counts people, not machines. Wallets classed bot or router are removed server-side before any predicate is evaluated, and the exclusion applies to every derived figure on the payload — sum_quote_usd, the buyers array, window_start — because 3 buyers printed next to five buyer rows is worse than not filtering at all. Two fields make the rule visible: counted_classes and excluded_buyers. unknown counts: the classifier will not judge a wallet below a minimum transaction count, so “unknown” is overwhelmingly “too new to judge”, and fresh wallets are the population worth finding. Signals logged before this rule carry counted_classes: ["all"] rather than being retro-labelled.
Subscribe frame — clusters
{
"action": "subscribe",
"id": "james-cluster-1",
"event_types": ["cluster_buy", "fresh_cluster", "serial_deployer_launch",
"rug_warning", "insider_distribution", "narrative_cluster",
"token_deployed", "first_liquidity"],
"filters": [
{ "field": "wallet_set", "op": "in", "value": "list:james_tracked" },
{ "field": "min_wallet_tier", "op": ">=", "value": 2 },
{ "field": "min_quote_usd_each", "op": ">=", "value": 50 },
{ "field": "distinct_buyers", "op": ">=", "value": 3 },
{ "field": "token_age_seconds", "op": "<=", "value": 21600 },
{ "field": "mcap_usd", "op": "<=", "value": 500000 },
{ "field": "funding_cluster", "op": "==", "value": false },
{ "field": "wallet_tx_count_max", "op": "<=", "value": 10 }
]
}Delivery guarantees
- Missed-while-disconnected is replayed, within the window (see above). This supersedes the v1 note that replay was a v2 feature: a missed trade cannot be re-derived from a cached answer the way a wallet score can.
- Exactly-once within the window, by construction —
from_idis exclusive, so a reconnect receives the gap once and no duplicates byid. Outside the window you are toldtruncated. idis the cursor and the ordering key.signal_id(cluster events only) is content-derived and is for dedup, not ordering.- Every emitted signal is written to the log before delivery is attempted. A failed delivery still leaves the log entry, with
delivered_to: []. - Every emitted trade is already durable in
raw_swapsbefore the frame exists; the frame is a republication. A dropped frame costs a live delivery, never a row. That is why the trade stream does not log-first and the signal stream does — they are different kinds of thing.