StockPay API
Accept payments settled in tokenized equities or ETH. A Stripe-shaped HTTP API — payments, payment links, hosted checkout, signed webhooks, idempotency — over a settlement layer that verifies every transfer on-chain itself, across Robinhood Chain and Ethereum.
- Base URL
- stockpay.tech
- Dependencies
- 0
- Endpoints
- 31
- Assets
- 194 equity · 3 crypto
- Chain
- 4663 mainnet
What actually works, and what doesn't
Read this before you plan an integration. The API surface is complete; what differs by asset is where custody lives.
Robinhood Chain (chain id 4663) is a live, EVM-compatible Arbitrum L2
carrying tokenized equities as ordinary ERC-20 “Robinhood Tokens”. AAPL, TSLA, NVDA,
MSFT, AMZN, GOOGL and SPY all have real contracts with 18 decimals, and they emit
standard Transfer events. StockPay settles them through the same on-chain
watcher as USDC — so the equity rail is genuinely live, not simulated.
Verified on-chain: block scanning for native transfers, ERC-20 log filtering for tokens, configurable confirmation depth, and reorg handling. Tested against live Sepolia and live Robinhood Chain.
There is no public Robinhood API for debiting a user's brokerage account. Their Crypto Trading API is own-account only — API key plus Ed25519 signing, no third-party OAuth. Untokenized positions still move broker-to-broker over ACATS in days. What changed is that tokenized equities exist on a public chain; the brokerage account itself remains closed to third parties.
StockPay accepts stock tokens the payer already holds in a self-custody wallet. Acquiring or redeeming those tokens against real shares happens through Robinhood, not through StockPay. Eligibility to hold them is set by Robinhood and varies by jurisdiction.
What this codebase will not do: drive Robinhood's private endpoints with a user's login, password, or MFA code. That breaks their terms and turns your server into a store of brokerage credentials. Everything here uses public chains and public APIs.
| Asset | Chain | Standard | Status |
|---|---|---|---|
| 194 tokenized equities and ETFs | Robinhood Chain (4663) | ERC-20, 18 decimals | Live on-chain |
| ETH | Ethereum / Sepolia | Native | Live on-chain |
| USDC | Ethereum / Sepolia | ERC-20, 6 decimals | Live on-chain |
All 194 are settlement assets — equities, ETFs, and index funds. The catalog is built from
Robinhood's live registry; refresh the snapshot with npm run tokens.
Quickstart
Create a merchant and keep the keys. The secret is shown once.
curl -s -X POST https://stockpay.tech/v1/merchants \ -H 'content-type: application/json' \ -H 'x-platform-key: YOUR_PLATFORM_KEY' \ -d '{"name":"Acme Robotics"}'
Prefer to run it yourself? No install step, no build — Node 22.5+ only, for its built-in SQLite.
cd server && npm start # everything on :4242That returns a secret key and a publishable key. The secret is shown once. Now take a payment end to end:
import StockPay from './sdk/stockpay.js'; const stockpay = new StockPay('sk_test_…'); // 1. Create the payment. Amount is an integer in cents. const payment = await stockpay.payments.create({ amount: 125000, currency: 'usd', description: 'Pro Plan · Annual', allowed_assets: ['AAPL', 'TSLA', 'ETH'] }, { idempotencyKey: 'order_5512' }); // 2. Payer picks an asset. This locks a binding rate for 15 minutes. await stockpay.payments.selectAsset(payment.id, { asset: 'AAPL' }); // 3. Confirm — opens the settlement channel, returns next_action. const confirmed = await stockpay.payments.confirm(payment.id); console.log(confirmed.next_action); // 4. Settlement is observed, never asserted by the client. const settled = await stockpay.waitForSettlement(payment.id); console.log(settled.status); // 'succeeded'
Prefer not to build a UI? Create a payment link and send the URL — that is the whole integration.
Authentication
Bearer tokens. Two kinds, and the difference matters.
| Key | Where it belongs | Can do |
|---|---|---|
| sk_test_… sk_live_… | Server only. Never ship it to a browser. | Everything. |
| pk_test_… pk_live_… | Safe in page source. | Read the asset catalog, read one checkout session, and quote/confirm one payment — only when it also presents that payment's client_secret. |
curl https://stockpay.tech/v1/payments \
-H "Authorization: Bearer sk_test_…"A publishable key hitting a secret-only route returns 403 insufficient_permissions. Without the client_secret check, a publishable key would be a merchant-wide write credential sitting in page source — so quote and confirm require it.
Objects are merchant-scoped. Reading another merchant's payment returns 404, not 403 — a missing object and someone else's object are deliberately indistinguishable, so ids can't be probed.
Payment lifecycle
Every status change is checked against this machine. Centralising it is what stops a late webhook or a racing settlement watcher from resurrecting a canceled payment or succeeding one twice.
Two properties worth relying on:
- Confirm never means paid.
confirmonly opens the settlement channel. A payment reachessucceededonly when a transfer is independently observed. - Self-transitions are no-ops, not errors. Rails redeliver; re-asserting a status a payment already holds succeeds quietly, so a duplicate callback never 500s.
Payments
| Method | Path | Notes |
|---|---|---|
| POST | /v1/payments | Idempotent. Returns client_secret. |
| GET | /v1/payments | Cursor paginated; status filter. |
| GET | /v1/payments/:id | Includes attached refunds. |
| POST | /v1/payments/:id/select_asset | Locks the rate. Publishable + client_secret OK. |
| POST | /v1/payments/:id/confirm | Opens settlement. Returns next_action. |
| POST | /v1/payments/:id/cancel | Blocked once terminal. |
| POST | /v1/payments/:id/refunds | Idempotent. Partial supported. |
Amounts are integers, always
amount is a whole number of minor units — 125000 is $1,250.00. Sending 1250.00 is rejected with a message saying so. Every internal calculation is integer or BigInt; asset quantities cross the wire as decimal strings. A float anywhere in this path eventually mis-settles a payment.
Quotes round in the merchant's favour
Converting an invoice to a quantity rounds up, so a settled payment never lands short. $12.50 of a $3.00 asset quotes 4.166667, not 4.166666.
Locked rates expire
A quote holds for QUOTE_TTL_SECONDS (default 900). Between quoting and settling the asset moves, and an unbounded lock is a free option written against the merchant. On expiry the payment drops back to requires_payment_method, emits payment.quote_expired, and confirm returns 422 quote_expired.
{
"id": "pay_04cjvf63b03t73tabhqgt3x1",
"object": "payment",
"amount": 125000,
"amount_received": 0,
"currency": "usd",
"status": "requires_payment_method",
"allowed_assets": ["AAPL", "TSLA", "ETH"],
"settlement": null,
"client_secret": "pay_04cj…_secret_9f2b…",
"livemode": false,
"metadata": { "order_id": "ord_5512" }
}After confirm, the settlement block is populated and next_action tells the client what to do — one of send_onchain_transfer, redirect_to_rail, or await_settlement.
Assets and quotes
197 settlement assets, all on Robinhood Chain mainnet (4663). One chain, one watcher, one confirmation policy. ETH is that chain's native gas token, so it settles there directly — there is no bridge in the path.
| Asset | Count | Standard | Priced by |
|---|---|---|---|
| Tokenized equities & ETFs | 194 | ERC-20, 18 decimals | Robinhood market data |
| ETH | 1 | Native gas token | Coinbase spot |
| WETH | 1 | ERC-20, 18 decimals | Proxied to ETH |
| USDG | 1 | ERC-20, 6 decimals | 1:1 USD peg |
Both contracts are deployed and verified on chain, but showed zero transfers in 40,000 blocks when this was written. The equity tokens and native ETH are where the activity is. Treat WETH and USDG as available rather than proven.
No exchange lists USDG, so it quotes at $1.00 with price_source: "peg". That is
an assumption about a fiat-backed stablecoin, not an observed rate. A depeg would not show up
in your quotes — watch it separately if you accept meaningful volume in it.
Two price feeds, never crossed
Equities come from Robinhood's market-data endpoint; ETH from Coinbase spot. The namespaces
are separate and must stay that way — ETH on the equity feed is
Ethan Allen Interiors at about $23, not Ethereum. Pricing crypto there would mis-settle
every payment.
Searching the catalog
# open on a curated set (what checkout shows first) GET /v1/assets?featured=1&amount=125000 # search ticker and company name GET /v1/assets?q=quantum → QBTS, QUBT, XNDU GET /v1/assets?q=gold → GLD # price an explicit set, or narrow by kind GET /v1/assets?symbols=AAPL,PLTR,ETH&amount=125000 GET /v1/assets?kind=equity&limit=100
Each row carries contract_address, chain_id and isin
so you can verify the token you are accepting. An asset with no sourceable price returns
available: false and cannot be quoted.
allowed_assets
Omit it, or pass an empty array, and any settlement asset is accepted — checkout opens on a curated set and searches the rest. Pass a list to restrict a payment to exactly those.
Payment links
A reusable URL that mints a fresh payment per visitor. No frontend required — this is the entire integration for invoicing, one-off sales, or a "pay me" button.
curl -s -X POST https://stockpay.tech/v1/payment_links \ -H "Authorization: Bearer sk_test_…" \ -H 'content-type: application/json' \ -d '{"amount":125000,"description":"Pro Plan · Annual", "allowed_assets":["AAPL","TSLA","NVDA","ETH","USDC"]}' # → { "url": "https://stockpay.tech/pay/zax9rr52q2kq", … }
Opening that URL creates a payment plus a checkout session and 302s to the hosted page. Deactivating a link stops new sessions; sessions already open still complete.
Hosted checkout
A prebuilt page at /c/:session_id — asset picker, live quantities, rate lock, settlement polling, success state. The server injects the session id and publishable key into the page, so nothing secret is exposed.
const session = await stockpay.checkout.sessions.create({ amount: 125000, currency: 'usd', success_url: 'https://acme.test/thanks', cancel_url: 'https://acme.test/cart' }); redirect(session.url); // → /c/cs_…
Building your own UI instead? Use the publishable key plus client_secret against GET /v1/checkout/sessions/:id/state, then select_asset and confirm. That endpoint returns only what a checkout needs to render — never the merchant's other payments.
Refunds
Full or partial, against a succeeded payment. Over-refunding is rejected with refund_exceeds_balance and the remaining amount in the message.
A refund records the obligation against the payment and emits refund.succeeded. Actually returning value to the payer is a payout, which depends on your rail and custody arrangement — this API deliberately does not imply it has moved money back.
Idempotency
Send Idempotency-Key on any POST that creates something. Retrying with the same key replays the original response byte-for-byte and sets Idempotent-Replayed: true — no duplicate charge.
| Situation | Result |
|---|---|
| Same key, same body | Original response replayed. |
| Same key, different body | 409 idempotency_key_reuse. Guessing which body was meant is how duplicate payments happen. |
| Same key, first attempt still in flight | 409 idempotency_in_progress. Retry shortly. |
| Key used on a request that failed | Key is released. The same key works on retry — a validation error must not burn it. |
Webhooks
Register an endpoint, get a signing secret once. It is never listable again.
curl -s -X POST https://stockpay.tech/v1/webhook_endpoints \ -H "Authorization: Bearer sk_test_…" \ -H 'content-type: application/json' \ -d '{"url":"https://acme.test/hooks","enabled_events":["payment.*"]}'
Verifying the signature
Header shape is deliberately identical to Stripe's, so existing verification code ports over:
StockPay-Signature: t=1787351525,v1=b3976fbb679ef99…
The signed payload is ${timestamp}.${rawBody}. Signing the timestamp alongside the body is what makes a replayed capture detectable.
import { constructEvent } from './sdk/stockpay.js'; // The RAW body — a re-serialized object will not match. app.post('/hooks', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = await constructEvent({ payload: req.body, signature: req.headers['stockpay-signature'], secret: process.env.STOCKPAY_WEBHOOK_SECRET }); } catch (err) { return res.status(400).send('invalid signature'); } if (event.type === 'payment.succeeded') { fulfil(event.data.object.metadata.order_id); } res.json({ received: true }); });
Delivery and retries
Delivery is queued, never awaited — a slow endpoint must not add latency to the API call that triggered it. Any 2xx is success. Backoff defaults to 10s, 60s, 5m, 30m, 2h. A 4xx other than 408/429 is treated as permanent: retrying a request the endpoint explicitly rejected just burns both sides' capacity.
GET /v1/events/:id shows per-attempt delivery state for debugging.
Event types
| Event | Fires when |
|---|---|
| payment.created | A payment object exists. |
| payment.requires_confirmation | An asset was chosen and a rate locked. |
| payment.processing | Settlement channel opened. |
| payment.succeeded | Transfer observed and confirmed. Fulfil here. |
| payment.failed | The rail rejected, or settlement could not complete. |
| payment.canceled | Canceled before settling. |
| payment.quote_expired | Locked rate aged out; payment reverted. |
| checkout_session.completed | The session's payment succeeded. |
| refund.succeeded | A refund was recorded. |
Filters accept exact types, a payment.* prefix wildcard, or *. An unknown type is rejected at registration rather than silently matching nothing.
Errors
Every failure returns the same envelope, so you branch on type and code instead of parsing prose.
{
"error": {
"type": "payment_error",
"code": "asset_not_allowed",
"message": "AAPL is not in this payment's allowed_assets (ETH).",
"param": "asset",
"doc_url": "https://stockpay.tech/docs#errors-asset_not_allowed"
}
}| Status | Type | Common codes |
|---|---|---|
| 400 | invalid_request_error | parameter_invalid, parameter_missing, invalid_json, amount_too_large |
| 401 | authentication_error | api_key_invalid |
| 403 | permission_error | insufficient_permissions |
| 404 | invalid_request_error | resource_missing |
| 405 | invalid_request_error | method_not_allowed |
| 409 | invalid_request_error | payment_invalid_state, idempotency_key_reuse, payment_link_inactive |
| 422 | payment_error | asset_not_allowed, quote_expired, refund_exceeds_balance |
| 500 | api_error | internal_error |
Pagination
Cursor based. Pass limit (1–100, default 10) and starting_after with the last id you saw. has_more tells you whether to keep going.
Ordering uses a strictly monotonic row sequence, not (created, id). Timestamps collide at one-second resolution, and paging on a non-total order silently skips or repeats rows — which for a payments list means a missed reconciliation.
ETH and USDC settlement
This rail is real. Nothing is trusted from the client: a payment settles only when the watcher has independently observed a transfer of the exact expected amount to your treasury address, aged past the confirmation depth.
RH_CHAIN_RPC_URL=https://robinhood-mainnet.g.alchemy.com/v2/KEY EVM_CHAIN_ID=4663 EVM_RECEIVING_ADDRESS=0x… # your treasury RH_CHAIN_CONFIRMATIONS=20
Two transfer shapes are detected. Native ETH emits no logs, so blocks are scanned for matching transactions; USDC is found with a single eth_getLogs query on the ERC-20 Transfer topic filtered to your address.
Telling concurrent payments apart
Two payments for the same amount to the same address are indistinguishable in block data. So each payment's expected amount carries a deterministic dust offset derived from its id — for ETH, a few thousand wei, far below one cent. The transfer is matched on the exact tagged amount.
Reorgs
Confirmation depth is re-checked from the receipt on every pass. If a transaction disappears from the chain, the payment returns to awaiting its transfer rather than staying falsely settled.
Leave EVM_RPC_URL unset and the watcher stays dormant; the lifecycle runs through the simulator instead, and the API says so plainly in next_action.instructions rather than implying a live watch.
Running on mainnet
The default configuration is Robinhood Chain mainnet. Nothing needs to be switched on — but two things must be set before you take real money.
Whoever holds the treasury private key can sweep every payment you receive. Generate it in a hardware wallet, or somewhere it is never printed, logged, or pasted. The server keeps a blocklist of addresses whose keys are known to be exposed and refuses to start on a mainnet chain if the treasury is one of them.
rpc.mainnet.chain.robinhood.com is rate-limited and will drop requests under
load — settlement detection lags when it does. Point RH_CHAIN_RPC_URL at Alchemy.
# Robinhood Chain mainnet is the default; this is the whole config RH_CHAIN_RPC_URL=https://robinhood-mainnet.g.alchemy.com/v2/KEY EVM_RECEIVING_ADDRESS=0xYourTreasury RH_CHAIN_CONFIRMATIONS=20 PLATFORM_KEY=a-long-random-string
| Setting | Default | Notes |
|---|---|---|
| EVM_CHAIN_ID | 4663 | Robinhood Chain mainnet. Set 46630 for its testnet, 11155111 for Sepolia. |
| RH_CHAIN_CONFIRMATIONS | 20 | An L2 block is seconds. Raise for high-value payments. |
| EVM_RECEIVING_ADDRESS | unset | Unset means every payment is simulated. Checksum-validated at boot. |
| DEFAULT_LIVEMODE | true on mainnet | New merchants get sk_live_ keys. |
Testing against a chain first
npm run test:chain # live Sepolia + live Robinhood Chain npm run testnet # generate a throwaway testnet treasury
The live suite reads real blocks, finds transfers that actually happened, and asserts the watcher detects them — and that it rejects a one-unit mismatch, a wrong address, and a wrong token contract.
npm run testnet prints a private key to your terminal. That is fine for a
testnet and disqualifying for mainnet — anything printed is exposed.
The Robinhood adapter
The adapter is now a fallback, not the main path. Tokenized equities settle on Robinhood Chain through the on-chain watcher. The adapter below only runs when a chain has no RPC or no treasury configured — it keeps the lifecycle exercisable offline.
createIntent({ payment, asset, quantityBase }) → { reference, // adapter-side identifier handoffUrl, // where to send the payer, or null instructions // surfaced in checkout } poll() → [{ paymentId, reference, status, detail }] cancel(paymentId)
| Mode | State | Use for |
|---|---|---|
| simulator | Works fully | Development, tests, demos. Runs the whole lifecycle locally. |
| connect | Wired, unproven | Robinhood Connect crypto handoff. Needs partner credentials; refuses rather than pretending if they are absent. |
Note the asymmetry, and why it is correct: Connect delivers crypto to an address you control, so the EVM watcher confirms it from the chain. Nothing about the handoff has to be trusted.
For a live equity leg, implement createIntent and poll against a brokerage or custody partner's API in src/settlement/robinhood.js. There is no shortcut through Robinhood's consumer app, and attempting one with user credentials is both a terms violation and a serious security liability.
Dashboard
A merchant dashboard at /dashboard. Sign in with a secret key; it is
exchanged for an httpOnly session cookie, so the key itself never lives in browser
JavaScript where a script injection could read it.
| View | Shows |
|---|---|
| Overview | Net volume, succeeded/pending counts, a 14-day settled-volume chart, and a breakdown by settlement asset. |
| Payments | Every payment with status, asset, quantity and age. Filter by state. |
| Payment links | Create a link from a form, copy its URL, deactivate it. |
| Webhooks | Add endpoints (the secret is shown once), delete them, and read the live event log. |
| Integration | API keys and copyable snippets for Node and cURL. |
| Settings | Chain, treasury address, confirmation depth, adapter mode and price source. |
Overview figures are aggregated in SQL rather than by loading every payment into memory, so the page stays flat as volume grows.
Configuration
| Variable | Default | Purpose |
|---|---|---|
| PORT | 4242 | Listen port. |
| DATABASE_URL | ./stockpay.db | SQLite file. Point at a mounted volume when hosted, or data is lost on redeploy. |
| PUBLIC_URL | https://stockpay.tech | Base for link and checkout URLs. |
| QUOTE_TTL_SECONDS | 900 | How long a locked rate holds. |
| PLATFORM_KEY | unset | Gates merchant creation. Unset ⇒ loopback only. |
| PRICE_SOURCE | static | static or http. |
| WEBHOOK_RETRIES | 10,60,300,1800,7200 | Backoff schedule, seconds. |
| WEBHOOK_TOLERANCE_SECONDS | 300 | Signature replay window. |
| ROBINHOOD_MODE | simulator | simulator or connect. |
| EVM_RPC_URL | unset | Unset ⇒ watcher dormant. |
| EVM_CONFIRMATIONS | 3 | Depth before settling. |
| EVM_RECEIVING_ADDRESS | unset | Treasury. Checksum-validated at boot. |
| EVM_USDC_ADDRESS | mainnet USDC | Set to the testnet token on Sepolia. |
Testing
cd server npm test # 63 tests — money, state machine, signatures, API npm run seed # demo merchant + payment link + keys node src/scripts/demo.js # full lifecycle with a live webhook receiver
Set ROBINHOOD_SIM_DELAY_MS=0 to settle instantly in tests. Drive settlement deterministically by calling tick() from src/settlement/index.js instead of waiting on the interval.
Going live
- Point prices at a real feed.
PRICE_SOURCE=http. The static table is a development fixture. - Set
PLATFORM_KEY. Otherwise merchant creation is loopback-only — which is safe, but will look broken behind a proxy. - Configure the EVM treasury and raise
EVM_CONFIRMATIONSto match the value at risk. - Terminate TLS in front of the API. Bearer keys in cleartext are compromised keys.
- Move off SQLite if you need more than one API process. The schema is portable; the store layer is the only thing to reimplement.
- Add rate limiting. The error shape (
429 rate_limit) exists; the enforcement does not. - Resolve the equity custody question before advertising stock settlement to real customers.
Rate limiting, KYC/AML, tax reporting, payout execution, and multi-process coordination are out of scope here. Each is table stakes for real money movement.
Endpoint index
| Method | Path | Auth |
|---|---|---|
| POST | /dashboard/session | secret key body |
| DELETE | /dashboard/session | session |
| GET | /dashboard/stats | session |
| GET | /dashboard/keys | session |
| POST | /v1/merchants | platform |
| GET | /v1/me | secret |
| POST | /v1/payments | secret |
| GET | /v1/payments | secret |
| GET | /v1/payments/:id | secret |
| POST | /v1/payments/:id/select_asset | publishable |
| POST | /v1/payments/:id/confirm | publishable |
| POST | /v1/payments/:id/cancel | secret |
| POST | /v1/payments/:id/refunds | secret |
| POST | /v1/payment_links | secret |
| GET | /v1/payment_links | secret |
| GET | /v1/payment_links/:id | secret |
| POST | /v1/payment_links/:id/deactivate | secret |
| POST | /v1/checkout/sessions | secret |
| GET | /v1/checkout/sessions | secret |
| GET | /v1/checkout/sessions/:id | secret |
| GET | /v1/checkout/sessions/:id/state | publishable |
| GET | /v1/assets | publishable |
| POST | /v1/quotes | publishable |
| POST | /v1/webhook_endpoints | secret |
| GET | /v1/webhook_endpoints | secret |
| DELETE | /v1/webhook_endpoints/:id | secret |
| GET | /v1/events | secret |
| GET | /v1/events/:id | secret |
Plus GET /health, GET /pay/:slug and GET /c/:session_id, which need no key,
and the site itself: /, /products, /developers,
/pricing, /company, /dashboard, /docs, /demo.