Skip to content
LogoLogo

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.

AspectDescription
Key TypePublic/Private key pair
SecurityPrivate key never leaves your server
AlgorithmEd25519 / ECDSA

To sign a request, you must:

  1. Canonicalize and hash the request body to generate a Content-Digest.
  2. Construct a Signature Base String containing the request metadata (method, path) and headers.
  3. Sign the base string using your private key.
  4. Send the signature and metadata in the Signature and Signature-Input headers.

Key Pair Generation

Before making signed requests, generate a cryptographic key pair and register the public key when creating your API key.

Supported Algorithms

AlgorithmIdentifierKey FormatWhat gets signedBest For
Ed25519ed25519PEMThe signature base stringRecommended for most integrations
ECDSA P-256ecdsa-p256-sha256PEMThe signature base stringStandard ECDSA
ECDSA secp256k1ecdsa-secp256k1-sha256EVM addressAn EIP-712 typed messagedifferent, see belowSigning 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.

# Generate private key
openssl genpkey -algorithm Ed25519 -out private_key.pem
 
# Extract public key
openssl pkey -in private_key.pem -pubout -out public_key.pem

Generating 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.pem

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

HeaderFormatDescription
Content-Digestsha-256=:BASE64:Base64-encoded SHA-256 hash of the canonicalized request body. Omit it on a request that sends no body.
Signature-Inputsig1=(...);created=...;keyid=...;alg=...Metadata describing the signature components.
Signaturesig1=: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 keyid is your Ethereum address (e.g. 0x742d35Cc...), and it is also a field inside the signed message
  • Use alg="ecdsa-secp256k1-sha256" in Signature-Input
  • The Signature header carries base64 of the raw 65-byte r || 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

ErrorCauseSolution
Invalid signatureSignature verification failedVerify signature base construction matches exactly
Missing Signature-Input headerHeader not providedInclude Signature-Input header
Missing Content-Digest header for request with bodyBody hash not providedInclude Content-Digest whenever you send a body
Clock drift detectedcreated outside the allowed windowWallet / Checkout: within 30s past / 5s future. Payout: within ±300s
Signature has already been usedThe same signature arrived twiceRe-sign with a fresh created on every attempt, retries included

Common Issues

  1. Key ordering matters — Ensure JSON is canonicalized (keys sorted alphabetically) before hashing
  2. Clock skew — Keep created inside the product window (see table above)
  3. Encoding — Use UTF-8 for all string operations
  4. Line endings — Use \n (LF) not \r\n (CRLF) in the signature base
  5. Replay protection — Each signature can only be used once, on every surface. Re-sign on every retry, and reuse the Idempotency-Key where 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:

PolicyWallet / Onramp / CheckoutPayout
Routeseverything else under /v1, including /v1/wallet/*/v1/payouts/* and /v1/payout/prefund/balance
created= window30s past / 5s future±300 seconds
Auth-layer signature-onceYesYes

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

  1. Never share your private key — It should only exist on your server
  2. Rotate keys periodically — Create new keys and deactivate old ones
  3. Use environment variables — Don't hardcode keys in source code
  4. Monitor API key usage — Check for unusual activity in the dashboard