api docs
public, no key, fair use. all payloads carry an honest asOf timestamp.
the envelope
every REST route wraps its payloadevery route under /api returns ApiResult<T>. no bare 500s, no silent nulls. asOf is the server-side fetch time in ms, stale data is allowed only with an honest stamp. a field a source cannot supply comes back null, never 0.
// every REST route, both outcomes
{ "ok": true, "asOf": 1752571200000, "data": <payload> }
{ "ok": false, "asOf": 1752571200000,
"error": "unconfigured" | "upstream" | "rate_limited" | "internal",
"message": "human readable, tells you what to do" }realtime
one stream, typed eventslive prints and ansem-wallet whale transfers on a single server-sent-events stream. no auth, CORS open on GET, sub-second from chain-observed to wire.
event: print // one dex trade, same shape as tape rows
id: <tx signature>
data: {"ts":<ms>,"side":"buy"|"sell","usd":n,"tokenAmt":n,
"price":n,"wallet":"...","venue":"pumpswap","sig":"..."}
event: whale // ansem-wallet transfer (WhaleEvent)
id: <tx signature>
data: {"ts":<ms>,"wallet":"...","direction":"out"|"in","amount":n,
"to":"...","sharePct":n|null,"sig":"..."}- id: every message carries the tx signature as its event id. dedupe on it.
- reconnect with Last-Event-ID (header, or ?lastEventId=) and everything newer than that signature is backfilled from the archive before the live tail resumes. fresh connects get the latest 30 events.
- heartbeat comment : ping every 15s. retry: 4000 is sent on connect, honor it.
- field names are load-bearing once public: only additive changes, nothing gets renamed or removed.
curl -N https://bullprint.xyz/api/events # resume after a disconnect: send the last id you saw (a tx signature) curl -N -H "Last-Event-ID: <sig>" https://bullprint.xyz/api/events
market
price, tape, flow, depththe one-call snapshot: price, mcap, fdv, 24h volume, ath, holder count, 7d spark, rolling 5m net flow.
server cache 60s, poll 30 to 60s
TokenSnapshot {
priceUsd: number
change24hPct: number | null // null when the origin omits it, never 0
marketCapUsd, fdvUsd, volume24hUsd: number | null
athUsd: number | null, athDate: string | null
holderCount: number | null // from the latest holder snapshot
spark7d: { ts, price }[] // 7d hourly closes, oldest first
netFlow5mUsd: number | null // rolling 5m buys minus sells
}the merged swap tape across venues, deduped by signature, newest first, plus windowed net flow and 15m buy pressure.
limit: 1..1000, default 300server cache 4s, poll 4 to 6s
coverageStartTs marks how far back the tape actually sees. when it is younger than a window, that window's sums are partial. treat them as such.
TapePayload {
trades: Trade[] // newest first
coverageStartTs: number | null // oldest merged print the tape sees
netFlow: { h1, h4, h24: { buyUsd, sellUsd } }
pressure15m: number | null // 0..1, 1 = all buys
venueSplit: { venue, volume24hUsd }[]
}
Trade { ts, side: "buy"|"sell", usd, tokenAmt, price, wallet, venue, sig }minute-bucketed buy/sell USD flow from the persistent archive. unlike the page-bounded tape, this accumulates as prints land, so wide windows genuinely differ.
window: 15m | 1h | 4h | 24h | 7d | 30d, default 1hserver cache 15s
a young archive is declared, not hidden: coverageStartTs is the oldest bucket held, archiveStartTs the first-ever write. minutes are retained 7 days; windows beyond that are served from the durable hourly archive at hour resolution.
FlowAggPayload {
series: { ts, buyUsd, sellUsd }[] // 1-minute buckets, oldest first
totals: { buyUsd, sellUsd }
windowMs: number
coverageStartTs: number | null // archive younger than window = partial
archiveStartTs: number | null // first-ever archive write
}top buyers and sellers by net USD over the window, ranked from archived prints.
window: 1h | 4h | 24h | 7d
observed-only: a wallet's totals cover prints the stream has seen since archiving began, not its full history. coverageStartTs says how far back that is. 7d reads the hour ledger's full reach.
LeadersPayload {
windowMs: number
buyers: { wallet, netUsd, buyUsd, sellUsd, prints }[]
sellers: { wallet, netUsd, buyUsd, sellUsd, prints }[]
coverageStartTs: number | null // the archived stream only reaches here
}buyer cohorts grouped by UTC day of first observed buy, with sell-through and median hold time per cohort.
server cache 60s
observed-only: cohort day is the first buy the print stream SAW, which is not necessarily the wallet's first buy ever. observingSinceTs marks the start of observation.
BuyerCohortsPayload {
cohorts: { day, buyers, soldPct, medianHoldMs }[] // per UTC day
trackedBuyers: number
observingSinceTs: number | null // observation starts here, not at genesis
}candles unioned across every resolved pool, backed by the durable candle bank: once a candle is observed it is stored forever, so history only deepens. optionally with a normalized % compare series over the same window.
tf: 1m | 5m | 15m | 1h | 4h | 1d, default 1h · compare: solana | bitcoin, optionalserver cache 20s, bank accrual cron every 15 min
banked reports the bank's honest reach. on hour and day frames no-trade buckets render flat (carried close, volume 0) and gapsFilled counts them. the bank stores only provider-observed candles, deepest pool wins a conflicting bucket.
OhlcvPayload {
candles: { time, open, high, low, close, volume }[] // oldest first
pool: string // the resolved top pool
timeframe: "1m" | "5m" | "15m" | "1h" | "4h" | "1d"
compare?: { time, value }[] | null // normalized % series, same window
}
// candle time is unix SECONDS, everything else in the API is mswhere ANSEM trades: cex/dex tickers plus onchain pools.
server cache 120s
either half may degrade to an empty array while the other succeeds. both failing is an error envelope.
{
venues: { exchange, pair, priceUsd, volume24hUsd,
spreadPct, trustScore, isDex, url }[]
pools: { address, dex, name, liquidityUsd, volume24hUsd, feePct }[]
}analytic slippage curves per pool: what it costs in USD to move the price 2% or 5%, both sides.
server cache 60s
derived from reserves under a constant-product assumption. on concentrated-liquidity pools this overstates depth. treat curves as an upper bound.
DepthPayload {
pools: {
address, dex,
buyCurve: [usdIn, priceImpactPct][] // cost to move price up
sellCurve: [usdIn, priceImpactPct][]
depthPlus2PctUsd, depthMinus2PctUsd,
depthPlus5PctUsd, depthMinus5PctUsd
}[]
}holders
snapshots, retention, the mapthe latest holder snapshot: count with 24h/7d deltas, concentration (gini, hhi, top-N shares), size cohorts, balance histogram, and the same stats recomputed without the ansem wallet.
snapshot cron every 15 min, server cache 30s
cohort thresholds: whale >=0.5% of supply, shark >=0.05%, fish >=0.005%, shrimp below. truncated=true means enumeration hit its cap and count is a floor, not a total.
HolderSummary {
count, countChange24h, countChange7d
medianBalance, gini, hhi
top10SharePct, top25SharePct, top100SharePct, ansemSharePct
exAnsem: { same concentration stats, ansem wallet removed } | null
cohorts: { whale, shark, fish, shrimp } // >=0.5% / 0.05% / 0.005% supply
histogram: [bucketMinTokens, holderCount][]
snapshotTs: number
truncated?: boolean // true = enumeration hit the cap, count is a floor
}top holders from the latest snapshot with labels, supply share, 7d change, first-seen and airdrop tags.
limit: default 100server cache 30s
before the first snapshot cron a ranked indexer fast path serves real balances with fewer columns: change7dPct, firstSeen and airdrop tags stay null/false until snapshot history exists.
HolderRow {
rank, address, label: string | null, kind: string | null
balance, supplyPct
change7dPct: number | null, firstSeen: number | null
airdropped: boolean
}the holder snapshot series, oldest first: count, concentration and cohorts over time.
one point per snapshot cron, server cache 30s
{ points: HolderHistoryPoint[] } // oldest first
HolderHistoryPoint {
ts, count, top10SharePct, ansemSharePct
gini?: number // additive from 2026-07-15, older points lack it
cohorts: { whale, shark, fish, shrimp }
}do airdrop recipients keep the bag: retention buckets and a survival curve over tagged recipients.
recomputed at snapshot time
buckets are measured against each recipient's cumulative drop baseline: added (grew), held, dumped. increasedPct is null until baselines exist. below 25 tagged recipients the route reports harvest progress instead of noise dressed as percentages.
AirdropRetention {
taggedRecipients: number
stillHoldingPct, dumpedPct: number
increasedPct: number | null // null until per-recipient baselines exist
survival: [daysSinceAirdrop, pctStillHolding][]
botDumpedPct?, organicDumpedPct?, classifiedCount?,
botShareOfRecipientsPct? // recipient-classifier split, additive
}who entered and who left the top 100 between daily records.
window: Nh | Nd, default 24hrecords persist once per UTC day, server cache 5 min
day granularity: wallets that bounce in and out between daily records are invisible. windowMs reports the actual span compared.
ChurnPayload {
windowMs: number // the ACTUAL span compared, never the requested one
entered: { address, rank, balance }[]
left: { address, lastRank }[]
trackingSinceTs: number | null
}the wallet constellation: top holders as nodes with precomputed layout positions, airdrop and transfer edges.
server cache 5 min
GraphPayload {
nodes: { id, label, kind, balance, supplyPct,
cluster, airdropped, x, y }[] // layout precomputed server-side
edges: { source, target, kind: "transfer" | "airdrop", weight }[]
builtAt: number
}the profile behind every wallet deep link: label, balance, rank, airdrop baseline, recent prints, transfer-graph neighbors.
address: base58 wallet
recentPrints and observed totals cover the archived print stream only, they are not the wallet's full trading history. rank is null beyond the snapshot top tier.
WalletProfilePayload {
address, label, kind, balance, supplyPct, rank, firstSeen
airdropped: boolean
airdropBaseline: { firstDropTs, amountReceived } | null
recentPrints: Trade[] // archived stream only, newest first
observed?: { buyUsd, sellUsd, prints } | null // totals over those prints
linkedWallets: { address, transfers }[]
}the dev wallet's live SOL balance. the /dev page pairs it with the standard wallet profile, the builder's wallet rides the same rails as everyone else's.
server cache 60s
display and tracking only. nothing in the terminal ever initiates a transfer.
intel
narrative, correlation, healththe timeline distilled: narrative summary, sentiment score, themes, top posts, active KOLs, risk flags, plus sentiment history for sparklines.
refresh cron hourly, poll 60s or slower
model-read social data, not chain truth. sentiment is an estimate with a timestamp, not a measurement.
IntelPayload {
narrativeSummary: string
sentimentScore: number // -100..100
momentum: "rising" | "cooling" | "stable"
themes: string[], riskFlags: string[]
topPosts: { author, handle, text, url, engagement, stance }[]
kolsActive: { handle, stance, note }[]
refreshedAt: number
history: { ts, sentimentScore, mentionEstimate }[]
}the narrative archive: every refresh banks a thin slice of themes, active KOLs and momentum, so the story over time is navigable, not overwritten.
one point per refresh cron, server cache 60s
archiving began with v6 and deepens from there, never backfilled. archivingSinceTs is the honest start.
do social spikes match buy pressure: hourly sentiment meaned against hourly net flow, pearson r over the aligned buckets.
server cache 120s
only hours present in BOTH series count. r is null below 6 aligned hours and null on a flat series, a 0/0 correlation is undefined, never fabricated.
FlowCorrelationPayload {
points: { ts, sentiment, netFlowUsd }[] // aligned hourly buckets, 168h max
pearsonR: number | null // null below 6 aligned hours or on a flat series
sampleSize: number
}ask the bull: a grok answer grounded exclusively in the terminal's own live payloads. auth required.
body: { question } · 280 chars max10 questions per account per day, resets 00:00 utc
the model sees ONLY the injected data slices, no outside search, no memory. every number in an answer must exist in sources. refusals (quota, empty question) ride the error envelope with honest messages.
AnalystAnswer {
answer: string
sources: { id, asOf }[] // the exact data slices the answer was grounded in
remainingToday: number // questions left for your account today
}every data source and cron reporting in: state, last-fresh timestamp, terse note on what is degraded and why.
server cache 30s
StatusPayload {
sources: { id, label, lastFresh, note,
state: "ok" | "degraded" | "down" | "unconfigured" }[]
crons: { id, label, lastRun, cadence }[]
checkedAt: number
}the 1200x630 share card behind every og link. no params is the token card, wallet= renders that wallet's live profile card, view=ansem the ansem wallet card.
wallet: base58 wallet, optional · view: ansem, optional
the one route that returns an image, not the envelope. card numbers come from the live api at render time. a failed fetch ships the card without numbers, never fabricated ones.
rules
what you can rely on- field names are stable. changes are additive only. nothing public gets renamed or removed.
- observed-only data is labeled. where the view starts later than the token, the payload says so: observingSinceTs, coverageStartTs, trackingSinceTs. sums over partial coverage are partial, label them.
- numbers are never fabricated. a field a source cannot supply is null, not 0. small samples report progress instead of percentages.
- if a source degrades, the payload says so. you get an error envelope with a cause, never a bare 500. /status shows every source and cron in one place.
- no key, fair use. CORS is open where noted. keep polling sane, respect the cache cadences above.
the bullpen
login, horns, receiptsthe social layer. login via privy, points are horns, everything auditable.
authed endpoints take Authorization: Bearer <privy access token>. everything else is public and rides the same envelope.
season standings by horns, top 50, plus the all-time top 10. names resolve server-side, badges are computed live from claimed wallets. pass a past season id and the permanent archive answers: final standings frozen at close, isArchived true.
server cache 60s
seasons are UTC ISO weeks. a new season resets the table, never the all-time list. archives are immutable: written at season close, verified before any live keys are reaped, kept forever.
LeagueTablePayload {
seasonId: string // UTC ISO week, "2026-W29"
rows: { rank, did, name, xHandle, wallet,
horns, badges: Badge[] }[]
alltime: { rank, name, horns }[]
}
Badge { id, label, detail: string | null }the season archive index: every archived season newest first, plus the live season id. feeds the season navigators.
server cache 60s
the day's bull call markets: two-sided questions with lock and settle times. public. send a bearer and myPick fills in.
server cache 30s
PredictMarketsPayload {
day: string // UTC "2026-07-16"
markets: { id, question, options: [string, string],
settleSource, locksAt, settlesAt,
myPick: string | null }[]
}lock a pick on an open market. auth required. one pick per market, no repicks, rejected after locksAt.
body: { marketId, pick }
refusals ride the ok envelope as { accepted: false, reason }. they are outcomes, not errors.
PredictSubmitResult {
accepted: boolean
marketId, pick: string
reason: string | null // set when refused (locked, dup, bad option)
}your picks with outcomes and settle receipts, the exact numbers each result was graded on. auth required.
PredictHistoryPayload {
rows: { marketId, question, pick,
outcome: "win" | "loss" | "push" | "pending",
hornsDelta, // 0 while pending
receipt: string | null }[] // the settle numbers, verbatim
streak: number
}the x league: registered handles ranked by scored $ANSEM posts this season. past seasons serve from the archive with posts 0 and scoredThroughTs null, those live in global structures and are not season facts.
scoredThroughTs is the honesty floor: everything through it is scored for every registered handle. rows carry their own lastSweptTs, lastSweep carries the newest sweep's counts.
XLeagueTablePayload {
seasonId: string
rows: { rank, xHandle, horns, posts }[]
scoredThroughTs: number | null // scoring sweep coverage
registeredHandles: number
}every scored post behind a handle's points: url, timestamp, points, engagement, stance. the receipts.
handle: registered x handle
XLeagueReceiptsPayload {
xHandle: string
items: { url, ts, points, engagement,
stance: "bull" | "bear" | "neutral" }[]
}the bull run season leaderboard. public. myBest fills when authed.
run submission is not a public write surface: every submitted run is replayed server-side from its seed, and a score that does not replay does not land.
GameBoardPayload {
seasonId: string
rows: { rank, name, score, ts }[]
myBest: number | null // the caller's own best when authed
}the stampede season leaderboard, same shape as bull run's. public. myBest fills when authed.
stampede submissions are replayed server-side like bull run and scalp: the recorded inputs re-simulate from the run's seed and a score that does not replay does not land.
GameBoardPayload {
seasonId: string
rows: { rank, name, score, ts }[]
myBest: number | null // the caller's own best when authed
}the season prize pool, when the community funds one.
community funded and display-only, zero custody: any bull can put a pool up and the solscan tx is the public receipt. funded stays null until a verified funding tx is posted, and the ui says no pool rather than inventing a pot. payouts are manual at season end.
PoolPayload {
seasonId: string
funded: { amountAnsem, txSig, note } | null // null = no pool funded
payoutPlan: string | null
}the launch pot: a raffle over one announcement post. entry takes BOTH a quote and a reply from a linked bullpen handle, each carrying an actual ansem thesis. the hourly sweep records halves automatically, self-verify on /raffle completes them instantly, one entry per account.
entry sweep hourly, server cache 30s
zero custody, every step receipted. at close a grok review filters slop and spam before the draw, verdicts public in result.filtered. the draw is deterministic and re-runnable: sorted QUALIFIED handles, seeded by the first finalized blockhash at or after endTs, algorithm named in the seed. entrants publishes the full draw set before the draw runs so anyone can audit it. pot stays null when none is live, send a bearer and mine says whether you are in the draw, pending shows which half you still owe. payouts are manual with tx sigs.
PostPotPayload {
pot: { id, postUrl, amountAnsem, winners, startTs, endTs,
status: "live" | "closed" | "drawn", note } | null
entryCount: number // COMPLETE entrants (quote + reply verified)
recent: string[] // recent entrant handles, display only
mine: { url, ts, kind } | null // authed + COMPLETE. null while half-way
pending?: { // v6: the caller's half-entry state
quote: { url, ts } | null,
reply: { url, ts } | null
} | null
pendingCount?: number // bulls with one verified half
judging?: { judged, total } | null // close review in progress
entrants?: { handle, ts, kind }[] // full draw set, newest first, bounded
// at 500. entryCount is the true total
result: {
drawnAt, entryCount,
seed: { slot, blockhash, algorithm }, // re-run the draw yourself
winners: { did, handle, name, postUrl }[],
payouts: { did, amountAnsem, txSig }[],
qualifiedCount?: number, // v6: thesis review survivors
filtered?: { handle, reason }[] // v6: public verdicts for the rest
} | null
}the zero-paste rail: one grok scan scoped to YOUR linked handle finds your quote and reply and records both halves itself. auth required, the raffle page fires it automatically.
15 min cooldown per account, 8 scans per day, pot-wide daily budget
refusals ride the ok envelope verbatim. the scan is a convenience rail: the hourly sweep and the paste-a-link verify remain, all three land through the same one-entry-per-account machinery.
the verified badge set for any wallet, computed at read time from stores the terminal already maintains. never self-reported, never stored stale.
address: base58 wallet
{ badges: Badge[] }
Badge {
id: string // "tier-whale" | "airdrop-og" | "diamond-hands" | ...
label: string // short chip text, e.g. "jun 28 og"
detail: string | null // one-line proof statement
}who claimed a wallet, if anyone. claiming itself happens in the app: the address must match the caller's privy-verified link.
address: base58 wallet
{ claimant: { did, name } | null } // null = unclaimedthe bulls
the herd, on chainTHE BULLS (thebulls.live), the community collection whose 0.5 SOL mints feed $ANSEM buybacks. every aggregate is an observed floor from the treasury harvest with a coverage stamp, never a claim of totality. site-only facts are not mirrored here.
the herd in six numbers: minted, holders, observed mint SOL, observed buyback ANSEM, buyback count, ansem per sol.
server cache 60s, poll 60s
the buyback swaps run through a custom program, so they are derived from raw transfers. harvestedThroughTs stamps how far the harvest has read; harvestExhausted says whether the full treasury history is walked.
BullsStatsPayload {
collection, treasury: string
minted, holders: number | null // null before the first enumeration
mintPriceSol: number
solRaisedObserved: number | null // observed 0.5 SOL mint inflows, a floor
ansemBoughtObserved: number | null // observed buyback output, a floor
buybackCount, avgAnsemPerSol: number | null
harvestedThroughTs: number | null // newest processed treasury tx
harvestExhausted: boolean | null // true = full history walked
snapshotTs: number | null
}the collection, 24 bulls a page, searchable by number or trait text. owners resolve from the enumeration snapshot.
page: 1-based · q: number or trait text, optional
BullsGalleryPayload {
items: BullAsset[]
page, pageSize: number
totalIndexed: number | null // null before the first enumeration
snapshotTs: number | null
}
BullAsset { id, name, image, owner,
attributes: { trait, value }[] }the buyback ledger: SOL to ANSEM swaps the treasury executed, newest first, with observed totals.
server cache 60s
totals are floors over harvested transactions only. a young harvest is declared, not hidden.
BullsBuybacksPayload {
events: { ts, sol, ansem, sig }[] // newest first, bounded
totals: { sol, ansem, count } | null
harvestedThroughTs: number | null
harvestExhausted: boolean | null
}the secondary market from magic eden: floor, listed count, lifetime volume, recent sales newest first.
server cache 120s
numbers come from the magic eden public api as reported, fields the origin cannot supply are null, never 0. salesCoverageTs stamps how far back the sales feed reaches.
BullsMarketPayload {
symbol: string // magic eden collection symbol
floorSol, listedCount: number | null
volumeAllSol: number | null // lifetime traded volume, as ME reports it
sales: { ts, priceSol, sig, name }[] // newest first, bounded
salesCoverageTs: number | null // how far back the sales feed reaches
fetchedAt: number | null
}top holders by bull count, with bullpen identity attached when the wallet is claimed.
snapshot every ~15 min, server cache 120s
BullsHoldersPayload {
rows: { owner, count, name, did }[] // did set when the wallet is claimed
distinctHolders: number | null
snapshotTs: number | null
}your claimed wallet's bulls plus this season's holder grant with its receipt. auth required.
the grant is auditable like every horn: grantReceipt states what was granted and why, and 0 means 0.
BullsHerdPayload {
wallet: string | null // null = no wallet claimed
count: number
bulls: BullAsset[] // bounded
hornsGranted: number // this season's holder grant, 0 when none
grantReceipt: string | null
}the drop desk
airdrop to holders, receipts you can verifyTHE DROP DESK builds provably complete recipient lists of $ANSEM holders for airdrops. it produces lists and receipts, and an in-browser sender that signs with your own wallet. it never holds keys or funds and never touches your tokens: every transaction is signed by your wallet, zero custody. the desk is in private preview behind an access code, and every /api/drop route requires it.
the hash receipt
every published snapshot carries a sha256 over a fixed canonical serialization of the holder set. the rule, verbatim: take every owner and their raw balance in base units, sort by address ascending, join each as "address:raw" with a single newline between rows, sha256 the resulting utf-8 string. the same bytes always hash the same way, so a receipt proves the list the desk served is the list it hashed.
what the hash does NOT claim: chain reproducibility. the walk takes time and the chain moves under it, so the receipt is scoped by its walk window (walkStartTs, walkEndTs) and the truncated flag, published beside the hash. it is an integrity receipt over a point-in-time enumeration, not a promise that a third party can replay the identical set later.
what is excluded, and why
exclusions are flags on the receipt, reason-tagged: cex (verified exchange hot wallets), pool (amm vaults, resolved from the live pools), burn (incinerator and dead addresses that actually hold the token), and pda (a structural catch: the top token accounts are resolved to their owner authorities and checked for ed25519 curve membership. program-owned accounts are off-curve, so any vault of any venue is caught). a snapshot refuses to publish if an off-curve whale in the top accounts is not explained.
the numbers are honest
allocation math is integer only, in the dropped token's base units, so a list can never allocate more than the total and never emits scientific notation. holding-age filters cover only the wallets the terminal has stamped: the unknown bucket is shown, not hidden, and acknowledged before export. every export is logged to a public ledger with the builder's handle, the count, the filters and the export hash.
published snapshot receipts: holder count, walk window, sha256, exclusions, source. metas only, no addresses. access code required.
server cache 60s
chunk data (the address list) is never public. addresses leave the server only through the authed, handle-gated, publicly-ledgered export.
the recipient list for a filter + allocation set. auth AND a linked x handle required. every export is written to the public ledger before a byte streams.
csv is address,amount for external multisenders (headerless variant available); csv-enriched adds 20 analysis columns (rank, tier, share, tenure, behavior flags) for spreadsheets, not senders; json carries raw base-unit amounts and is the pinned contract the live in-browser sender re-verifies by hash. the response is a file, not the envelope: a present X-Export-Sha header is success, its absence is a refusal.