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}/filings | DEF 14A proxy statements for the ticker, newest first. Empty for ETFs. |
| GET | /api/proposals?symbol=&status=open|closed | Ballots with their current tally. |
| POST | /api/proposals | Open a ballot. Needs x-api-key. |
| GET | /api/proposals/{id} | One ballot, status and tally. |
| GET | /api/proposals/{id}/votes | Every vote with weight, block and signature. |
| POST | /api/proposals/{id}/votes | Cast 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/stats | Counts: assets, ballots, votes, voters, current block. |
| GET | /api/health | Upstream checks: Robinhood registry, RPC, EDGAR, SQLite. 200 or 503. |
| GET | /api | Machine-readable index of every endpoint plus guidance for agents. |
| GET | /openapi.json | OpenAPI 3.1 with the same guidance in info.x-guidance. |
| GET | /api/feed.json · /api/feed.xml | JSON Feed 1.1 and RSS 2.0 of ballots and results. |
| POST | /api/verify/challenge | One-time message for a wallet to sign. Body: address, symbol, min?, audience?, ttl?. |
| POST | /api/verify | Trade 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/keys | JWKS. Verify holder tokens offline with jose or any EdDSA-capable library. |
| POST | /api/webhooks | Create a subscription. Self-serve, no key. The secret is returned once and manages it. |
| GET | /api/webhooks | List 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/tick | Run 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 soonToken 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 expiredVerify 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 soonPush 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.opened | A ballot was created. data.proposal |
| ballot.closed | endsAt passed. data.proposal, data.tally (final) |
| vote.cast | A signed vote was stored or changed. data.vote, data.tally, data.proposal |
| filing.new | A new DEF 14A or DEFA14A landed on EDGAR for a token you follow. data.symbol, data.filing |
| corporate_action.new | Robinhood published a split, dividend or merger. data.symbol, data.type, data.processDate, data.details |
| ping | Sent 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 reachedRate 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.