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.
- TypeScript
- Python
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.
# Use compact separators so the bytes are stable.
import json
body = json.dumps({"scenario_ids": ["4729318"], "org_id": ORG_ID},
separators=(",", ":"))
:::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_...\"}"
| Component | Rule | Example |
|---|---|---|
timestamp | Unix seconds, integer | 1714564800 |
HTTP_METHOD | Uppercase, no padding | POST, GET, DELETE |
request_path | Full path including /api/public/v1, no query string | /api/public/v1/evaluate |
raw_request_body | Exact 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
| Symptom | Likely cause | Fix |
|---|---|---|
403 Missing request signature | No X-FB-Signature header at all | Add the header. |
403 Invalid request signature | Timestamp older than 300 s | Re-sign immediately before sending. Check clock sync (ntpd / chronyd). |
403 Invalid request signature after a retry | Same ts reused but body or path changed | Recompute signature for every retry. |
403 Invalid request signature only on POST | Body bytes diverged from signed bytes | Stringify once and feed both signer and transport. |
403 Invalid request signature with proxies | Proxy re-serialised or normalised the body | Pass the raw body through; preserve content-length. |
403 Invalid request signature on GET with query string | Query string included in request_path | Drop the query string from the signed path. |
403 Invalid request signature from CI only | CI clock drift | Use 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
tsacross 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-Typemiddleware 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
- Handle webhooks — the same scheme on incoming deliveries (different signing key).
- Rate limits and retries — retry-safe signing.
- Error taxonomy — every documented error code.