@exdate/sdk

Typed client and webhook verifier for the exdate API — the corporate-action layer for Robinhood Chain Stock Tokens.

Nothing is computed in this package. Every number it returns traces back to a log, an ERC-8056 view call or the issuer's own feed, and the two API rules hold throughout: every bigint is a decimal string, and anything exdate has not observed is null — never 0, never absent.

pnpm add @exdate/sdk

Until the package is on npm, add it from the workspace ("@exdate/sdk": "workspace:*") or from a checkout. It depends on @exdate/core only; installing it never pulls in the server.

Reading

import { createClient } from '@exdate/sdk'

// the public instance, or the host your own indexer runs on
const exdate = createClient({ baseUrl: 'https://api.exdate.me', apiKey: process.env.EXDATE_API_KEY })

const SGOV = '0x92FD66527192E3e61d4DDd13322Aa222DE86F9B5'

const { token } = await exdate.token(SGOV)
token.multiplier.currentDecimal // '1.005101770003214918'
token.multiplier.scheduled      // null unless a change is genuinely pending
token.events.last?.applied      // derived from the clock: no log fires at effect

chain defaults to robinhood and accepts the key or the id (4663). Every method returns the route's response as-is:

Method Route
health() chains() /v1/health /v1/chains
tokens() token(addr) tokenOrNull(addr) /v1/:chain/tokens[/:addr]
events() /v1/:chain/events
reconciliations({ token?, status? }) /v1/:chain/reconciliations
yield(addr) /v1/:chain/tokens/:addr/yield
pending(addr) /v1/:chain/tokens/:addr/pending
status() calendar() /v1/status /v1/calendar
webhooks.catalogue() webhooks.events({ … }) /v1/webhooks, /v1/:chain/webhooks/events

Keys and quotas

Without apiKey the anonymous quota applies (60 requests a minute per address by default). With one, the quota the operator attached to the key. exdate.me() reports the tier and what is left without spending a request, and a 429 arrives as an ExdateError whose body carries retryAfterSeconds.

Errors

A non-2xx answer throws ExdateError carrying status, url and the parsed body, so "this token does not exist" is never silently the same as "the indexer is down":

try {
  await exdate.yield('0x0000000000000000000000000000000000000001')
} catch (error) {
  if (error instanceof ExdateError && error.isNotFound) { /* 404 */ }
}

// or, where a missing token is expected:
const maybe = await exdate.tokenOrNull(address) // null on 404, still throws on 500

The two endpoints worth knowing

yield(addr) is a ledger, not a rate. One row per observed multiplier step and per declared action. A step is called yield only when it is paired with an issuer cash dividend — so a split can never read as one. totals exists only when the ledger closes against uiMultiplier() read at the head. Everything it refuses to compute is listed with a reason code:

const ledger = await exdate.yield(SGOV)
ledger.totals?.dividendGrowthBps    // 20.22 — explained by a paired dividend
ledger.totals?.unexplainedGrowthBps // 30.73 — steps with no issuer row behind them
ledger.notComputed.map((n) => `${n.field}: ${n.reasonCode}`)
// [ 'annualizedYield: no_observed_schedule',
//   'trailingTwelveMonthYield: window_shorter_than_period',
//   'forwardYield: delivery_not_demonstrated', … ]

pending(addr) separates three states that are usually conflated:

const owed = await exdate.pending(BND)
owed.scheduled                      // a log is on chain; ~9 minutes of warning
owed.declared[0].state              // 'upcoming' | 'awaiting' | 'overdue' | 'declared_complete_not_on_chain'
owed.declared[0].grossPerToken      // rate x uiMultiplier — needs no price
owed.declared[0].projection         // null without a feed; `notAMeasurement: true` with one
owed.summary.longestOverdueDays     // 28 for BND on 2026-09-02

declared_complete_not_on_chain is the sharp one: the issuer's own feed says the action is COMPLETED while the multiplier has not moved.

Webhooks

The verifier is not a reimplementation — it is the same function the sender signs with. Verify the raw bytes, before parsing:

import { webhookFromRequest } from '@exdate/sdk'

export async function POST(request: Request) {
  const result = await webhookFromRequest(request, { secret: process.env.EXDATE_WEBHOOK_SECRET! })
  if (!result.ok) return new Response(result.reason, { status: 400 })

  // Narrowing on `type` narrows `data` with it.
  if (result.event.type === 'dividend.reconciled') {
    result.event.data.impliedHaircutBps // 3378 — 33.78 % of SGOV's August dividend withheld
  }
  return new Response('ok')
}

parseWebhook({ secret, header, body }) does the same from parts, and verifyWebhook(…) returns { valid: false, reason }malformed_header, timestamp_outside_tolerance, signature_mismatch — rather than throwing, so a handler can log which check failed without touching the secret.

Three things the scheme guarantees, and one it asks of you:

  • the timestamp is inside the signed material and checked against a 300 s window, so a captured delivery cannot be replayed;
  • secret accepts an array, so a rotation can accept both while deliveries are in flight;
  • event ids are deterministic — a redelivery, or the same occurrence noticed by both the live indexer and the poller, carries the id you already have. Key your bookkeeping on it.
  • do not re-encode the body before verifying. Key order and whitespace change the bytes; the signature covers bytes. webhookFromRequest reads the body itself for exactly this reason.

The seven event types and their payloads are typed in @exdate/core/webhooks (WebhookData), and the indexer that emits them is compiled against the same map — a payload that drifts from what this package promises is a build error, not a surprise in production.

Types

YieldLedger and PendingView are derived from the functions that produce them, so they cannot drift. The rest (TokenView, ReconciliationView, MultiplierEventView, …) are declared here, so that installing the SDK does not drag in the server and its HTTP framework; test/contract.assert.ts compiles them against the API's serialisers in both directions, in the repo where both exist.

One helper, because every consumer needs it and getting it backwards is the classic mistake:

import { underlyingSharesPerToken } from '@exdate/sdk'
underlyingSharesPerToken(token) // 1.00510177 for SGOV, or null if never polled

Never multiply a Chainlink answer by the multiplier. Robinhood's feeds publish Token Price = Underlying Equity Price x Multiplier; the multiplier is already in the answer. Every response that carries a price says so in its own answerIncludesMultiplier field.