Skip to main content

Sign requests with HMAC

This guide is a deep dive on the request-signing step. If you just need a working example, the Authentication page shows minimal signers in three languages. Come back here when you hit Invalid request signature in production.

When to use this

  • Building or debugging your signer.
  • Diagnosing intermittent 403s.
  • Choosing a JSON serialiser that won't break signatures.

The contract, in one screen

message = "{timestamp}.{HTTP_METHOD}.{request_path}.{raw_request_body}"
signing_key = raw API key (the same string you put in Authorization)
algorithm = HMAC-SHA-256, lowercase hex
header value = "t=<unix_seconds>,v1=<hex>"
header name = X-FB-Signature
tolerance = ±300 seconds
comparison = constant-time on server

Step 1 — Choose your byte representation

The signature covers the bytes you send. Reuse the same bytes for both signing and transport.

const body = JSON.stringify({
scenario_ids: ['4729318'],
org_id: ORG_ID,
});
// `body` is the string you sign AND the string you put in fetch's body.

:::caution Don't double-serialise If your HTTP client serialises a dict for you, sign the serialised output, not the original dict. Frameworks like requests and axios accept a string body — use that. :::

Step 2 — Build the canonical string

"1714564800.POST./api/public/v1/evaluate.{\"scenario_ids\":[\"4729318\"],\"org_id\":\"org_example_...\"}"
ComponentRuleExample
timestampUnix seconds, integer1714564800
HTTP_METHODUppercase, no paddingPOST, GET, DELETE
request_pathFull path including /api/public/v1, no query string/api/public/v1/evaluate
raw_request_bodyExact bytes; empty string for bodyless requests{"scenario_ids":...}

The four components are joined with a literal . (dot). There is no length prefix, no escaping, and no trailing newline.

Step 3 — Compute the signature

import crypto from 'node:crypto';

const message = `${ts}.${method.toUpperCase()}.${path}.${rawBody}`;
const signature = crypto
.createHmac('sha256', apiKey)
.update(message)
.digest('hex'); // lowercase hex

Step 4 — Format and send the header

X-FB-Signature: t=1714564800,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

Whitespace inside the header value is not trimmed server-side — emit it exactly as t=<ts>,v1=<hex>.

Common errors

SymptomLikely causeFix
403 Missing request signatureNo X-FB-Signature header at allAdd the header.
403 Invalid request signatureTimestamp older than 300 sRe-sign immediately before sending. Check clock sync (ntpd / chronyd).
403 Invalid request signature after a retrySame ts reused but body or path changedRecompute signature for every retry.
403 Invalid request signature only on POSTBody bytes diverged from signed bytesStringify once and feed both signer and transport.
403 Invalid request signature with proxiesProxy re-serialised or normalised the bodyPass the raw body through; preserve content-length.
403 Invalid request signature on GET with query stringQuery string included in request_pathDrop the query string from the signed path.
403 Invalid request signature from CI onlyCI clock driftUse a time source that syncs (most managed CIs do).

Clock skew checklist

  • The signed timestamp must be your local Unix time at the moment of signing, in seconds.
  • Server tolerance is ±300 seconds.
  • Re-sign on retry. Do not reuse ts across attempts.
  • Avoid pre-signing batches more than 4 minutes ahead.

Debugging recipe

When in doubt, log the exact canonical string and the exact body bytes, then compare them on the server side via the support channel.

console.log('signing:', JSON.stringify(message));
console.log('body bytes:', Buffer.byteLength(rawBody));
console.log('sig:', signature);

Compare both sides character-by-character. Most "I swear this should work" cases come down to:

  • An extra trailing newline introduced by JSON.stringify(obj, null, 2).
  • A Content-Type middleware that re-serialised the body.
  • A path normaliser that stripped trailing slashes.

Server-side verification (FYI)

You'll never run the server, but knowing the rough algorithm helps reasoning:

def verify(header_value: str, method: str, path: str, raw_body: bytes, api_key: str) -> bool:
parts = dict(p.split("=") for p in header_value.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > 300:
return False
expected = hmac.new(
api_key.encode(),
f"{ts}.{method.upper()}.{path}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(parts["v1"], expected)

Next steps