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.
t=<unix_ts>,v1=<base64sig>
.
abs(now - t) ≤ 300s (5 min)
.sig = base64(HMAC_SHA256(secret, ts + "." + raw_body))
.
sig
with the header’s v1
using a constant-time function (a “secure equals”).X-Webhook-Id
(or the (t,v1)
pair). If you see the same one again within ~10 minutes, reject it.X-Webhook-Event
: e.g. order.submitted
, order.pending
, order.settled
, order.failed
, order.refunded
X-Webhook-Id
: unique request id (use for replay protection)X-Webhook-Signature
: the header aboveX-Webhook-Event
: e.g. order.submitted
, order.processing
, order.settled
, order.failed
, order.refunded
X-Webhook-Id
: unique id for idempotencyX-Webhook-Signature
: the signature header abovev1
with crypto.createHmac("sha256", secret).update(
).digest("base64")
.v1
with base64(hmac_sha256(secret, f"{ts}.{raw_body}"))
.POST /webhooks/test
to validate your signature logic (no secrets in the docs UI).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 |