API documentation
Public page. Auth for the API itself is an X-API-Key header (or ?key= on the WebSocket); keys are issued per consumer. Everything documented here is running.
Read API
| endpoint | returns |
|---|---|
GET /v1/wallet/{address}/score | tier, hit_rate, realized_pnl_usd, closed_positions, median_multiple, weights_version, computed_ts |
GET /v1/wallet/{address} | class, first_seen, funder, flags, tx_count |
GET /v1/token/{address} | status, labels[], age_seconds, deployer, initial/last mcap, liquidity, pools[] |
GET /v1/token/{address}/cluster | distinct_buyers, sum_quote_usd, buyers[], funding_cluster, window_start |
GET /v1/candles/{pool}?interval=1m&from=&to= | own candles — quote-asset stamped |
GET /v1/leaderboard?window=7d | ranked by realized PnL (closed, scam-excluded) |
GET /v1/signals?type=&token=&wallet=&limit= | signal history with full state snapshots |
GET /v1/health | ingest lag, stream lengths, gap counts, last reconciliation |
WS /v1/stream?key=… | live cluster signals AND the per-trade stream (DNS-only host — no bot challenge) |
Answers come from precomputed Redis state and target < 5 ms server-side. If an answer is not cached the response is {"status":"not_indexed"} — the API never falls through to the database on the read path, which is what keeps consumer traffic off the pipeline. /v1/signals is the one documented exception: signal history is the record, not a cached answer.
Wallet lists
These resolve the wallet_set predicate on both streams. Authenticated, and every call writes an audit row before the mutation is applied — a failed audit write is a refused mutation.
| endpoint | notes |
|---|---|
GET /v1/lists | every list with its member and blacklist counts |
GET /v1/lists/{name} | members and blacklist in full |
POST /v1/lists {name} | create (idempotent) |
DELETE /v1/lists/{name} | clear membership — the blacklist is PRESERVED |
POST /v1/lists/{name}/add {wallets:[…]} | add — 409 in full if any address is blacklisted |
POST /v1/lists/{name}/remove {wallets:[…]} | remove |
POST /v1/lists/{name}/blacklist {wallets:[…]} | remove AND permanently exclude |
DELETE /v1/lists/{name}/blacklist {wallets:[…]} | ADMIN-AUTHED — the only undo |
The blacklist is permanent. An add naming a blacklisted address is refused in full — nothing is added — with 409 and a reason, and the refusal is itself audited. Deleting a list keeps its blacklist, so recreating the list by the same name inherits the same exclusions. Un-blacklisting needs a separate admin credential and removes from the blacklist only; it does not add the address back.
Stream API
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, e.g. list:james_tracked |
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.