Essentials

Signing requests

Authenticate signed API requests using JWS (JSON Web Signature).


Some Volt endpoints require a signed request, on top of your API credentials: a detached JWS (JSON Web Signature) over the raw request body, sent in the X-JWS-Signature header. This adds a second factor to authentication.

This page covers the signing scheme used for stablecoin and .

For outgoing payments from , see instead.

Set up a signing key

Technical specs

An RSA key pair in PKCS1, PKCS8 or x509 format, 2048–4096 bits. Use your own PKI infrastructure, ideally HSM-backed, if you have one — otherwise either option below works.

Deliver the public key to Volt

  • Option 1: Generate in Fuzebox — go to Configuration > Customers > API Access > Signature Keys > Generate a key pair. Fuzebox generates the pair, saves the public key, and shows you the private key once. Volt never stores it after that.
  • Option 2: Generate it yourself, then upload — generate the pair with your own tooling (e.g. openssl), then upload the public key under Configuration > Customers > API Access > Signature Keys > Upload an existing public key.

Either way, you get back a key ID (kid, a UUID). Keep it safe — you'll need it for every signed request.

Never share your private key with Volt or anyone else. If a key may be compromised, notify Volt immediately, generate a new pair, and upload the new public key.

Build the signature

The JWS is detached — the token carries no payload segment of its own. The request body is what's signed; it's referenced by the token but never embedded in it.

Protected header (JSON, base64url-encoded):

FieldTypeDescription
algStringAlways RS256.
typStringAlways JWT.
kidUUIDThe public key ID Volt supplied at key registration.
iatNumberCurrent time as a Unix timestamp (seconds). Generate it immediately before signing — not ahead of time.
jtiStringJWT unique identifier. Generate a fresh value for every request (UUID works well). Max 64 characters, visible ASCII only — no spaces.
Example header
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "ce161c49-4373-4b07-82fa-217998f6b3e8",
  "iat": 1757937600,
  "jti": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed"
}

Signing input: base64UrlEncode(header) + "." + base64UrlEncode(body) — the body exactly as sent, with no extra whitespace or line breaks.

Token: base64UrlEncode(header) + ".." + base64UrlEncode(signature) — note the double dot; the middle (payload) segment is intentionally empty.

The double dot .. is critical. Validation will fail if you use only one dot.

End to end
header = {
  alg: "RS256", typ: "JWT", kid: "ce161c49-4373-4b07-82fa-217998f6b3e8",
  iat: Math.floor(Date.now() / 1000), jti: crypto.randomUUID(),
}

encodedHeader = base64UrlEncode(JSON.stringify(header)) // eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNlMTYxYzQ5LTQzNzMtNGIwNy04MmZhLTIxNzk5OGY2YjNlOCIsImlhdCI6MTc4OTYyNjE0NywianRpIjoiZTJjZTE4OTktM2FiMC00ZDI1LWI4MDEtN2Q4YWU3ODFhMjAwIn0
encodedBody = base64UrlEncode(requestBody)

signature = RSASHA256(encodedHeader + "." + encodedBody, privateKey)
encodedSignature = base64UrlEncode(signature) // oUjLLtQigBniTiYswfE0JAjMiYXtIlNtVi1Lr1jqBx103vXgVtEdWApUMpG3wze3qVXD_APA2Sk8oLV4DGeBb5pN7yRGeCdxoV3IsikCVs6rn2Q2Jat-bReQMX39F7-Rpn7RznjHUsyWWcNbDKy1wRFcEnDJBVdb_1lKdFBPWaKMkB1Yd8t8X2va6mq7pJXPAMS36Gwc37vULZvdw4D-49r8mcbEGnNXwkcuZ08hMk4UsmM0kxLeNcrVD3wZtuU0N43u1trlPnuX9RDOOh9Gz0fEH1fwxdveAZaMOOWr7IPHBeV8nZXHxt1lpwJ-dpAsSDMvCFhr-MHuOQDDdqsfhQ

token = encodedHeader + ".." + encodedSignature

Never resend a previously signed token to retry a request. Generate a fresh token for every request. It's the Idempotency-Key header, not the signature, that protects a retry from being processed twice.

Send the request

Add the token as the X-JWS-Signature header:

X-JWS-Signature: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNlMTYxYzQ5LTQzNzMtNGIwNy04MmZhLTIxNzk5OGY2YjNlOCIsImlhdCI6MTc4OTYyNjE0NywianRpIjoiZTJjZTE4OTktM2FiMC00ZDI1LWI4MDEtN2Q4YWU3ODFhMjAwIn0..oUjLLtQigBniTiYswfE0JAjMiYXtIlNtVi1Lr1jqBx103vXgVtEdWApUMpG3wze3qVXD_APA2Sk8oLV4DGeBb5pN7yRGeCdxoV3IsikCVs6rn2Q2Jat-bReQMX39F7-Rpn7RznjHUsyWWcNbDKy1wRFcEnDJBVdb_1lKdFBPWaKMkB1Yd8t8X2va6mq7pJXPAMS36Gwc37vULZvdw4D-49r8mcbEGnNXwkcuZ08hMk4UsmM0kxLeNcrVD3wZtuU0N43u1trlPnuX9RDOOh9Gz0fEH1fwxdveAZaMOOWr7IPHBeV8nZXHxt1lpwJ-dpAsSDMvCFhr-MHuOQDDdqsfhQ

If a signed request is rejected

Every signature-related problem returns the generic authentication error, on purpose: the response never reveals which check failed. The one exception is a valid signature that was already used, which returns 409 Conflict. A token is used up as soon as its signature is verified, even if the request then fails, so this 409 doesn't tell you whether the original request was processed. Don't assume it was: sign a fresh token and resend the request with the same Idempotency-Key. The key makes that retry safe — it goes through if the original didn't, and is never processed a second time if the original did. If a signed request is unexpectedly rejected, check on your side first: iat generated right before signing, jti genuinely fresh, and the key still active in Fuzebox.

How is this guide?

Last updated on