API reference

StockVotes API

Base URL is wherever you deploy it. Everything returns JSON with open CORS. Reads are open. Opening ballots takes an x-api-key header; webhooks are self-serve. Start at /api for a machine-readable index, or /openapi.json.

Overview

A ballot belongs to one Stock Token. A vote is a signature from a wallet that holds that token. Weight is the wallet's balance on Robinhood Chain, stored as an 18-decimal integer string so nothing gets rounded.

Three ballot kinds: proxy mirrors an item from a DEF 14A, question is an upvote-only Say-style question, poll is anything with 2 to 10 choices.

Endpoints

GET/api/assets?q=Stock Token registry (symbol, contract, multiplier, logo). Filter with q.
GET/api/assets/{symbol}One asset plus live bid/ask from Robinhood.
GET/api/assets/{symbol}/filingsDEF 14A proxy statements for the ticker, newest first. Empty for ETFs.
GET/api/proposals?symbol=&status=open|closedBallots with their current tally.
POST/api/proposalsOpen a ballot. Needs x-api-key.
GET/api/proposals/{id}One ballot, status and tally.
GET/api/proposals/{id}/votesEvery vote with weight, block and signature.
POST/api/proposals/{id}/votesCast or change a vote. Body: voter, choice, signature.
GET/api/voters/{address}?symbol=Voting power of an address for one token.
GET/api/voters/{address}Every Stock Token the wallet holds, with share equivalents, in one multicall.
GET/api/statsCounts: assets, ballots, votes, voters, current block.
GET/api/healthUpstream checks: Robinhood registry, RPC, EDGAR, SQLite. 200 or 503.
GET/apiMachine-readable index of every endpoint plus guidance for agents.
GET/openapi.jsonOpenAPI 3.1 with the same guidance in info.x-guidance.
GET/api/feed.json · /api/feed.xmlJSON Feed 1.1 and RSS 2.0 of ballots and results.
POST/api/verify/challengeOne-time message for a wallet to sign. Body: address, symbol, min?, audience?, ttl?.
POST/api/verifyTrade nonce + signature for a holder token (EdDSA JWT). 403 if the wallet holds less than min.
GET/api/verify?token=Introspect a holder token without a JWT library.
GET/api/verify/keysJWKS. Verify holder tokens offline with jose or any EdDSA-capable library.
POST/api/webhooksCreate a subscription. Self-serve, no key. The secret is returned once and manages it.
GET/api/webhooksList every subscription, secrets masked. Needs x-api-key.
GET/DELETE/api/webhooks/{id}?secret=Subscription plus its last 20 deliveries, or remove it.
POST/api/webhooks/{id}/test?secret=Send a ping now and return the delivery result.
POST/api/webhooks/tickRun the scheduler once, for an external cron.

Open a ballot

POST /api/proposals
x-api-key: <STOCKVOTES_ADMIN_KEY>

{
  "symbol": "AAPL",
  "kind": "proxy",
  "title": "Item 2: Advisory vote on executive compensation",
  "body": "From the 2026 proxy statement.",
  "choices": ["For", "Against", "Abstain"],
  "endsAt": "2027-02-20T00:00:00Z",
  "snapshot": true,
  "source": "https://www.sec.gov/Archives/edgar/data/320193/000130817926000008/aapl014016-def14a.htm"
}

snapshot: true pins the current block as the record date. Leave it out and each vote reads the latest balance. question ballots ignore choices and get a single Upvote. startsAt defaults to now.

Cast a vote

The voter signs this exact text with personal_sign (EIP-191). Lowercase the address.

StockVotes vote
proposal: <id>
choice: <index>
voter: <0xaddress lowercase>
POST /api/proposals/<id>/votes
{ "voter": "0xAbc…", "choice": 0, "signature": "0x…" }

201 → { "vote": { "weight": "12500000000000000000", "block": 64546809, … }, "tally": { … } }

One vote per address per ballot. Voting again replaces the earlier choice and re-reads the balance. Smart accounts that implement ERC-1271 verify fine.

How weight is read

Token contracts come from Robinhood's registry at api.robinhood.com/rhj/assets. We call balanceOf(voter) on the right contract through the public RPC at rpc.mainnet.chain.robinhood.com.

Public RPC serves recent state only. A ballot with a snapshot block older than that will fail with a historical-state error until you point ROBINHOOD_RPC_URL at an archive provider (Alchemy, QuickNode, dRPC).

To turn weight into shares multiply by the asset's multiplier. /api/voters/{address} already does this.

Verify a holder

Coming soon

Token gating without touching a chain. Your frontend asks for a challenge, the wallet signs it, you get back a JWT that says how much of which token the wallet held at which block. Nonces are single use and die after five minutes.

