The trade tape, filtered your way, sold honestly.
Live decoded prints from robinhood, bnb and base — the same tape the traktor dashboard watches — over a cursor-paginated REST history and a push websocket. What you are paying for is not just the decoding: it is a contract about what the numbers mean, kept by software and stated on every row.
The first ten minutes
Keys are hand-minted while the customer count is small — you were given one that looks
like tk_live_…. It is shown exactly once; we store only a keyed hash.
Minute one: the last prints over $1,000
# bare numbers are exact dollars; suffixes work: 1k, 1.5m
curl -s "$BASE/v1/flows?minUsd=1k&limit=5" \
-H "Authorization: Bearer $TRAKTOR_KEY"
Every response is enveloped — { data, meta } — and every row carries its
honesty fields: lagSeconds (may be negative), origin,
blockTimeSource, flags[].
Minute three: the live socket
const ws = new WebSocket("ws://localhost:8790/v1/stream", ["traktor.v1"]);
ws.onmessage = ({ data }) => {
const f = JSON.parse(data);
if (f.op === "hello")
ws.send(JSON.stringify({ op: "auth", id: "1", key: KEY }));
else if (f.op === "ack" && f.re === "1")
ws.send(JSON.stringify({ op: "subscribe", id: "2", sub: "whales",
filter: { side: "buy", minUsd: "5k" } }));
else if (f.op === "ack" && f.re === "2")
console.log("backfill anchor:", f.result.seq); // ← §08
else if (f.op === "events")
for (const e of f.d) console.log(e.side, e.usdAmount, e.chain);
};
Minute ten belongs to the handoff — how you stitch history and live into one gapless stream. Read it before you build; it is the one integration detail everyone gets wrong on the first try.
The honesty contract
Most feeds fabricate the figures this one leaves null, and hide the lateness this one labels. These clauses are contractual: the software conforms to them, and a claim you can catch us breaking is a bug report we want.
Events are delivered in ascending seq — the order rows became durable in our
database. Not transaction order, not block order, not wall-clock order. On this tape
chain time cannot order anything: ~40% of rows disagree with arrival order, EVM block
time is interpolated (and overshoots — negative lag is real and unclamped), and sixteen rows
have shared one millisecond.
Lateness is a field, not a footnote. A third of rows arrive over a
minute after their block time, some hours after (the sweep pass). Every event carries
lagSeconds and origin: "live" | "sweep" | "backfill"; the
maxLagSeconds filter is your "fresh prints only" knob — a max-age cut, not a
liveness oracle.
A null never satisfies a numeric predicate — floor or ceiling.
usdAmount is genuinely null on ~1.4% of robinhood rows; it is never
zero-substituted, and it fails both minUsd and maxUsd. Want unpriced
rows? Omit the bounds and filter on the explicit null yourself.
Wire money is exact dollars. ?minUsd=300 means three
hundred dollars. Suffixed strings ("300k", "1.5m") are accepted on both
transports. Robinhood buy notionals read ~8% low, systematically — flagged
rbh_buy_understated, never silently corrected.
Nothing is dropped silently, and dropped events are never billed.
A slow socket sheds oldest-first with counted notice frames and running totals on
every heartbeat. Every server-initiated close is preceded by a bye frame carrying
the detail the 123-byte close reason cannot.
A stale chain is a 200 with caveats, never a 503.
/v1/meta/freshness states per-chain lag against published thresholds — it is how you
distinguish "no trades happened" from "the chain stopped producing". Coverage is three chains,
fomo and gmgn on each, axiom on bnb and robinhood; a filter naming anything else is accepted, empty, and caveated.
Retention is stated, not implied. Trade-level rows: 24 hours. Minute
buckets: 7 days. A cursor below the floor answers 410 cursor_expired with a
resumeCursor — never a quietly empty page.
Every figure says whose it is. /v1/flows and the
flows stream channel carry only what our own decoding produced — that does not
change. The market surface (§09) carries the dashboard's enrichment too, and every field on it is
classified by source in /v1/meta/filters under market.provenance, dated
by the clock of the source that wrote it. A third party's figure is presented as theirs and never
corrected.
Authentication
One key is the credential for both transports. On REST it travels in the header, and only the header:
Authorization: Bearer tk_live_…
A key in a query string is a hard 400 key_in_query.
Query strings live forever in proxy logs. On the socket, a credential-shaped query parameter
on the upgrade URL closes 4015 credential_in_query — rotate that key; it is in
log files now.
On the socket the key travels in the first frame after the upgrade (browsers
cannot set headers on a WebSocket, and URLs are logged — the frame is what remains). You
have 5 frames, 4 KiB and 5,000 ms to authenticate; then 4001.
At rest a key is an HMAC under a server-side pepper. Revocation bites on the next request; the in-process cache bound is 5 seconds, the absolute worst case 300. Unknown and malformed keys answer identically — there is nothing to enumerate.
One filter, one meaning
The same filter object drives /v1/flows and a socket subscription, and it
means the same thing on both — enforced by one shared evaluator and proven by a replay
harness before anything ships. Every predicate is a pure function of the row.
| field | type | default | meaning |
|---|---|---|---|
windowMinutes | int 1–10080 | 60 | lookback bound on blockTime; plan history gates depth |
maxLagSeconds | number · null | 3600 | max ingestedAt − blockTime age; null = unbounded |
chains | list | all | robinhood · bnb · base · solana — empty means all |
platforms | list | all | fomo · gmgn · pump · axiom — pump is in the vocabulary, untracked (Solana only) |
side | enum | all | all · buy · sell |
minUsd / maxUsd | money · null | — | exact dollars; suffixes ok; nulls fail both (§2.3) |
tokens / wallets | list ≤ 50 | — | address allow-lists |
v | int | 1 | grammar version; a declared unknown version is refused |
Flat conjunction only — list fields give OR within a dimension; there are no
AND/OR/NOT trees. The machine-readable grammar, with ranges, costs and the caveat
vocabulary, lives at /v1/meta/filters.
REST /v1
| route | purpose |
|---|---|
GET /v1/flowskey |
the tape — cursor-paginated both directions, full filter vocabulary |
GET /v1/flows/{id}key |
one print by natural key chain:tx:leg (URL-encoded) |
GET /v1/stream/positionkey |
current head seq, for backfill-before-connect clients |
GET /v1/meta/freshnesskey |
per-chain lag, counts, state: live|stale|down with stated thresholds |
GET /v1/meta/filtersopen |
the grammar, machine-readable: units, ranges, defaults, coverage, costs |
GET /v1/plansopen |
tiers and limits — the numbers below, from the running service |
GET /v1/usagekey |
this key's consumption in the current period |
GET /v1/market…key |
the dashboard's views — combined or one at a time (§09) |
GET /v1/pools/{chain}/{token}key |
liquidity depth segments for the deepest readable pool (§09) |
GET /v1/alert-capabilitiesopen |
closed alert grammar, work limits and current coverage health |
GET·POST·PATCH·DELETE /v1/alerts…key / session |
private rule CRUD, disabled duplication and bounded dry-run preview |
GET·POST /v1/notifications…key / session |
private keyset feed, unread count and read receipts |
Ordering: the arrival axis
Both directions walk (ingestedAt, _id) — "the last N trades" means last N
by arrival, because on this tape chain time runs backwards for four rows in ten
(§2.1). order=desc (default) pages back from now; order=asc tails
forward, holding back the newest ~2 seconds so a row can never land beneath a cursor you
already hold.
Cursors
Two kinds, one conversion point, all opaque:
| kind | looks like | where it comes from |
|---|---|---|
| seq | 1-1788252174973-0 | the socket ack, /v1/stream/position — accepted verbatim as cursor= |
| continuation | c1_aa_….… | meta.page.cursor — signed; carries your filter's fingerprint and the frozen floors |
Re-send the identical filter set on every page (400 cursor_query_mismatch
otherwise); only limit may change. An empty ascending page echoes its position
— poll the same cursor every 1–5 s to tail over REST. Tampering earns a flat
400 invalid_cursor that never says which check failed; a position under the
retention floor earns 410 with a details.resumeCursor.
Money, on every answer
RateLimit-Limit / -Remaining / -Reset ride every response;
X-Traktor-Cost says what the request billed (a 304 bills zero — ETags
hash the data, so conditional polling is nearly free). /v1/flows costs
1 + ceil(limit/100) units; point reads cost 1; meta routes are free but still
rate-armed.
Private alerts
Build up to 100 account-scoped rules from the closed vocabulary at
/v1/alert-capabilities. Rules combine up to
12 conditions, scope up to 50 token or wallet addresses, and choose crossing, repeat or one-shot
delivery. Matches arrive in the private notification feed; edits use an optimistic
revision, and duplicates start disabled.
| route | meaning |
|---|---|
POST /v1/browser-sessions/exchange | exchange an API key for a random 12-hour bearer |
GET|DELETE /v1/browser-sessions/current | inspect or revoke the presented browser session |
GET|POST /v1/alerts | list or create rules |
GET|PATCH|DELETE /v1/alerts/{id} | read, revision-update or delete one rule |
POST /v1/alerts/{id}/duplicate|test | copy disabled or preview a bounded current sample |
GET /v1/notifications | newest-first feed on (createdAt,id) |
GET /v1/notifications/unread-count | exact unread count |
POST /v1/notifications/read | mark selected IDs or all account rows read |
POST /v1/telegram/link-codes | mint a one-time code and its t.me deep link for the Telegram bot |
GET|DELETE /v1/telegram/links[/{chatId}] | the Telegram chats linked to the account; unlink one |
POST /v1/browser-sessions/telegram | a Mini App signs in: verified Telegram initData for a linked chat mints the 12-hour bearer |
A browser bearer is stored only as a SHA-256 hash, is bound to its source API key, and becomes
invalid as soon as that key is revoked or expires. The web proxy keeps it in an
HttpOnly, SameSite=Strict, production __Host- cookie.
Preview is read-only and bounded to six calls/minute, burst three, 250 candidate subjects and a
three-second Mongo budget.
Coverage loss is visible. Per account: 300 detailed feed rows/hour and 10,000 exact event/rule evaluations/fixed minute. Per stateful rule: 1,000 tracked subjects. Overflow becomes a cumulative unread feed summary; it is never silently reported as a non-match. Feed rows and dormant state are retained for 90 days.
The socket
/v1/stream, subprotocol traktor.v1. Text frames, one JSON object
each, every frame { op, id }, responses echo re. Exactly one
terminal answer — one ack or one error — per client frame.
The server refuses unknown fields (a typo must not silently widen a filter); you ignore
unknown server ops.
| client → server | |
|---|---|
auth | the key, first frame, nowhere else |
subscribe | filter + optional from seq + delivery shape |
unsubscribe | one name or an atomic list |
subs | full live state — ask, don't guess |
ping | keepalive; payload echoed |
bye | clean close; queued events flush first |
| server → client | |
|---|---|
hello | pre-auth: budgets, heartbeat params |
ack / error | the terminal answers |
events | batches; seq = last element — persist it |
heartbeat | 15 s; per-sub seq/dropped/queued |
notice | drops, pressure, plan changes — never silent |
bye | precedes every server-initiated close |
Resume
Reconnect and subscribe with from: <last seq you processed>. A same-epoch,
in-window span replays exactly — no gap, no duplicate, because every v1 predicate is
pure. A trimmed span, a stale epoch, or a resume outside your plan's replay window is
answered on the ack (resumed: false, gap: { reason }) — never a
silent hole. Backfill the gap over REST from the new anchor and dedupe on id.
Flow control
If your socket cannot keep up, the oldest queued events for that subscription drop —
counted, noticed within a second, cumulative on every heartbeat, and never billed.
Shedding never closes the connection; 4008 is reserved for a socket that stops
reading TCP entirely. Under pressure the server may widen your batching (and says so); it
never narrows it. Send any frame each 30 s — a ping works; 45 s of
silence closes 4009, retry at once.
The handoff
How to stitch history and live into one stream without a gap — the one integration detail three separate designs got wrong in three different ways, settled:
1. subscribe FIRST — the ack's result.seq is your anchor
2. page GET /v1/flows?cursor=<that seq> backwards, same filter
3. dedupe on id across the seam
Gap-free by construction: the socket is attached before the backfill query runs, so nothing inserted during the backfill can fall between the halves. Not duplicate-free, on purpose: the seam is at-least-once. Size your seen-set honestly — twenty minutes of paging at the measured peak (~1,750 rows/min) is roughly 35,000 ids, not a handful.
Prefer backfill-before-connect? GET /v1/stream/position hands
you the head seq with the same dedupe obligation. The socket-first recipe above remains the
documented, tested path.
The market surface
Everything the dashboard shows, on the same key. GET /v1/market serves several views
in one body — views= any of summary, tokens, series, tape, rotation, wallets
— and every view is also its own route:
| route | units | serves |
|---|---|---|
/v1/market | Σ views | the views you name; summary is always served and always billed |
/v1/market/summary | 1 | header counters over the filter |
/v1/market/tokens | 3 | the token board, volume-ranked — limit 1–1000 (200), offset to 50,000, meta.page |
/v1/market/series | 2 | net flow per minute by chain and trading app |
/v1/market/tape | 2 | trades with names, tickers, market cap and wallet stats — limit 1–500 (120) |
/v1/market/rotation | 5 | sell-then-buy links — withinMinutes 1–240 (30), limit 1–200 (40) |
/v1/market/wallets | 3 | the wallet board with Cielo's imported opinion — limit 1–500 (120) |
/v1/market/whales | 3 | wallets at or above a minWalletBalanceUsd floor — required here, and not a views member |
/v1/pools/{chain}/{token} | 10 | depth segments for the deepest readable pool — robinhood or bnb |
One grammar — chains, platforms, windowMinutes, side, minTradeUsd, minWalletBalanceUsd,
launchpads, dexSignals, feeSharing, stockPairs and every screen by its key — published with units, bounds,
costs and provenance at /v1/meta/filters under
market. Each route reads only what it can act on and refuses the rest by name
(400 unknown_parameter, with details.accepts): side and
minTradeUsd screen single trades, so only tape reads them.
dexSignals takes paid (a paid DexScreener profile and social update),
boost (boost spend), and cto (a GMGN-reported community takeover); the choices are OR within the facet, and every view that reads
launchpads reads it too. Bucket-backed
views take every window on every plan; tape, rotation and
wallets read raw trades and are gated by your plan's history
(403 plan_forbids). A balance floor selecting more than 5,000 wallets is
422 filter_too_broad before anything runs.
curl -s "$BASE/v1/market?views=tokens,series&windowMinutes=60&launchpads=flap&minNet=5k" \
-H "Authorization: Bearer $KEY" | jq '.data.tokens[:3] | map({ticker, netUsd, marketCap, metadataAt})'
Fresh market reads and ranking
Market REST and public subscriptions share one committed-data producer. Token rows refresh
after committed changes; advancing the minute rebuilds the rolling window. Responses use
private, max-age=1 and date their build with meta.asOf.
Token routes accept sort, dir=asc|desc, named=1 and
tail=1; tail cannot combine with a nonzero offset. Supported market windows are
5, 15, 30, 60, 240, 1440 and 10080 minutes.
Public market streaming
GET /v1/public/market?resource=flow&query=... returns bounded public resource
JSON. Resources are flow, quote, token, saved,
stocks, assets and feed. The query uses the web vocabulary
(chain, platform, window, screens and ranking), capped at 24 hours.
Connect to /v1/market/stream with subprotocol traktor.market.v1, without
credentials or URL parameters. Subscribe with the same query. The server sends reset,
then a complete snapshot; update replaces the whole view. Each carries
sub, revision, cursor, updatedAt and data.
An unchanged rebuild sends current with the same revision and a new freshness time.
Send ping on heartbeats. Reconnect and resubscribe after a reset or lost connection;
market cursors do not promise execution-level replay. The authenticated traktor.v1
protocol continues to serve raw trades, and rejects market filters such as maxMcap.
{"op":"subscribe","id":"sidebar","query":{"resource":"flow","params":"chain=bnb&window=240&views=tokens&sort=mcap&limit=40"}}
Identical public queries share a producer. Limits per process: 256 sockets, eight per IP, 16 subscriptions per socket, 96 groups, three simultaneous reads and 4 MiB response frames. Slow readers reconnect for a new snapshot. Trading commands retain authenticated, idempotent HTTP endpoints.
Two clocks on the row
Current valuation carries valuationSource, marketPriceUpdatedAt and
valuationExpiresAt, covering verified pool prices and provider fallback.
liquidity, ticker and logo are DexScreener's, dated by metadataAt;
launchpad, graduated, athPrice and both social signals are
GMGN's, dated by gmgnAt (dexscreenerSocialsUpdated is named for what GMGN
reports on, not for who reported it). Our own numbers — flow, buyers, holders, holder-reward
verification — are as fresh as the tape. The whole table is market.provenance.
Pool depth
GET /v1/pools/{chain}/{token} returns the deepest readable pool as exact per-range
segments — USD price bounds, the tokens sitting in the range, and the USD that must flow to cross it
— so binning it to a chart never refetches. Ten units, because a cold read is ~40 RPC calls; the
10-second cache makes the second reader nearly free. Failures name their cause:
404 pool_not_found, 422 pool_unreadable, 502 upstream_failed
with details.source.
Plans & limits
These numbers are read live from /v1/plans
when you load this page; the ack's limits object is authoritative at runtime —
read it, don't hard-code this table.
| limit | Free | Pro |
|---|---|---|
| price / month | $0 | $99 |
| REST units / minute | 30 | 600 |
| REST units / month | 20,000 | 5,000,000 |
| history window | 60 min | 24 h |
| max rows / page | 100 | 1,000 |
| socket connections | 1 | 10 |
| subscriptions / connection | 2 | 50 |
| events / second | 5 | 300 |
| stream units / month | 60,000 | 1,000,000 |
The feed's measured mean is ~15.6 events/s with bursts to 29/s: Pro carries the
firehose; Free deliberately cannot on a wide filter — the ack warns
(rate_cap_below_feed) and drop notices make the shedding visible, never silent.
Non-payment downgrades entitlement to Free; it never revokes your key.
Errors & close codes
Every REST error is { error: { code, message, requestId, details, docs } } —
code is stable forever and the only thing to branch on. The load-bearing ones:
| http | code | what to do |
|---|---|---|
| 400 | key_in_query | move the key to the Authorization header; consider it leaked |
| 400 | invalid_cursor / cursor_query_mismatch | start over / re-send the minting filter set |
| 401 | invalid_key / key_revoked | check the key; revoked keys stay revoked |
| 402 | subscription_inactive | fix billing; the key still exists |
| 400 | unknown_parameter | this route does not read that key; details.accepts lists what it does |
| 400 | screen_conflict / window_out_of_range | the band crosses itself / that window is not one of the six |
| 403 | plan_forbids | narrow windowMinutes to your plan's history |
| 410 | cursor_expired | resume from details.resumeCursor |
| 422 | filter_too_broad | narrow the filter or the window before it runs |
| 429 | rate_limited | wait Retry-After — it is exact |
| 429 | quota_exhausted | waiting will not help; see details.resetsAt |
| 404 · 422 · 502 | pool_not_found / pool_unreadable / upstream_failed | no pool yet / the chain would not read it / DexScreener or the RPC failed — all refunded |
| 401 | session_required / session_expired | connect or renew the browser alert session |
| 409 | alert_limit_reached / alert_revision_conflict | delete a rule / refresh before retrying the edit |
| 409 | alert_once_completed | change the rule or duplicate it to arm a fresh one-shot |
| 429 | preview_rate_limited | wait Retry-After before another dry run |
Socket close codes
Every server-initiated close is preceded by a bye frame with detail
and retryable. An unknown 4xxx code is not retryable unless its bye said so.
| code | retry? | |
|---|---|---|
1001 | deploy — resume from the bye's lastSeq | after retryAfterMs |
4001 | no auth within the deadline | at once |
4002 | auth failed | no |
4003 | subscription lapsed mid-connection | after billing |
4005 | connection allowance held | close another first |
4006 | monthly stream allowance spent | at resetsAt |
4008 | socket stopped reading for 30 s | narrow the filter |
4009 | 45 s inbound silence | at once |
4015 | credential in the upgrade URL | rotate the key |