Purser Report.
Interface · v1

Reading the manifest

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.

Read this part first

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.

01

Endpoints

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.

EndpointParametersReturns
/statusCounts by verdict, last indexed block and time, and the endpoint list.
/tokensverdictStart 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}hoursOne 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.
/poolstokenEvery indexed pool. ?token={address} to narrow.
/eventsseverity, limit, afterId, sinceThe 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.

02

Fields that can refuse to answer

These are the ones worth understanding before you trade on anything here. Each is null in a specific, meaningful circumstance.

FieldTypeWhat null means
priceUsdnumber | nullNull 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.
rawPoolPriceUsdnumber | nullOn 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.
bandPctnumber | nullNull 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.
costToMove5pctUsdnumberQuote-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.
coverageRationumberFraction 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.
03

Verdicts

VerdictMeansDoes not mean
IssuerRuntime 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.
UnverifiedCarries the shared beacon marker but an unrecognised template.An accusation. It is withheld judgement, not a finding.
CounterfeitNo beacon marker, yet claims a real listed ticker.
UnaffiliatedNo marker and no ticker collision. Not our concern.
04

Events

/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.

Polling without missing anything

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 minute

A 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.

KindFires when
liquidity-appearedA token's first pool holds liquidity. It is now tradeable, and manipulable.
liquidity-drainedEvery pool has emptied. Positions cannot be exited on chain at any price.
supply-changedTotal supply moved — a mint or a burn.
multiplier-changedThe corporate-action multiplier was applied.
multiplier-pendingA new multiplier is staged but not yet live.
admin-nonce-incrementA privileged EOA sent transactions. Severity branches on the role held.
coverage-changedLiquidity coverage crossed a band boundary.
divergenceFee tiers disagree on price beyond threshold.
band-narrower-than-costed-movePosted liquidity is exhausted before 5% — the cost figure prices a move the pool cannot sustain in-band.
supply-anomalyPooled tokens exceed total supply. An upstream data-integrity problem.
token-scan-failed / -recoveredA token could not be read, and later could.
05

Limits and caching

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.

06

What this does not do

Known limitations

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.