POST /api/verify/challenge
{ "address": "0xAbc…", "symbol": "TSLA", "min": "1", "audience": "yourapp.com", "ttl": 3600 }
→ { "nonce": "k3Jx…", "message": "StockVotes holder verification\naddress: 0xabc…\nsymbol: TSLA\nmin: 1\n…", "expiresAt": "…" }

POST /api/verify
{ "nonce": "k3Jx…", "signature": "0x…" }        // signature = personal_sign(message)
→ 200 { "ok": true, "token": "eyJhbGciOiJFZERTQSIs…", "claims": { "sub": "0xAbc…", "symbol": "TSLA", "balance": "12.5", "block": 64551020, "aud": "yourapp.com", "exp": … } }
→ 403 { "ok": false, "balance": "0.4", "min": "1" }
→ 410 challenge unknown, used or expired

Verify the token on your server with the public key at /api/verify/keys. Check aud so a token minted for someone else's app cannot be replayed against yours.

import { createRemoteJWKSet, jwtVerify } from "jose";
const keys = createRemoteJWKSet(new URL("https://<host>/api/verify/keys"));
const { payload } = await jwtVerify(token, keys, { audience: "yourapp.com" });
if (payload.symbol !== "TSLA" || Number(payload.balance) < 1) deny();

min is in tokens, decimal string, default 0 meaning any positive balance. ttl is seconds, 60 to 86400, default 3600. The live demo at /verify runs this exact flow with your wallet.

Tiers are just different min values. A feed that gives two free calls to anyone, the full feed to wallets holding 1 TSLA and a fast lane to wallets holding 10, issues three challenges with min 0, 1 and 10 and reads balance from whichever token comes back.

Webhooks

Coming soon

Push instead of poll. Subscribe a URL to events, optionally filtered to a few tokens. No account: the secret in the create response is the only credential, and it manages the subscription afterwards. Every delivery is signed, retried five times with backoff (1m, 5m, 30m, 2h, 12h) and logged. Limits: 25 subscriptions per host, https only in production, no private or raw-IP targets.

ballot.openedA ballot was created. data.proposal
ballot.closedendsAt passed. data.proposal, data.tally (final)
vote.castA signed vote was stored or changed. data.vote, data.tally, data.proposal
filing.newA new DEF 14A or DEFA14A landed on EDGAR for a token you follow. data.symbol, data.filing
corporate_action.newRobinhood published a split, dividend or merger. data.symbol, data.type, data.processDate, data.details
pingSent by the test endpoint.
POST /api/webhooks
{ "url": "https://yourapp.com/hooks/stockvotes",
  "events": ["ballot.closed", "filing.new", "corporate_action.new"],
  "symbols": ["AAPL", "TSLA"] }            // omit symbols for every token
→ 201 { "webhook": { "id": "5cce4299", "secret": "whsec_…", … } }   // secret shown once

GET    /api/webhooks/5cce4299        // subscription + last 20 deliveries
POST   /api/webhooks/5cce4299/test   // ping your receiver now
DELETE /api/webhooks/5cce4299        // all three take: x-webhook-secret: whsec_…

Send the secret in the x-webhook-secret header. ?secret= in the query string also works and is handy from a browser, but query strings end up in proxy and CDN access logs, so prefer the header anywhere those are kept.

Each POST to your URL carries x-stockvotes-event, x-stockvotes-delivery and x-stockvotes-signature: t=<unix>,v1=<hex> where v1 = HMAC-SHA256(secret, t + "." + rawBody). Respond 2xx within 10 seconds. Anything else is retried.

{ "id": "4e78420d-…", "event": "ballot.closed", "createdAt": "2026-09-16T14:01:16Z",
  "data": { "proposal": { "id": "aapl-cmp", "symbol": "AAPL", … }, "tally": { "choices": [ … ], "totalWeight": "…" } } }

Filing and corporate-action watchers prime on first run: existing filings are remembered, not announced. Filings are checked every 10 minutes with a one-hour EDGAR cache, so expect a new proxy statement within the hour.

Errors and limits

Every error has the same shape, so a client can branch on code without parsing prose.

{ "error": { "code": "forbidden", "message": "0xAbc… holds no NVDA at block 64550349" } }

400 bad_request     bad input, message names the field
401 unauthorized    signature mismatch, wrong x-api-key or webhook secret
403 forbidden       wallet holds nothing (votes) or less than min (verify)
404 not_found       unknown asset, ballot or webhook
409 conflict        ballot is not open
410 gone            verification challenge unknown, used or expired
429 rate_limited    see retry-after
502 upstream_error  Robinhood, RPC or EDGAR did not answer; retry shortly
503 unavailable     STOCKVOTES_ADMIN_KEY not set, or subscription capacity reached

Rate limits per IP: 30 a minute on verify and votes, 10 a minute on creating webhooks. Reads that mirror upstream data send cache-control: public, max-age=30, stale-while-revalidate=300. When Robinhood or EDGAR are down the last good registry keeps serving and /api/health turns 503, so point your monitor there.