Verify webhook signatures
We sign every webhook with:- Signature = a tamper-proof stamp made with your secret. If the body or timestamp changes, the stamp won’t match.
- t (timestamp) = when we created the event (seconds since epoch).
- v1 = the signature itself (base64 string).
base64(HMAC_SHA256(secret, "${ts}.${raw_body}")) with a ±5 minute tolerance.
We sign the exact raw bytes you receive (no reformatting). Verify against the raw body, not a re-serialized JSON object.
What to validate (in order)
-
Read raw body
Use the raw bytes from the request. Don’t JSON stringify/pretty-print before verifying. -
Parse the header
Expectt=<unix_ts>,v1=<base64sig>. -
Freshness (clock skew)
Make sure the request is recent:abs(now - t) ≤ 300s (5 min).
Why: prevents old/replayed requests from being accepted. -
Recompute the signature
Rebuild it yourself with your secret:
sig = base64(HMAC_SHA256(secret, ts + "." + raw_body)). -
Secure compare
Compare yoursigwith the header’sv1using a constant-time function (a “secure equals”).
Why: avoids tiny timing differences that could leak info. -
Replay protection (nice to have)
Keep a short-lived cache ofX-Webhook-Id(or the(t,v1)pair). If you see the same one again within ~10 minutes, reject it.
Why: blocks attackers from re-sending a previously valid request. -
Then parse JSON & ack fast
s Once verified, parse JSON and return 2xx quickly. Do heavy work async; we retry on non-2xx.
X-Webhook-Event: e.g.order.submitted,order.pending,order.settled,order.failed,order.refundedX-Webhook-Id: unique request id (use for replay protection)X-Webhook-Signature: the header above
X-Webhook-Event: e.g.order.submitted,order.processing,order.settled,order.failed,order.refundedX-Webhook-Id: unique id for idempotencyX-Webhook-Signature: the signature header above
Minimal receivers (drop-in)
Node (Express)
Python (FastAPI)
Sample payload (event body)
Local testing
- Best: use your own signer to hit your local receiver.
- Node: compute
v1withcrypto.createHmac("sha256", secret).update().digest("base64"). - Python: compute
v1withbase64(hmac_sha256(secret, f"{ts}.{raw_body}")).
- Node: compute
- Or call our
POST /webhooks/testto validate your signature logic (no secrets in the docs UI).
Common errors
| Error | Why | Fix |
|---|---|---|
Invalid webhook signature | Computed signature doesn’t match | Use raw bytes; ensure ts+"."+raw_body and base64 of HMAC-SHA256 |
Signature timestamp outside tolerance window | Clock skew > 5 min | Sync server time; regenerate t |
Malformed signature header | Missing t or v1 | Format: t=<unix_ts>,v1=<base64sig> |
| Works locally but fails in prod | Body re-serialized | Don’t JSON.stringify()/re-encode before verifying |

