Skip to content
LogoLogo

Request Signing

You must include a digital signature in all write requests (POST, PUT, PATCH, DELETE). BLOX uses RFC 9421 HTTP Message Signatures. The signature gives integrity, authenticity, and non-repudiation for each request.

How it Works

RFC 9421 signatures use asymmetric cryptography. You sign requests with your Private Key. BLOX verifies them with the Signing Public Key that you gave when BLOX created your API key.

AspectDescription
Key TypeA public/private key pair
SecurityYour private key stays on your server
AlgorithmEd25519 / ECDSA

To sign a request:

  1. Canonicalize the request body and hash it to make the Content-Digest.
  2. Make a Signature Base String that contains the request metadata (method, path) and the headers.
  3. Sign the base string with your private key.
  4. Send the signature and the metadata in the Signature and Signature-Input headers.

Key Pair Generation

Before you send signed requests, generate a cryptographic key pair. Register the public key when BLOX creates your API key.

Supported Algorithms

AlgorithmIdentifierKey FormatSigned dataBest 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 message. Refer to its section.Signatures from an Ethereum wallet

Ed25519 and ECDSA P-256 sign the same bytes and use the same helper below. secp256k1 uses a different scheme. Read its section before you select 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)

To sign with an EVM wallet, give your Ethereum address as the public key. BLOX verifies each signature against this address.


Required Headers

All signed requests must have these headers and also blox-api-key:

HeaderFormatDescription
Content-Digestsha-256=:BASE64:The Base64-encoded SHA-256 hash of the canonicalized request body. Do not send it if the request has no body.
Signature-Inputsig1=(...);created=...;keyid=...;alg=...Metadata that describes the signature components.
Signaturesig1=:BASE64:The cryptographic signature of the base string.

Content-Digest

Calculate the SHA-256 hash of the canonicalized body (keys in alphabetical order). Encode the hash in Base64:

Content-Digest: sha-256=:X48E9qOokqqrvDts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:

Signature-Input

This header defines the signed components. The necessary components are @method, @path, content-digest, and content-type. If a request has no body, sign only @method and @path. The pages of the endpoints that take no body tell you this.

Signature-Input: sig1=(@method @path content-digest content-type);created=1705900000;keyid="your_key_id";alg="ed25519"

Signature

The signature, with a colon before it and a colon after it:

Signature: sig1=:w7SdqL8L...:

Signature Base String

The signature base string shows the request in a deterministic format:

"@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 use the helper again with the examples on the endpoint pages.


Algorithm-Specific Notes

Ed25519

  • Sign the signature base directly. A prehash is not necessary.
  • BLOX recommends this algorithm because it is simple and secure.
  • Use alg="ed25519" in Signature-Input.

ECDSA P-256

  • Use SHA-256 as the hash function.
  • Use alg="ecdsa-p256-sha256" in Signature-Input.

ECDSA secp256k1 (EVM Wallet)

  • The keyid is your Ethereum address (for example, 0x742d35Cc...). It is also a field in the signed message.
  • Use alg="ecdsa-secp256k1-sha256" in Signature-Input.
  • The Signature header contains the base64 of the raw 65-byte r || s || v. Do not use hex.
  • The address that the signature recovers to must be the same as your registered address.

Use exactly this typed data:

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" },
  ],
};

The domain has no chainId and no verifyingContract. It has only the two fields above.

This is a complete signer that uses 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 also lists (@method @path content-digest content-type). These components and the header values must agree with the typed message that you signed. If they do not agree, the verification fails.


Troubleshooting

ErrorCauseSolution
Invalid signatureThe signature verification failedMake sure that your signature base is exactly the same as the format above
Missing Signature-Input headerThe header is not in the requestInclude the Signature-Input header
Missing Content-Digest header for request with bodyThe body hash is not in the requestInclude Content-Digest each time that you send a body
Clock drift detectedcreated is outside the permitted windowWallet / Checkout: in the range 30s in the past to 5s in the future. Payout: in the range ±300s
Signature has already been usedBLOX received the same signature two timesMake a new signature with a new created value for each attempt, also for retries

Common Issues

  1. Key order: Canonicalize the JSON (keys in alphabetical order) before you hash it.
  2. Clock skew: Keep created in the window of the product (refer to the 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: BLOX accepts each signature one time only, on all APIs. Make a new signature for each retry. If the endpoint takes an Idempotency-Key, use the same key again.

Payout differences

The wire format is the same as for Wallet and Onramp. The freshness policy is different. The policy applies to the route, not to the path prefix. All routes are under /v1:

PolicyWallet / Onramp / CheckoutPayout
RoutesAll other routes under /v1, including /v1/wallet/*/v1/payouts/* and /v1/payout/prefund/balance
created= window30s in the past / 5s in the future±300 seconds
BLOX accepts each signature one time onlyYesYes

The two APIs both have trigger addresses. Each trigger address route uses the window of its API. /v1/wallet/bank-accounts/{bankAccountId}/address uses the narrow window. /v1/payouts/beneficiaries/{beneficiaryId}/address uses the payout window. If you sign requests for the two routes, use a new created value for each request.

BLOX accepts a signature one time only on each API. Thus, to retry a request, make a new signature with a new created value. This control is different from the Idempotency-Key. With a new signature, BLOX accepts the retry. The Idempotency-Key prevents a second payout from the retry.

Webhook signatures use HMAC, not RFC 9421. Refer to Webhooks.


Security Best Practices

  1. Never share your private key: Keep it only on your server.
  2. Rotate keys periodically: Create new keys and deactivate the old keys.
  3. Use environment variables: Do not hard-code keys in source code.
  4. Monitor API key usage: Examine the dashboard for unusual activity.