A webhook receiver has two independent obligations that are routinely conflated: prove the request came from the sender, and ensure a genuine event is applied once. Signature verification does the first. It does nothing whatsoever about the second, because the sender’s own retries are authentic. This runbook covers both and sits under webhook delivery guarantees.
Prerequisites. You should have a deduplication store available — Redis or a processed-events table — and be able to read the raw request body in your framework before any JSON middleware consumes it.
Step 1 — Capture the raw body
This is where most integrations break, and it always looks like a signature bug.
// Express: keep the raw bytes alongside the parsed body.
app.use('/webhooks', express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }, // Buffer, not a string
}));
# FastAPI / Starlette: read the body first and cache it so the route still parses.
@app.post("/webhooks/payments")
async def receive(request: Request):
raw = await request.body()
request._body = raw
verify_signature(raw, request.headers)
// net/http: read once, then hand the bytes back to the decoder.
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil { http.Error(w, "unreadable body", 400); return }
r.Body = io.NopCloser(bytes.NewReader(raw))
Never verify against JSON.stringify(req.body). {"a":1,"b":2} and {"b":2,"a":1} are the same object and different bytes, and the HMAC only sees bytes.
Step 2 — Recompute the HMAC
The signed payload is usually the timestamp and the raw body joined by a separator, not the body alone — including the timestamp is what makes the signature cover it and stops an attacker replaying an old body with a fresh timestamp.
POST /webhooks/payments HTTP/1.1
Webhook-Timestamp: 1785941673
Webhook-Signature: v1=5f2a...c91d,v1=7b3e...11aa
Content-Type: application/json
{"id":"evt_9f8e7d","type":"charge.succeeded","data":{"amount":4500}}
import hashlib, hmac, time
TOLERANCE_S = 300 # 5 minutes
def verify(raw: bytes, headers, secrets: list[bytes]) -> None:
ts = headers.get("Webhook-Timestamp")
sig_header = headers.get("Webhook-Signature")
if not ts or not sig_header:
raise Unauthorized("missing_signature_headers")
signed_payload = ts.encode() + b"." + raw # timestamp is INSIDE the MAC
presented = {s.split("=", 1)[1] for s in sig_header.split(",") if s.startswith("v1=")}
# Every currently valid secret is tried, so rotation never drops a delivery.
for secret in secrets:
expected = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, p) for p in presented):
break
else:
raise Unauthorized("signature_mismatch")
if abs(time.time() - int(ts)) > TOLERANCE_S:
raise Unauthorized("timestamp_outside_tolerance")
Step 3 — Separate the two windows
The single most common design error is using one window for both purposes.
| Window | Purpose | Typical value | Consequence of getting it wrong |
|---|---|---|---|
| Replay tolerance (timestamp) | Bound how long a captured request is useful to an attacker | 300 seconds | Too long: an intercepted request stays replayable for hours. Too short: legitimate deliveries fail on clock skew. |
| Deduplication window (event id) | Absorb the sender’s own retries | 24–72 hours | Too short: a sender’s retry after a long outage is applied twice. Too long: unnecessary storage, no correctness harm. |
The sender’s retry schedule sets the deduplication floor. Most webhook providers retry with escalating backoff for 24 to 72 hours, so a dedup window shorter than the sender’s give-up time leaves a gap in which a genuine, correctly signed retry is applied a second time.
def receive(raw: bytes, headers, conn) -> tuple[int, str]:
verify(raw, headers, active_secrets()) # step 2: authenticity, 300 s tolerance
event = json.loads(raw)
try:
with conn.transaction():
conn.execute(
"INSERT INTO processed_webhooks (event_id, received_at) VALUES (%s, now())",
(event["id"],), # dedup, 72 h retention
)
apply_effect(event, conn)
except UniqueViolation:
return 200, "duplicate" # 200, not an error — the sender did nothing wrong
return 200, "processed"
Returning 200 on a duplicate matters. A 409 or a 500 tells the sender to retry, which turns one redundant delivery into an escalating series of them.
Step 4 — Handle secret rotation
def active_secrets() -> list[bytes]:
"""Every secret currently inside its validity window, newest first.
Verification tries all of them, so rotation never drops a delivery."""
return [s.value for s in WebhookSecret.objects
.filter(valid_from__lte=now(), valid_until__gte=now())
.order_by("-valid_from")]
Verification and testing
Confirm verification uses raw bytes. Send a body with non-canonical key order and assert it still verifies.
BODY='{"type":"charge.succeeded","id":"evt_test_1","data":{"amount":4500}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex \
| awk '{print $2}')
curl -s -o /dev/null -w '%{http_code}\n' -XPOST localhost:8080/webhooks/payments \
-H "Webhook-Timestamp: $TS" -H "Webhook-Signature: v1=$SIG" \
-H 'Content-Type: application/json' -d "$BODY"
# expect: 200
Confirm the replay tolerance is enforced.
OLD=$(( $(date +%s) - 600 ))
SIG=$(printf '%s.%s' "$OLD" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $2}')
curl -s -o /dev/null -w '%{http_code}\n' -XPOST localhost:8080/webhooks/payments \
-H "Webhook-Timestamp: $OLD" -H "Webhook-Signature: v1=$SIG" -d "$BODY"
# expect: 401 — valid signature, expired timestamp
Confirm deduplication is independent of the signature. Send the same event twice with two freshly computed, both-valid signatures.
SELECT count(*) FROM processed_webhooks WHERE event_id = 'evt_test_1'; -- expect 1
SELECT count(*) FROM ledger_entries WHERE event_id = 'evt_test_1'; -- expect 1
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
| Signature verified against the re-serialised body, so every delivery fails once the sender changes serialiser or field order | Capture the raw buffer before JSON middleware and verify against it. Add a test that sends a body with reversed key order and asserts a 200. |
webhook_signature_failure_total by sender; a step change to 100% right after a sender release is the signature |
| Timestamp tolerance set to 24 hours “to be safe”, leaving intercepted requests replayable all day | Set it to 300 seconds and fix the underlying clock skew instead, per measuring and bounding NTP clock skew. | webhook_timestamp_offset_seconds histogram; if p99 exceeds 30 s the receiver’s clock is the problem |
Duplicate deliveries answered with 409, so the sender treats them as failures and retries harder |
Return 200 with a body indicating the duplicate. The sender did nothing wrong and must not be told to retry. |
webhook_responses_total{outcome="duplicate",status} — anything other than 200 here is a bug |
| Old secret revoked immediately on rotation, and retries of events first sent before the switch are rejected | Keep both secrets valid for at least the sender’s maximum retry span (72 hours) and verify against all active secrets. | webhook_signature_secret_used label; watch usage of the old secret decay to zero before revoking |
| Dedup window set to 1 hour while the sender retries for 72, so a delivery after a long receiver outage is applied twice | Set dedup retention above the sender’s documented give-up time. Assert the configured value at startup against a constant. | webhook_dedup_retention_seconds gauge compared against the sender’s retry span |
SRE / observability checklist
webhook_signature_failure_total— Counter labelled bysenderandreason(mismatch,missing_headers,timestamp_outside_tolerance). Page on any sustained rate; it is either an attack or a broken integration.webhook_timestamp_offset_seconds— Histogram ofnow - Webhook-Timestamp. A p99 above 30 seconds means clock skew is eating the tolerance budget.webhook_responses_total{outcome}— Counter split byprocessedandduplicate. A duplicate ratio above 5% means the receiver is slow enough to trigger sender retries.webhook_dedup_retention_seconds— Gauge exported at startup, alerted if it falls below the sender’s documented retry span.webhook_signature_secret_used— Label recording which secret verified each delivery, so a rotation can be completed on evidence rather than on a calendar.event_idandsenderlog fields — On every delivery, so a duplicate can be traced to the original and to the sender’s own delivery attempt id.
Related
- Webhook Delivery Guarantees — the parent page on delivery semantics, retry schedules and acknowledgement contracts.
- Handling Duplicate Webhook Deliveries in Payment Gateways — the deduplication half in depth, with gateway-specific retry behaviour.
- Building an Idempotent Consumer with a Processed-Events Table — the marker-table mechanism used for the dedup step here.
- Request Fingerprinting to Detect Payload Mismatch — the same raw-bytes-versus-parsed-object trap, on the request side.