rh-crypto
v0.1.0
Published
Zero-dependency Ed25519-signed HTTP client for the Robinhood Crypto Trading API. Byte-exact request signing, keypair generation, and a thin authenticated client for any v1/v2 endpoint.
Maintainers
Readme
rh-crypto
A zero-dependency, Ed25519-signed HTTP client for the Robinhood Crypto Trading
API. Ed25519 ships in Node's
standard node:crypto, so this package pulls in nothing else.
One RobinhoodCrypto instance issues a byte-exact signed request to any v1 or v2
endpoint. The signature covers the exact bytes transmitted — path (including the
query string), uppercase method, and the body serialized exactly once.
- Base URL:
https://trading.robinhood.com - Auth headers:
x-api-key,x-signature,x-timestamp - Signed message:
`${apiKey}${timestamp}${path}${method}${body}` - Timestamps are Unix seconds and valid for 30 seconds only.
- US-only. Requires a Robinhood Crypto account and API credentials created at https://robinhood.com/account/crypto.
Install / requirements
Node 20 or newer. Inside this monorepo the package is available as the workspace
rh-crypto; the modules are plain .mjs files you can also import by path.
Generate a keypair
You generate the keypair locally and hand Robinhood only the public key.
node packages/rh-crypto/keygen.mjsIt prints a base64 32-byte private seed and public key:
private (keep secret): <base64 32-byte seed>
public (give to RH) : <base64 32-byte public key>Paste the public key into the "Add key" flow in your crypto account settings,
then store both secrets outside the repo (.env is gitignored):
printf 'RH_API_KEY=rh-api-...\nRH_PRIVATE_KEY=...\n' >> .envQuick start
import { RobinhoodCrypto } from 'rh-crypto'; // or: '../packages/rh-crypto/client.mjs'
// Reads RH_API_KEY and RH_PRIVATE_KEY from the environment by default.
const rh = new RobinhoodCrypto();
// GET with no body.
const account = await rh.get('/api/v1/crypto/trading/accounts/');
// GET with query params (array values repeat the key, and are part of the signature).
const holdings = await rh.get('/api/v1/crypto/trading/holdings/', { asset_code: ['BTC', 'ETH'] });
// POST — the body is serialized exactly once, then that same string is signed and sent.
const order = await rh.post('/api/v1/crypto/trading/orders/', {
client_order_id: crypto.randomUUID(),
side: 'buy',
type: 'market',
symbol: 'BTC-USD',
market_order_config: { asset_quantity: '0.0001' },
});Run the bundled smoke test end to end:
node --env-file=.env examples/rh-whoami.mjsIt should print an account object with account_number, status,
buying_power, and buying_power_currency.
Exports
client.mjs
class RobinhoodCrypto— the authenticated client.new RobinhoodCrypto({ apiKey?, privateKey? })— defaults toprocess.env.RH_API_KEYandprocess.env.RH_PRIVATE_KEY. Throws if either is missing. The private key is a base64 32-byte Ed25519 seed.request(method, path, { query?, body? })— low-level signed request. Returns the parsed JSON body, or throws aRobinhoodErroron non-2xx.get(path, query?)— convenience forGET.post(path, body, query?)— convenience forPOST.
buildQuery(query)— serializes a query object to a?a=b&c=dstring. Array values repeat the key;null/undefinedvalues are dropped.
sign.mjs
loadPrivateKey(base64Seed)— wraps a base64 32-byte Ed25519 seed in the PKCS8 prefix and returns a NodeKeyObject. Throws if the seed isn't 32 bytes.publicKeyBase64(privateKey)— derives the base64 public key from a privateKeyObject.authHeaders({ apiKey, privateKey, method, path, body?, timestamp? })— returns the three auth headers.pathmust include the query string;bodyis the exact serialized string you transmit (or""for no body);timestampdefaults to the current Unix second.
errors.mjs
class RobinhoodError— thrown on non-2xx responses. Carriesstatus,type,errors[],method,path,headers. Getters:byField,isValidation,isAuth,isPermission,isRateLimit,isServer,summary.toRobinhoodError({ status, payload, method, path, headers })— builds aRobinhoodErrorfrom a failed response (used internally by the client).triage(error)— maps aRobinhoodErrorto{ action, retryable, hint }over the documented status codes.logError(error)— logs a greppable JSON record. Never emits headers, so the signature and key are never written to logs.
marketdata.mjs
The read layer: list pairs, read top of book, and price a hypothetical order size before committing to it.
listTradingPairs(rh, { symbols?, limit? })— every tradable pair, following thenextcursor to exhaustion.symbolsfilters to specific pairs (array; repeats thesymbolparam). Returns the flat array of pair objects. Thenextcursor is a full URL, so each page is re-signed against the path and query it points at — neverfetched blindly, because the signature covers the path.bestBidAsk(rh, symbols)— best bid/ask for one symbol or an array. Returns aMapkeyed by symbol. Ignores order size — do not use it to compute expected fill on anything larger than the minimum.estimatedPrice(rh, { symbol, side, quantities })— size-aware v1 quote.sideis'bid'(you are selling),'ask'(you are buying), or'both'.quantitiesis one value or an array of at most 10. Readpriceoff eachresults[]entry — that is your size-aware price; the spread-inclusive fields describe top of book, not your size.estimatedPriceV2(rh, { symbol, side, quantities })— the v2 quote, which additionally returns the fee under your current fee tier (fee_ratio,est_fee,est_total_cost,est_total_credit). Use it when the quote must include fees.slippageBps({ topOfBook, sized, side })— basis points between the size-agnostic top of book and the size-aware quote. Positive means the size-aware price is worse. Returnsnullif the reference field is absent.
To estimate the cost of a buy, request an ask quote; to estimate the
credit from a sell, request a bid quote. The bid and ask both include a
spread: the buy spread is the percent difference between the ask and the mid, and
the sell spread is the percent difference between the bid and the mid.
Pairs carry sizing constraints (min_order_size, asset_increment,
quote_increment) that every order must respect but that change rarely. Cache
the pair list at process start, refresh on an interval, and validate order sizes
against the cache before hitting the order endpoint.
orders.mjs
Building and validating order bodies before they cost anything, plus the write calls themselves. Every builder throws on a bad shape so mistakes surface before a network round trip.
buildOrder({ symbol, side, type, config, clientOrderId? })— assembles anAddOrderbody, deriving the<type>_order_configkey fromtypeso it cannot drift. Generates a UUIDclient_order_idwhen none is given; reuse one id across retries of the same logical order for idempotency.marketConfig({ assetQuantity })—market_order_config. Market orders takeasset_quantityonly; there is no notional field.limitConfig({ assetQuantity?, quoteAmount?, limitPrice })—limit_order_config.stopLossConfig({ assetQuantity?, quoteAmount?, stopPrice, timeInForce? })—stop_loss_order_config. Becomes a market order when the stop triggers, so it can fill well belowstopPricein a fast move.stopLimitConfig({ assetQuantity?, quoteAmount?, limitPrice, stopPrice, timeInForce? })—stop_limit_order_config. Bounds the fill price but can fail to fill at all.For every config that supports both, exactly one of
assetQuantity(base currency) orquoteAmount(quote currency) may be present — the builders enforce this with an XOR rather than letting the API reject the request.timeInForcedefaults to'gtc'and must be one ofTIME_IN_FORCE(gtc,gfd,gfw,gfm). Every price and quantity is coerced to a decimal string, because the API rejects numbers. In v1,time_in_forceis accepted on the two stop configs only.assertStopSane({ side, stopPrice, limitPrice?, lastPrice })— refuses a stop that would trigger immediately (sell stop at/above last, buy stop at/below last) and a stop-limit whose limit sits on the wrong side of the stop and may never fill.assertTradable(pair, { side, quantity })— validates a quantity against aTradingPair's status and min/max bounds.roundToIncrement(quantity, increment)— floors a quantity to the pair's increment as a fixed-precision string. Rounds down, never up, so it cannot push you pastmax_order_sizeor your buying power.placeOrder(rh, body, { dryRun = true })— posts the order. Dry run is the default; it returns the would-be request without spending. Pass{ dryRun: false }to place a real order.cancelOrder(rh, orderId)— requests a cancel. Returns a success string, not an order object; the order may still fill before the cancel lands, so poll afterwards.getOrder(rh, orderId)/waitForTerminal(rh, orderId, { timeoutMs?, intervalMs? })— read one order via the?id=filter, or poll until terminal.
lifecycle.mjs
Following an order from submission through executions to a terminal state.
isTerminal(state)— true only forfilled,canceled,failed. Any other state (including v1'spartially_filledand v2'spending, and any state added later) is treated as non-terminal, so an unknown state keeps you polling rather than falsely reporting "done".averageFill(order)— quantity-weighted average price computed fromexecutions[], as a number, ornullbefore the first fill. Prefer this over the order'saverage_price, which isnulluntil filled. Executioneffective_priceandquantityare decimal strings; this coerces withNumber()— keep the string form for anything you persist, since floats lose precision on large notionals.fillRatio(order)— fraction filled (0–1) against the requestedasset_quantity, ornullfor aquote_amountorder that has no asset target. Do not let thatnullcollapse to0in position sizing.track(rh, orderId, { intervalMs?, timeoutMs?, onChange? })— polls an order, firingonChange(order, previous)wheneverstateorfilled_asset_quantitymoves, and resolves with the final order once terminal or the deadline passes. Do not poll one order per second forever; back off once an order has been open and unchanged for a while (see the rate-limit prompt).ordersSince(rh, isoTimestamp, { symbol?, state? })— every order updated since a timestamp, following pagination. Use it on startup to reconcile:gfd/gfw/gfmorders expire on Robinhood's clock, so an order that vanished overnight expired, it did not fail.
node --env-file=.env examples/rh-bracket.mjs BTC-USD # dry run
node --env-file=.env examples/rh-bracket.mjs BTC-USD --live # spends moneyportfolio.mjs
Balances, holdings, mark-to-market value, and fee tier. Reads only.
getAccount(rh)— the single v1 account object (account_number,status,buying_power,buying_power_currency).getAccountsV2(rh)— the v2 accounts array, with the paginated list already unwrapped. v2 accounts additionally carryaccount_type,is_api_tradable, andfee_tier_status.getFeeTier(rh, accountNumber?)— that account'sfee_tier_status, ornullif it carries none. Defaults to the first account.getHoldings(rh, { assetCodes? })— every holding, following pagination, withtotal_quantityandquantity_available_for_tradingcoerced to numbers at the boundary (v2 returns them as strings; nothing downstream has to care which version produced them).markToMarket(rh, { quote = 'USD' })— a snapshot:cash,invested,total,currency, anunpricedlist of asset codes with no quotable pair, andpositionssorted by value descending. Each position carriesprice,value, andlocked(quantity tied up by resting sell orders). Marked against the bid because that is what a sell would actually receive. Assets with no USD pair are reported inunpricedrather than valued at zero.concentration(snapshot, assetCode)— the fraction (0–1) of the portfolio held in one asset.
node --env-file=.env examples/rh-portfolio.mjsFeeTierStatus: fee_ratio, thirty_day_volume, next_fee_tier_ratio
(nullable), next_fee_tier_threshold (nullable). Both nulls mean you are already
in the best tier available to you. Fee tier is per account and shifts as volume
rolls off the 30-day window — re-read it rather than caching it for the life of
the process.
keygen.mjs
A runnable script (no exports) that prints a fresh base64 keypair.
Market data: v1 vs v2 are not interchangeable
Both API versions expose pairs, best bid/ask, and estimated price, but the
response shapes differ in ways that silently produce undefined if you assume
they match. Reach for v2 when you need fees folded into the quote; otherwise v1
carries the richer spread breakdown.
Endpoints
| Purpose | v1 path | v2 path |
|---|---|---|
| Trading pairs | /api/v1/crypto/trading/trading_pairs/ | /api/v2/crypto/trading/trading_pairs/ |
| Best bid/ask | /api/v1/crypto/marketdata/best_bid_ask/ | /api/v2/crypto/marketdata/best_bid_ask/ |
| Estimated price | /api/v1/crypto/marketdata/estimated_price/ | /api/v2/crypto/trading/estimated_price/ |
side is one of bid, ask, both. quantity is a comma-separated list, at
most 10 values, each between the pair's min and max order size.
Note the v2
estimated_pricepath is undertrading/, notmarketdata/. A published curl sample for v1 also shows a different path (/marketdata/api/v1/estimated_price/) than the spec key (/api/v1/crypto/marketdata/estimated_price/). This package uses the spec path, which is what Robinhood's own reference Python client uses. If it 404s on your account, try the sample path and record which one your account accepts.
Trading pair fields
| v1 TradingPair | V2TradingPair |
|---|---|
| symbol | symbol |
| asset_code, quote_code | asset_code, quote_code |
| asset_increment, quote_increment | asset_increment, quote_increment |
| max_order_size | max_order_size |
| min_order_size | min_order_amount (renamed) |
| status | status |
| — | is_api_tradable (added) |
status is tradable | untradable | sellonly — not a boolean. A
sellonly pair accepts sells and rejects buys, so filter on
status === 'tradable' before buying, not on truthiness. Code that reads
min_order_size off a v2 response gets undefined and will happily submit
an order that gets rejected — read min_order_amount there.
Best bid/ask fields
| v1 BidAskPrice | V2BestBidAsk |
|---|---|
| symbol | symbol |
| price (mid) | — |
| bid_inclusive_of_sell_spread, sell_spread | bid |
| ask_inclusive_of_buy_spread, buy_spread | ask |
| timestamp | — |
v2 is much thinner: just symbol, bid, ask, with no spread breakdown or
mid.
Estimated price fields
| v1 EstimatedPrice | V2EstimatedPrice |
|---|---|
| symbol, side, quantity | symbol, side, quantity |
| price (size-aware, use this) | bid, ask |
| bid_inclusive_of_sell_spread, sell_spread | — |
| ask_inclusive_of_buy_spread, buy_spread | — |
| timestamp | timestamp |
| — | fee_ratio, est_fee, est_total_cost, est_total_credit |
Use price from a v1 estimate as your size-aware price — the
spread-inclusive fields describe top of book, not your size. Use v2 when you need
the fee included in the quote.
In v1, prices come from partner market makers. In v2, partner exchanges provide
prices and orders route accordingly. Quotes are point-in-time and carry a
timestamp; treat anything older than a few seconds as stale in a fast market.
Print a live quote for any symbol:
node --env-file=.env examples/rh-quote.mjs BTC-USDIt prints the pair's sizing constraints, top of book, and a size-aware ask quote with the slippage in basis points.
Portfolio: accounts and holdings differ across versions too
| Object | v1 | v2 |
|---|---|---|
| Account fields | account_number, status, buying_power, buying_power_currency | same four plus account_type, is_api_tradable, fee_tier_status |
| Accounts response | a single object | a paginated list — read .results, don't index [0] on the raw response |
| Holdings quantities | numbers | strings — arithmetic without coercion silently concatenates |
| Holdings request | account_number optional | account_number required — omitting it is a 400, not an empty list |
portfolio.mjs normalizes holdings quantities to numbers at the boundary, so the
version that produced them stops mattering downstream. Two more traps worth
stating outright:
quantity_available_for_tradingis the number to size sells against, nottotal_quantity— the latter includes quantity locked by resting sell orders, and sizing off it produces rejections that look like phantom-balance bugs.statushas three values (active,deactivated,sell_only). Asell_onlyaccount accepts sells and rejects buys; check it at startup and fail loudly rather than discovering it on your first buy.
v1 versus v2 for order placement
Decision: order placement uses v1. The shared constant ORDER_API_VERSION
in client.mjs is 'v1', and orders.mjs posts to
/api/v1/crypto/trading/orders/. Every later prompt reads that one constant
rather than rediscovering the choice.
All read-only actions exist on both versions, so nothing in this toolkit's reads depends on the order version — only order placement and fee-tier volume accrual do.
Why v1 here
- Internal consistency. The order module already ships on the v1 path, and keeping the constant and the code in agreement is worth more than a fee-tier edge that only applies to enrolled accounts.
- Simplest surface, fewest moving parts for a toolkit whose default order flow is a dry run.
- No forced migration. Per Robinhood's Help Center there is currently no announced deprecation date for v1.
The v2 tradeoff, stated plainly
- Only v2 orders count toward your 30-day trading volume for fee tiers. Place
through v1 and
thirty_day_volumestays flat no matter how much you trade — expected behavior, not a reporting bug. - Fee-tier trading is limited to eligible jurisdictions.
- If
getFeeTier(rh)returnsnull, your account is not enrolled in fee tiers, so placing orders on v2 would not benefit you today. Confirm withnode --env-file=.env examples/rh-portfolio.mjsbefore assuming otherwise.
Switch to v2 if you intend to build volume and your account is fee-tier enabled. Change it in one place, and never mix versions across order placement — half your volume would then stop counting:
- Set
ORDER_API_VERSION = 'v2'inclient.mjs. - Point the order paths in
orders.mjsat their v2 equivalents (/api/v2/crypto/trading/orders/). - Keep re-reading
getFeeTierrather than caching it — the tier changes as volume rolls off the 30-day window.
Streaming: there is no published socket, so this polls
There is no published WebSocket or streaming endpoint for the Robinhood Crypto
Trading API. Verified 2026-07-20 against the full OpenAPI 3.0.1 spec at
https://docs.robinhood.com/crypto/trading: it declares 14 paths, all HTTP GET
or POST under https://trading.robinhood.com, and contains zero occurrences of
websocket, wss://, stream, or subscribe. No webhooks section, no
callbacks. This is stated plainly here so nobody re-litigates it later.
RobinhoodStream (in stream.mjs) therefore presents a push-style interface —
'quote' / 'order' / 'error' / 'idle' events plus an async quotes()
iterator — over a poller. Both polls batch, so two endpoints at a 2s interval is
60 requests/minute total regardless of how many symbols you watch. Quotes
deduplicate on timestamp, orders on state + filled quantity, and every failure
class backs off exponentially to a ceiling. A future socket can be swapped in
behind the same interface without touching callers.
Before assuming the poller is still the right answer, confirm the surface has not changed:
# Does the published spec mention streaming at all?
curl -s https://docs.robinhood.com/crypto/trading/ \
| grep -o '/_next/static/chunks/pages/crypto/trading-[a-f0-9]*\.js'
# Fetch that chunk and grep it:
curl -s "https://docs.robinhood.com<chunk path from above>" \
| grep -c -e websocket -e 'wss://' -e subscribe
# A non-zero count means the surface changed. Re-read the docs.Do not build against an unpublished internal socket found by inspecting app traffic: it is not covered by the documented API, can change without notice, and using it is a customer-agreement question, not an engineering one.
Verify
# Unit tests — no network access required.
node --test packages/rh-crypto/sign.test.mjs
# Live smoke test — needs valid credentials in .env.
node --env-file=.env examples/rh-whoami.mjs
# Live size-aware quote — needs valid credentials in .env.
node --env-file=.env examples/rh-quote.mjs BTC-USD
# Live portfolio snapshot — needs valid credentials in .env.
node --env-file=.env examples/rh-portfolio.mjs
# Live quote + order stream — needs valid credentials in .env. Ctrl-C to stop.
node --env-file=.env examples/rh-stream.mjs BTC-USD ETH-USDThe unit test proves key-loading is correct by deriving Robinhood's published
demo public key from the matching private seed. For the portfolio snapshot,
compare the printed cash against buying power in the Robinhood app and each
position quantity against the app's holdings — they must match exactly. total
should land within a fraction of a percent of the app's crypto value; the small
gap is expected because this marks against the bid inclusive of spread while the
app may show a mid. If getFeeTier prints nothing, the account is not enrolled
in fee tiers.
Gotchas
- Sign the exact bytes you send. Serialize the body once; sign that string; send that string. Serializing twice (e.g. handing the object to a library that re-serializes) is the most common cause of intermittent 401s.
- The query string is part of the signature. The client derives the sent URL and the signed path from one string so they can't drift.
- The 30-second window is short. Generate the timestamp immediately before
the request; on retry, re-sign with a fresh timestamp rather than replaying old
headers. Clock drift over 30s produces 401s that look like a bad key — check
date -ufirst. 401means the signature or timestamp is wrong;403means the key is valid but lacks the permission selected at key-creation time (re-create the credential to change scope).- The published example signature is not reproducible from JSON. Robinhood's
worked example signs a Python
dictstr()repr, not JSON. Don't treat it as a JSON canonicalization spec; the rule that matters is signing the exact bytes sent. That's why the unit test pins key derivation, not a sample signature.
