exdate API reference
Every example below is a real response, captured from the indexer running against Robinhood Chain
mainnet on 2026-09-02. Nothing here is illustrative: if a field is null in an example, that is
what the chain said.
Base URL: https://api.exdate.me, or wherever you run the indexer yourself —
http://localhost:42069 under pnpm dev, or the box deploy/install-api.sh sets up (see Hosting
in the README). The public instance is one small machine with no availability commitment. :chain
accepts the key (robinhood) or the id (4663).
Two rules hold everywhere:
- every bigint is a decimal string —
"1005101770003214918", never a JS number; - anything exdate has not observed is
null— never0, never a default, never an absent key.
A third rule governs prices: Chainlink's Robinhood feeds publish
Token Price = Underlying Equity Price × Multiplier. The multiplier is already in the answer.
Every response that carries a price says so in answerIncludesMultiplier / includesMultiplier.
Keys and quotas
Every route but /v1/health is counted. Without a key a caller shares an anonymous quota per client
address (60 requests a minute by default); with one, the quota the operator attached to it. The key
travels as Authorization: Bearer <key> or X-Api-Key: <key>, and three headers come back on every
answer: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (a Unix timestamp). Past
the quota the answer is 429 with Retry-After and a JSON body:
{ "error": "rate limited", "limitPerMinute": 60, "retryAfterSeconds": 41 }
An unknown key is 401 {"error":"unknown API key"}, never a silent downgrade to anonymous, so a typo
shows up as a refusal and not as a smaller quota than promised.
GET /v1/me
What the API knows about the caller, without spending a request:
{ "tier": "key", "label": "acme", "limitPerMinute": 600, "remaining": 598, "resetAt": "2026-09-03T16:41:00.000Z", "keysConfigured": 2 }
label is the name the operator gave the key; the key itself is never echoed. tier is anonymous
without a key, and label is then null.
GET /v1/health
{ "ok": true, "registryGeneratedAt": "2026-09-02T15:14:00.463Z" }
registryGeneratedAt dates the token registry snapshot, not the process. Uncounted, so a probe
never eats into a quota.
GET /v1/chains
The chains this deployment serves. Multi-chain from day one: Base / Coinbase is a planned second issuer, so nothing keys on a single chain.
GET /v1/:chain/tokens · GET /v1/:chain/tokens/:address
The list carries count, polled (how many have been read at least once) and every token. One
token, in full:
{
"chainId": 4663,
"address": "0x92fd66527192e3e61d4ddd13322aa222de86f9b5",
"symbol": "SGOV",
"name": "iShares 0-3 Month Treasury Bond • Robinhood Token",
"decimals": 18,
"isin": "US46436E7186",
"issuer": "Robinhood Assets (Jersey) Limited",
"registry": { "source": "robinhood:/rhj/assets", "generatedAt": "2026-09-02T15:14:00.463Z" },
"state": "indexed",
"multiplier": {
"current": "1005101770003214918",
"currentDecimal": "1.005101770003214918",
"scheduled": null,
"lastChangeEffectiveAt": "2026-09-01T00:00:26.000Z",
"totalSupplyUI": "14295704240018345085764",
"sampledAt": "2026-09-02T18:34:20.000Z"
},
"events": {
"count": 3,
"last": {
"effectiveAt": "2026-09-01T00:00:26.000Z",
"applied": true,
"announcedAt": "2026-08-31T23:50:51.000Z",
"announcementLeadSeconds": 575,
"announcedTx": "0xf33317c324c4d1d53278dd5c0fcb6ca3afeea41ccf39441ecada548148f5f4e7",
"announcementCount": 1,
"source": "onchain:scan",
"oldMultiplier": "1002981519346766532",
"newMultiplier": "1005101770003214918",
"stepBps": 21.1394787994627
}
},
"feed": {
"proxy": "0xa0df4ee0fff975306345875e3548fcc519577a11",
"verified": false,
"decimals": 8,
"answer": "10092226805",
"price": "100.92226805",
"updatedAt": "2026-09-02T00:01:11.000Z",
"ageSeconds": 67444,
"beyondHeartbeat": false,
"status": "live",
"oraclePaused": false,
"includesMultiplier": true
}
}
Four fields carry the traps this API exists to avoid:
multiplier.scheduledis non-null only while a change is genuinely pending —effectiveAtin the future andnewUIMultiplier != uiMultiplier. Outside that window the on-chain views are retrospective:effectiveAt()holds the timestamp of the last change that already happened. Reading it as "pending" reports phantom dividends.multiplier.lastChangeEffectiveAtisnullwhile a change is pending, because the timestamp then belongs toscheduledand is not a change that happened.events.last.appliedis derived from the clock. Nothing is emitted on chain when a multiplier change takes effect — the announcement is the only log.feed.verifiedisfalseon every row today: the token → feed pairing is derived from the ticker, and no first-party statement links them.
state is not_yet_polled until the poller has read the ERC-8056 views once — a token with no data
says so rather than showing zeros. A single token 404s with {"error":"unknown token", …}.
GET /v1/:chain/events
Every UIMultiplierUpdated log, newest first, with source naming which scanner found it
(onchain:indexer, onchain:scan, onchain:sweep — all three are real logs with real transaction
hashes). announcementCount is above 1 where a schedule was re-announced; CRWD emitted the same
(newMultiplier, effectiveAt) twice, eleven hours apart.
GET /v1/:chain/reconciliations
The body is { chainId, counts, returned, reconciliations }. ?token= narrows to one address,
?status= to one state; returned is the size of the filtered list and counts is always the whole
picture, so a filtered view cannot read as the total.
{
"id": "0x00000000000000000000000000000000…63fe4a67:2026-08-06",
"symbol": "SGOV",
"status": "matched",
"confidence": "medium",
"feedCorroboratedBy": ["multiplier-step", "traded-price"],
"declared": {
"type": "CORPORATE_ACTION_TYPE_CASH_DIVIDEND",
"status": "CORPORATE_ACTION_STATUS_COMPLETED",
"processDate": "2026-08-06",
"grossPerShare": "0.306812",
"source": "robinhood:/rhj/corporate-actions"
},
"observed": {
"effectiveAt": "2026-08-07T15:10:24.000Z",
"oldMultiplier": "1000957519890990718",
"newMultiplier": "1002981519346766532",
"stepBps": 20.2206328995484,
"lagDays": 1,
"source": "onchain:UIMultiplierUpdated"
},
"price": {
"value": "100.57120681",
"feed": "0xa0df4ee0fff975306345875e3548fcc519577a11",
"roundId": "18446744073709551646",
"updatedAt": "2026-08-07T00:01:33.000Z",
"stalenessSeconds": 54531,
"atPhaseFloor": false,
"source": "chainlink:getRoundData"
},
"result": {
"expectedStepBps": 30.53615327227618,
"receivedPerShare": "0.203166809056096883",
"impliedHaircutBps": 3378,
"impliedReinvestPrice": "151.732144846392134274"
}
}
Read it as: the issuer declared $0.306812 per underlying share; the multiplier moved 20.22 bps; at
the equity price implied by the Chainlink round in force, that step delivered $0.2032 — 33.78 %
did not arrive. id is issuerId:processDate, because the issuer's id names a dividend series,
not a payment: SGOV, SHY and BND reuse theirs every month.
impliedReinvestPrice is the price the step would have needed for the dividend to have arrived in
full. It needs no oracle, so it is the discriminator for the 159 of 194 tokens with no Chainlink
feed: a genuine reinvestment implies a price near spot (the two matched rows land at 1.47× and
1.51× today's price), and every anomaly is far outside.
Statuses: matched, anomaly, pending (declared, nothing on chain), unmatched (a step with no
issuer row — expected before ~2026-08-05, where the issuer's feed ends) and
unsupported_action_type (a split matched to a step: no per-share rate to reconcile against).
confidence starts at low: the token → feed pairing is inferred from a ticker, and no first-party
statement links them. It reaches medium from three observed events once the pairing is
corroborated by behaviour, and the row's own feedCorroboratedBy says by which behaviour — never
merge the two, because they are not the same claim:
multiplier-step— this token's own step was seen moving this feed by the step's own size, above the feed's round-to-round noise, with no other mapped feed closer. Causal, and true of SGOV alone.traded-price— the token's on-chain traded price repeatedly sits far closer to this feed's answer than to any other mapped feed. Identification, and weaker: two unrelated assets can trade at one price. True of 23 of 35 pairings today.
high is reserved for a first-party address-level link, which nothing has today. A row refused for
a reason of its own — no price at effectiveAt, a non-positive rate — reads low and still reports
feedCorroboratedBy, because that is a fact about the pairing and not about the event. An empty
array means the pairing rests on a ticker match alone; the same list is served per token under
feed.corroboratedBy on GET /v1/:chain/tokens/:address.
GET /v1/:chain/tokens/:address/yield
A ledger of distributions, not a rate. One row per observed step and per declared action.
observed.netYieldBpsandresult.netYieldBpsexist only where a step is paired with an issuer cash dividend — a split produces the same arithmetic identity with no economic gain, so it never gets the name.price.underlyingPricestates its own derivation (tokenPrice / multiplierBefore).totalsisnullunless the ledger closes: the last applied step'snewMultiplierequalsuiMultiplier()read at the head. Growth is split intodividendGrowthBps(explained by paired dividends) andunexplainedGrowthBps, compounded, and the two multiply back to the whole.notComputedlists every refused figure with a machine-readable reason:annualizedYield: no_observed_schedule,trailingTwelveMonthYield: window_shorter_than_period,forwardYield: delivery_not_demonstrated. Nothing in the shape is per annum, trailing or forward — a documented refusal cannot be mistaken for a value that has not arrived yet.
SGOV on 2026-09-02: growth 51.02 bps = 20.22 explained ⊕ 30.73 unexplained, over three steps.
GET /v1/:chain/tokens/:address/pending
What is owed and has not arrived, with each state kept apart:
state |
Meaning | Certainty |
|---|---|---|
scheduled (top level) |
a log is on chain, effectiveAt in the future |
certain, ~9 minutes out |
upcoming |
declared, process date has not arrived | nothing owed yet |
awaiting |
declared, process date passed, still inside the 4-day pairing window | normal |
overdue |
declared, past the window, issuer still says in progress | late |
declared_complete_not_on_chain |
the issuer says COMPLETED, the multiplier has not moved | anomaly |
upcoming and awaiting are separated because awaiting carries a claim — the chain should move
within the window — that is simply false for a date that has not arrived. summary counts them
apart (declaredUpcoming, declaredAwaiting).
BND, four weeks after its own issuer marked the dividend complete:
{
"state": "indexed",
"multiplier": { "current": "1000000000000000000", "currentDecimal": "1" },
"scheduled": null,
"declared": [{
"state": "declared_complete_not_on_chain",
"issuerStatus": "CORPORATE_ACTION_STATUS_COMPLETED",
"processDate": "2026-08-05",
"daysSinceProcessDate": 28,
"windowDays": 4,
"grossPerUnderlyingShare": "0.25155",
"grossPerToken": "0.25155",
"projection": null,
"note": "the issuer marks this action completed; the multiplier has not moved"
}],
"summary": { "declaredOverdue": 1, "declaredCompleteNotOnChain": 1, "longestOverdueDays": 28 }
}
grossPerToken is rate × uiMultiplier — two known numbers, no price, so it is stated for all 194
tokens. projection.stepBpsIfPaidInFull is what a full payment would produce at the latest
round; it carries notAMeasurement: true and is null without a feed. history reports the
haircut measured on this token's own past distributions and applies it to nothing. The landing date
and the surviving fraction are refused under notComputed.
GET /v1/status
{ observedAt, chains: [ { chainId, name, tokens, tokensWithFeed, tokensWithoutFeed, live, stale, paused, unknown, feeds } ] } — one entry per chain served. feeds carries the health of every token
that has a feed (symbol, token, feed, verified, status, ageSeconds, beyondHeartbeat,
updatedAt), and tokensWithoutFeed lists the rest, because a caller must be able to see that most
Stock Tokens have no oracle at all rather than infer it from a short list. Off-hours these feeds hold
their last answer with no heartbeat, so updatedAt and ageSeconds are the only honest signals;
status is live | stale | paused | unknown.
GET /v1/calendar
{ observedAt, chains: [ { chainId, upcomingCorporateActions, scheduledMultiplierUpdates } ] }. Two
different horizons in one response: upcomingCorporateActions from the issuer runs weeks ahead;
scheduledMultiplierUpdates is what is genuinely pending on chain, which is about nine minutes.
GET /v1/webhooks
The event catalogue, the signing scheme (HMAC-SHA256 over `${t}.${rawBody}`, 300 s
tolerance), the header names, the retry schedule, and endpointsConfigured — which is what tells an
operator whether silence means "nothing happened" or "nobody is listening". Each event states what
exdate observed to send it; multiplier.applied says outright that it is a poller observation,
because no log fires when a change takes effect.
GET /v1/:chain/webhooks/events
The outbox: every event recorded, with each delivery's attempts, responseStatus and error.
Events are recorded whether or not an endpoint is configured, so this is a usable event log on its
own — and the honest answer to "did you send it?". signedBody is the exact string the signature
covers, so a delivery can be replayed and verified byte for byte. Delivery rows carry the endpoint
id and host only; the configured URL and its secret never leave the process.
?type=, ?status= and ?limit= filter; the counts stay whole.
Verifying a webhook
import { webhookFromRequest } from '@exdate/sdk'
const result = await webhookFromRequest(request, { secret: process.env.EXDATE_WEBHOOK_SECRET! })
if (!result.ok) return new Response(result.reason, { status: 400 })
if (result.event.type === 'dividend.reconciled') {
result.event.data.impliedHaircutBps // 3378
}
Verify the raw bytes before parsing: key order and whitespace change the bytes, and the signature covers bytes. See the SDK reference for the rest.
Running your own
pnpm dev runs it on a local PGlite database. If Ponder stops with Schema "public" was previously
used by a different Ponder app, the local database belongs to an earlier build: delete
packages/indexer/.ponder/ and start again. For a hosted instance see Hosting in the README:
docker compose up -d brings up Postgres and the indexer, and EXDATE_API_KEYS turns on keys.