Request Signing
For all state-changing requests (POST, PUT, PATCH, DELETE), you must include a digital signature. BLOX uses RFC 9421 HTTP Message Signatures to ensure the integrity, authenticity, and non-repudiation of requests.
How it Works
RFC 9421 signatures use asymmetric cryptography. You sign requests with your Private Key, and BLOX verifies them using the Signing Public Key you provided when generating your API key.
| Aspect | Description |
|---|---|
| Key Type | Public/Private key pair |
| Security | Private key never leaves your server |
| Algorithm | Ed25519 / ECDSA |
To sign a request, you must:
- Canonicalize and hash the request body to generate a Content-Digest.
- Construct a Signature Base String containing the request metadata (method, path) and headers.
- Sign the base string using your private key.
- Send the signature and metadata in the
SignatureandSignature-Inputheaders.
Key Pair Generation
Before making signed requests, generate a cryptographic key pair and register the public key when creating your API key.
Supported Algorithms
| Algorithm | Identifier | Key Format | What gets signed | Best For |
|---|---|---|---|---|
| Ed25519 | ed25519 | PEM | The signature base string | Recommended for most integrations |
| ECDSA P-256 | ecdsa-p256-sha256 | PEM | The signature base string | Standard ECDSA |
| ECDSA secp256k1 | ecdsa-secp256k1-sha256 | EVM address | An EIP-712 typed message — different, see below | Signing with an Ethereum wallet |
The first two sign the same bytes and share the helper below. secp256k1 is a genuinely different scheme — read its section before you pick it.
Generating Ed25519 Keys (Recommended)
# Generate private key
openssl genpkey -algorithm Ed25519 -out private_key.pem
# Extract public key
openssl pkey -in private_key.pem -pubout -out public_key.pemGenerating ECDSA P-256 Keys
# Generate private key
openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem
# Extract public key
openssl ec -in private_key.pem -pubout -out public_key.pemUsing an Ethereum Wallet (secp256k1)
If you prefer signing with an EVM wallet, provide your Ethereum address as the public key. Signatures are verified using public key recovery from the signature.
Required Headers
All signed requests require these headers in addition to blox-api-key:
| Header | Format | Description |
|---|---|---|
Content-Digest | sha-256=:BASE64: | Base64-encoded SHA-256 hash of the canonicalized request body. Omit it on a request that sends no body. |
Signature-Input | sig1=(...);created=...;keyid=...;alg=... | Metadata describing the signature components. |
Signature | sig1=:BASE64: | The cryptographic signature of the base string. |
Content-Digest
Compute SHA-256 hash of the canonicalized body (keys sorted alphabetically) and Base64-encode it:
Content-Digest: sha-256=:X48E9qOokqqrvDts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:Signature-Input
Defines which components are signed. Required components: @method, @path, content-digest, content-type. A request that sends no body signs @method and @path only — the endpoints that take no body say so.
Signature-Input: sig1=(@method @path content-digest content-type);created=1705900000;keyid="your_key_id";alg="ed25519"Signature
The resulting signature, wrapped in colons:
Signature: sig1=:w7SdqL8L...:Signature Base String
The signature base string is a deterministic representation of the request:
"@method": POST
"@path": /v1/wallet/token/withdrawals
"content-digest": sha-256=:X48E9qOokqqrvDts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
"content-type": application/json
"@signature-params": (@method @path content-digest content-type);created=1705900000;keyid="your_key_id";alg="ed25519"Implementation Examples
The examples use Ed25519 and RFC 8785 JSON canonicalization. Set BLOX_KEY_ID and BLOX_PRIVATE_KEY (the path to your PKCS#8 PEM private key), then reuse the helper on endpoint pages.
Algorithm-Specific Notes
Ed25519
- Sign the signature base directly (no prehashing required)
- Recommended for simplicity and security
- Use
alg="ed25519"in Signature-Input
ECDSA P-256
- Sign using SHA-256 as the hash function
- Use
alg="ecdsa-p256-sha256"in Signature-Input
ECDSA secp256k1 (EVM Wallet)
- The
keyidis your Ethereum address (e.g.0x742d35Cc...), and it is also a field inside the signed message - Use
alg="ecdsa-secp256k1-sha256"inSignature-Input - The
Signatureheader carries base64 of the raw 65-byter || s || v, not hex - Verified by
ecrecover: the recovered address must equal your registered one
The typed data is exactly:
const domain = { name: "Blox API", version: "1" };
const types = {
HttpRequest: [
{ name: "method", type: "string" },
{ name: "path", type: "string" },
{ name: "contentDigest", type: "string" },
{ name: "contentType", type: "string" },
{ name: "created", type: "uint256" },
{ name: "keyid", type: "address" },
],
};Note the domain has no chainId and no verifyingContract — just the two fields above.
Complete signer, using viem:
// npm install viem
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { privateKeyToAccount } from "viem/accounts";
const wallet = privateKeyToAccount(process.env.BLOX_EVM_PRIVATE_KEY);
const domain = { name: "Blox API", version: "1" };
const types = {
HttpRequest: [
{ name: "method", type: "string" },
{ name: "path", type: "string" },
{ name: "contentDigest", type: "string" },
{ name: "contentType", type: "string" },
{ name: "created", type: "uint256" },
{ name: "keyid", type: "address" },
],
};
export async function signBloxEvm(method, path, body) {
const digest = createHash("sha256")
.update(canonicalize(body))
.digest("base64");
const contentDigest = `sha-256=:${digest}:`;
const created = Math.floor(Date.now() / 1000);
const signatureHex = await wallet.signTypedData({
domain,
types,
primaryType: "HttpRequest",
message: {
method: method.toUpperCase(),
path,
contentDigest,
contentType: "application/json",
created: BigInt(created),
// Your address is both the keyid and a signed field.
keyid: wallet.address,
},
});
// 65 raw bytes (r || s || v), base64 — not the 0x hex string.
const signature = Buffer.from(signatureHex.slice(2), "hex").toString(
"base64",
);
const params =
`(@method @path content-digest content-type);created=${created};` +
`keyid="${wallet.address}";alg="ecdsa-secp256k1-sha256"`;
return {
"Content-Digest": contentDigest,
"Signature-Input": `sig1=${params}`,
Signature: `sig1=:${signature}:`,
};
}Signature-Input still lists (@method @path content-digest content-type) and must agree with the typed message — the server rebuilds the message from those same headers before recovering your address.
Troubleshooting
| Error | Cause | Solution |
|---|---|---|
Invalid signature | Signature verification failed | Verify signature base construction matches exactly |
Missing Signature-Input header | Header not provided | Include Signature-Input header |
Missing Content-Digest header for request with body | Body hash not provided | Include Content-Digest whenever you send a body |
Clock drift detected | created outside the allowed window | Wallet / Checkout: within 30s past / 5s future. Payout: within ±300s |
Signature has already been used | The same signature arrived twice | Re-sign with a fresh created on every attempt, retries included |
Common Issues
- Key ordering matters — Ensure JSON is canonicalized (keys sorted alphabetically) before hashing
- Clock skew — Keep
createdinside the product window (see table above) - Encoding — Use UTF-8 for all string operations
- Line endings — Use
\n(LF) not\r\n(CRLF) in the signature base - Replay protection — Each signature can only be used once, on every surface. Re-sign on every retry, and reuse the
Idempotency-Keywhere the endpoint takes one
Payout differences
Wire format is identical to Wallet / Onramp. What differs is the freshness policy, and it is a property of the route, not the path prefix — everything lives under /v1:
| Policy | Wallet / Onramp / Checkout | Payout |
|---|---|---|
| Routes | everything else under /v1, including /v1/wallet/* | /v1/payouts/* and /v1/payout/prefund/balance |
created= window | 30s past / 5s future | ±300 seconds |
| Auth-layer signature-once | Yes | Yes |
Deposit trigger addresses exist on both surfaces and follow the surface they sit on: /v1/wallet/bank-accounts/{bankAccountId}/address uses the tight window, /v1/payouts/beneficiaries/{beneficiaryId}/address uses the payout one. If you sign requests for both, use a fresh created per request.
A signature is accepted once on either surface, so a retry means re-signing with a fresh created. That is not the same control as Idempotency-Key: re-signing gets the request past the auth layer, and the key is what stops the second one from creating a second payout. Webhook delivery signing remains HMAC (not RFC 9421) — see Webhooks.
Security Best Practices
- Never share your private key — It should only exist on your server
- Rotate keys periodically — Create new keys and deactivate old ones
- Use environment variables — Don't hardcode keys in source code
- Monitor API key usage — Check for unusual activity in the dashboard