v0.1.0 · 2026-08-22

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.

Real — tokenized equities settle on-chain

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.

Real — ETH and USDC

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.

Still not possible

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.

What that means in practice

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.

AssetChainStandardStatus
194 tokenized equities
and ETFs
Robinhood Chain (4663)ERC-20, 18 decimalsLive on-chain
ETHEthereum / SepoliaNativeLive on-chain
USDCEthereum / SepoliaERC-20, 6 decimalsLive 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.

Terminal
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.

Self-hosting
cd server && npm start   # everything on :4242

That returns a secret key and a publishable key. The secret is shown once. Now take a payment end to end:

payment.mjs
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.

KeyWhere it belongsCan 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.
Request
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.

requires_ payment_method requires_ confirmation processing succeeded failed canceled select_asset confirm observed cancel rail rejected quote expired terminal

Two properties worth relying on:

  • Confirm never means paid. confirm only opens the settlement channel. A payment reaches succeeded only 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

MethodPathNotes
POST/v1/paymentsIdempotent. Returns client_secret.
GET/v1/paymentsCursor paginated; status filter.
GET/v1/payments/:idIncludes attached refunds.
POST/v1/payments/:id/select_assetLocks the rate. Publishable + client_secret OK.
POST/v1/payments/:id/confirmOpens settlement. Returns next_action.
POST/v1/payments/:id/cancelBlocked once terminal.
POST/v1/payments/:id/refundsIdempotent. 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.

POST /v1/payments · 201
{
  "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.

AssetCountStandardPriced by
Tokenized equities & ETFs194ERC-20, 18 decimalsRobinhood market data
ETH1Native gas tokenCoinbase spot
WETH1ERC-20, 18 decimalsProxied to ETH
USDG1ERC-20, 6 decimals1:1 USD peg
WETH and USDG are quiet

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.

USDG is priced at its peg, not a market

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

GET /v1/assets
# 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.

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.

Create a session
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.

Scope

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.

SituationResult
Same key, same bodyOriginal response replayed.
Same key, different body409 idempotency_key_reuse. Guessing which body was meant is how duplicate payments happen.
Same key, first attempt still in flight409 idempotency_in_progress. Retry shortly.
Key used on a request that failedKey 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.

Register
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:

Header
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.

Express
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

EventFires when
payment.createdA payment object exists.
payment.requires_confirmationAn asset was chosen and a rate locked.
payment.processingSettlement channel opened.
payment.succeededTransfer observed and confirmed. Fulfil here.
payment.failedThe rail rejected, or settlement could not complete.
payment.canceledCanceled before settling.
payment.quote_expiredLocked rate aged out; payment reverted.
checkout_session.completedThe session's payment succeeded.
refund.succeededA 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.

422
{
  "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"
  }
}
StatusTypeCommon codes
400invalid_request_errorparameter_invalid, parameter_missing, invalid_json, amount_too_large
401authentication_errorapi_key_invalid
403permission_errorinsufficient_permissions
404invalid_request_errorresource_missing
405invalid_request_errormethod_not_allowed
409invalid_request_errorpayment_invalid_state, idempotency_key_reuse, payment_link_inactive
422payment_errorasset_not_allowed, quote_expired, refund_exceeds_balance
500api_errorinternal_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.

.env
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.

Without a chain

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.

1. Use a treasury key you generated yourself

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.

2. Move off the public RPC

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.

.env
# 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
SettingDefaultNotes
EVM_CHAIN_ID4663Robinhood Chain mainnet. Set 46630 for its testnet, 11155111 for Sepolia.
RH_CHAIN_CONFIRMATIONS20An L2 block is seconds. Raise for high-value payments.
EVM_RECEIVING_ADDRESSunsetUnset means every payment is simulated. Checksum-validated at boot.
DEFAULT_LIVEMODEtrue on mainnetNew merchants get sk_live_ keys.

Testing against a chain first

Terminal
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.

Never use a generated key on mainnet

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.

Adapter interface
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)
ModeStateUse for
simulatorWorks fullyDevelopment, tests, demos. Runs the whole lifecycle locally.
connectWired, unprovenRobinhood 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.

ViewShows
OverviewNet volume, succeeded/pending counts, a 14-day settled-volume chart, and a breakdown by settlement asset.
PaymentsEvery payment with status, asset, quantity and age. Filter by state.
Payment linksCreate a link from a form, copy its URL, deactivate it.
WebhooksAdd endpoints (the secret is shown once), delete them, and read the live event log.
IntegrationAPI keys and copyable snippets for Node and cURL.
SettingsChain, 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

VariableDefaultPurpose
PORT4242Listen port.
DATABASE_URL./stockpay.dbSQLite file. Point at a mounted volume when hosted, or data is lost on redeploy.
PUBLIC_URLhttps://stockpay.techBase for link and checkout URLs.
QUOTE_TTL_SECONDS900How long a locked rate holds.
PLATFORM_KEYunsetGates merchant creation. Unset ⇒ loopback only.
PRICE_SOURCEstaticstatic or http.
WEBHOOK_RETRIES10,60,300,1800,7200Backoff schedule, seconds.
WEBHOOK_TOLERANCE_SECONDS300Signature replay window.
ROBINHOOD_MODEsimulatorsimulator or connect.
EVM_RPC_URLunsetUnset ⇒ watcher dormant.
EVM_CONFIRMATIONS3Depth before settling.
EVM_RECEIVING_ADDRESSunsetTreasury. Checksum-validated at boot.
EVM_USDC_ADDRESSmainnet USDCSet to the testnet token on Sepolia.

Testing

Terminal
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

  1. Point prices at a real feed. PRICE_SOURCE=http. The static table is a development fixture.
  2. Set PLATFORM_KEY. Otherwise merchant creation is loopback-only — which is safe, but will look broken behind a proxy.
  3. Configure the EVM treasury and raise EVM_CONFIRMATIONS to match the value at risk.
  4. Terminate TLS in front of the API. Bearer keys in cleartext are compromised keys.
  5. Move off SQLite if you need more than one API process. The schema is portable; the store layer is the only thing to reimplement.
  6. Add rate limiting. The error shape (429 rate_limit) exists; the enforcement does not.
  7. Resolve the equity custody question before advertising stock settlement to real customers.
Not included

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

MethodPathAuth
POST/dashboard/sessionsecret key body
DELETE/dashboard/sessionsession
GET/dashboard/statssession
GET/dashboard/keyssession
POST/v1/merchantsplatform
GET/v1/mesecret
POST/v1/paymentssecret
GET/v1/paymentssecret
GET/v1/payments/:idsecret
POST/v1/payments/:id/select_assetpublishable
POST/v1/payments/:id/confirmpublishable
POST/v1/payments/:id/cancelsecret
POST/v1/payments/:id/refundssecret
POST/v1/payment_linkssecret
GET/v1/payment_linkssecret
GET/v1/payment_links/:idsecret
POST/v1/payment_links/:id/deactivatesecret
POST/v1/checkout/sessionssecret
GET/v1/checkout/sessionssecret
GET/v1/checkout/sessions/:idsecret
GET/v1/checkout/sessions/:id/statepublishable
GET/v1/assetspublishable
POST/v1/quotespublishable
POST/v1/webhook_endpointssecret
GET/v1/webhook_endpointssecret
DELETE/v1/webhook_endpoints/:idsecret
GET/v1/eventssecret
GET/v1/events/:idsecret

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.