Signs a request with credentials the host has already obtained. Internal.
This package does not handle authentication
It signs. The host implements authentication and holds the credentials. This module is handed them per call, signs one request, and keeps nothing.
Robinhood's scheme
Three headers:
x-api-key: <api key>
x-timestamp: <unix seconds>
x-signature: base64(ed25519_sign(payload, private_key))where the signed payload is a plain concatenation, in this order and with no separators:
api_key <> timestamp <> path <> method <> bodyFour details in that line are easy to get wrong, and each produces the same unhelpful 401:
pathincludes the query string./api/v2/crypto/marketdata/best_bid_ask/?symbol=BTC-USD, not the path alone. A signature over the bare path fails on every request that filters.methodis uppercase.bodyis the empty string for a GET, not omitted — the concatenation still has a slot for it.timestampis seconds as a string, not milliseconds.
The key is a seed, not a signing key
Robinhood issues a base64-encoded 32-byte Ed25519 seed. NaCl-style, the seed deterministically derives the signing key. Handing the seed to a signer that expects a full 64-byte secret key produces a valid-looking signature that the venue rejects — so the derivation happens here, once, from the venue's own format.
Summary
Types
Credentials the host obtained. Signed with, and not kept.
Types
Functions
@spec headers(String.t(), String.t(), String.t(), credentials(), keyword()) :: {:ok, [{String.t(), String.t()}]} | {:error, term()}
Headers for a signed request.
path must already include the query string, because the venue signs it.
Returns {:error, {:missing_credentials, :robinhood}} rather than signing with a partial
credential, and {:error, {:invalid_private_key, reason}} when the key is not the
base64 32-byte seed the venue issues — both of which are clearer than the 401 they would
otherwise become.
Builds the signed string through payload/5 rather than concatenating a second time
here — see that function's moduledoc for why a second, hand-kept copy of the same
ordering is exactly the kind of thing that drifts.
The signed payload — headers/5's own signing path builds it by calling this, not by
concatenating a second time. Also exposed because its ordering is the whole scheme.
A signature is opaque; the string it was taken over is not, and it is the thing worth
asserting. Confirmed against the vendor's own documentation,
docs.robinhood.com/crypto/trading/ (Authentication → Headers and Signature),
fetched live 2026-09-06 and against the reference implementation Robinhood links from
it: message = f"{api_key}{current_timestamp}{path}{method}{body}", method uppercase,
path including the query string, body contributing no characters for a request that has
none — the same string a plain concatenation with an empty body argument produces here.