Every figure Purser Report publishes is available over HTTP as JSON. No key, no sign-up. The same numbers the dashboard renders, with the caveats attached to them rather than kept in the prose.
Several fields are deliberately null in cases where a plausible number could have been supplied instead. That is the whole point of this service: a reading that could not be taken is reported as absent, never as a figure that happens to parse. If you write a client that treats null as zero, it will conclude a token is free to move or worthless at exactly the moments it should refuse to act.
Base URL https://purser.report/api/v1. Every response is { data, generatedAt }. generatedAt is when the response was built, not when the chain was read — that is observedAt on the observation itself.
| Endpoint | Parameters | Returns |
|---|---|---|
| /status | — | Counts by verdict, last indexed block and time, and the endpoint list. |
| /tokens | verdict | Start here. Every tracked token with its latest reading inline — price, coverage, cost to move, band — plus fundedPools. One request is the whole market. ?verdict=issuer|unverified|counterfeit|unaffiliated. |
| /tokens/{address} | hours | One token: latest observation, its pools, a history window and the caveats that apply to it. ?hours=24, capped at 72 hours and 1,000 rows — see retention in §05. |
| /pools | token | Every indexed pool. ?token={address} to narrow. |
| /events | severity, limit, afterId, since | The change log. ?severity=critical,high, ?limit=50 (capped at 500). Poll incrementally with ?afterId= or ?since= — see §05. |
Addresses are accepted in any case and always returned lowercase. An address that is tracked but has no record yet returns 404 not_found; a malformed one is rejected rather than guessed at.
A /tokens row is the token record plus two additions: fundedPools, the number of its pools actually holding liquidity, and latest, its most recent observation — or null for a token discovered and verified but not yet read. The caveats in §02 apply to latest exactly as they do on the detail endpoint; a field never means one thing on the list and another on the record.
| Deciding what is tradeable |
|---|
const { data } = await (await fetch(
'https://purser.report/api/v1/tokens'
)).json()
const tradeable = data.filter(t =>
t.fundedPools > 0 && // there is a market at all
t.latest && // it has been read
t.latest.priceUsd !== null // the price is per-share, not raw
)
// Do NOT rank by cost alone. The figure only holds inside the band.
const fragile = tradeable.filter(t =>
!t.latest.bandFullRange &&
t.latest.bandPct !== null &&
t.latest.bandPct < 5 // book runs out before the 5% it prices
) |
That is one request. Before this endpoint carried latest, the same result took 179 — a six-minute sweep at 30 requests a minute, against an indexer that re-reads each token every ten. Any client written against that older shape could not keep up with the data it was being offered.
These are the ones worth understanding before you trade on anything here. Each is null in a specific, meaningful circumstance.
| Field | Type | What null means |
|---|---|---|
| priceUsd | number | null | Null when multiplierKnown is false. The ERC-8056 corporate-action multiplier could not be read, so the only price available is the raw pool price, which is not per-share. CrowdStrike's multiplier is 4.0 — served unadjusted its price is four times wrong. The unadjusted figure is still available as rawPoolPriceUsd, under a name that says what it is. |
| rawPoolPriceUsd | number | null | On a pool: null when hasLiquidity is false. A ratio of two dust balances is not a price. One live NVDA pool holds 21 wei against 24 wei, which divides out to $7.36 billion. Nulled rather than zeroed, because a 0 would assert the token is worthless — a different false claim, not the absence of one. |
| bandPct | number | null | Null in two opposite cases, which bandFullRange tells apart. If bandFullRange is true, liquidity is posted across every price (a full-range position, band infinite) — JSON has no Infinity, so no number could be sent. If false, the band could not be derived at all. Treating the first as “thin” sizes a trade against the deepest possible book exactly backwards. |
| costToMove5pctUsd | number | Quote-denominated capital to push the price up 5%. Upward only: pushing down means selling the base token, which is a base-denominated figure we do not compute. It holds only inside bandPct — past that edge there is no posted liquidity and the rest of the move is close to free, so this number alone does not rank tokens by how hard they are to move. |
| coverageRatio | number | Fraction of total supply sitting in pools. A different question from cost to move, which is depth at the current price. Coverage near zero with a healthy cost figure means a little liquidity concentrated tightly at spot. |
| Verdict | Means | Does not mean |
|---|---|---|
| Issuer | Runtime bytecode is byte-identical to the known Robinhood proxy template and delegates to the real beacon. | That Robinhood deployed this instance. Provenance verification against the minting account is not implemented. This is the weakest link in the classification and is stated wherever the verdict appears. |
| Unverified | Carries the shared beacon marker but an unrecognised template. | An accusation. It is withheld judgement, not a finding. |
| Counterfeit | No beacon marker, yet claims a real listed ticker. | — |
| Unaffiliated | No marker and no ticker collision. Not our concern. | — |
/events is a change log, not a status poll. A row means something changed; a condition that merely persists is visible in the token and pool records instead. Severities: critical, high, medium, info.
Do not re-read the newest page each time. Without a cursor a poller re-reads and de-duplicates the same rows forever, and — the part that actually costs you — silently misses events whenever more than limit of them land between two polls, with nothing in the response to indicate a gap.
Keep the highest id you have seen and pass it back as ?afterId=. id is a bigserial, so it is monotonic and never reused. If you are resuming from a stored timestamp instead, use ?since= with an ISO-8601 time.
let cursor = 0
setInterval(async () => {
const url = 'https://purser.report/api/v1/events'
+ '?afterId=' + cursor + '&limit=500'
const { data } = await (await fetch(url)).json()
for (const e of data) handle(e)
if (data.length) cursor = Math.max(...data.map(e => Number(e.id)))
}, 60_000) // the indexer writes at most once a minuteA malformed cursor is ignored rather than rejected. Ignoring it costs one page of rows you have already seen; rejecting it would stop your poller dead.
| Kind | Fires when |
|---|---|
| liquidity-appeared | A token's first pool holds liquidity. It is now tradeable, and manipulable. |
| liquidity-drained | Every pool has emptied. Positions cannot be exited on chain at any price. |
| supply-changed | Total supply moved — a mint or a burn. |
| multiplier-changed | The corporate-action multiplier was applied. |
| multiplier-pending | A new multiplier is staged but not yet live. |
| admin-nonce-increment | A privileged EOA sent transactions. Severity branches on the role held. |
| coverage-changed | Liquidity coverage crossed a band boundary. |
| divergence | Fee tiers disagree on price beyond threshold. |
| band-narrower-than-costed-move | Posted liquidity is exhausted before 5% — the cost figure prices a move the pool cannot sustain in-band. |
| supply-anomaly | Pooled tokens exceed total supply. An upstream data-integrity problem. |
| token-scan-failed / -recovered | A token could not be read, and later could. |
30 requests per minute per IP, fixed window, no key required. Over quota returns 429 with Retry-After.
Responses are cached at the edge for 30 seconds. The indexer runs every minute, so a cached reading is at worst half a cycle behind one that was already up to a minute old. Because a cached response is shared, X-RateLimit-Remaining and X-RateLimit-Reset appear only on uncached responses — errors and 429s — where they describe you rather than whoever populated the cache. X-RateLimit-Limit is a policy constant and is always present.
Retention. Observations are kept for 3 days and then deleted, so?hours= is capped at 72. That is a storage limit, not a design preference: the indexer writes roughly 7,000 observations an hour, about 78 MB a day, and the database behind this service is a 512 MB tier. Asking for a longer window returns the capped one rather than a silent partial answer.
CORS is open to all origins for GET and OPTIONS. Integers that exceed IEEE-754 range — supplies, reserves — are serialised as decimal strings, not numbers. Parse them with BigInt; a token supply is around 1023 and reading it as a float loses precision silently.
The issuer verdict does not prove provenance. It proves the bytecode is the genuine proxy template, not that Robinhood deployed that particular instance. The check against the minting account is not built.
Our own record is not independently verifiable. There is no on-chain checkpoint of these observations, so you are trusting this service the way it asks you not to trust anyone else. That is a real gap and it is ours.
Discovery is a snapshot. 179 tokens were found by scanning Uniswap v3 pool creations and verifying bytecode. A token listed after that scan is not picked up automatically yet.
Funds are out of scope. 17 Robinhood-issued tokens representing ETFs, bonds and commodities are verified but deliberately not tracked. Only single-company equities appear here.