Idempotent Webhook Retry and Signature Verification
At-least-once delivery is only safe if the receiver can tell an authentic event from a forged one, a fresh event from a replay, and a first delivery from a retry. Those three checks — signature verification, replay protection, and idempotent deduplication — are what turn a durable-but-noisy delivery channel into one a compliance system can trust. Skip the signature check and an attacker can forge an out-of-service clearance that returns an unsafe vehicle to service against 49 CFR § 396.11. Skip idempotency and a routine retry re-opens a work order or double-toggles a hold. This page implements all three on the receiver, plus the sender-side backoff that produces the retries in the first place.
The delivery guarantees this page hardens come from the webhook event delivery layer, which signs each DvirComplianceEvent and delivers it at-least-once; the concrete vendor mappings that ride on top are in syncing DVIR severity scores to Samsara and Geotab. Here the concern is the contract between them: what the sender signs, and exactly what the receiver must verify before it applies a single byte of a compliance decision.
Prerequisites
Anchor link to "Prerequisites"- Python 3.10+ —
match/caseandX | Yunions. hmac/hashlib/secrets(stdlib) — HMAC-SHA256 and constant-time comparison.- redis-py — a Redis instance for the nonce and
event_iddedup keys with TTL. - FastAPI (or any ASGI framework) — to read the raw request body before any JSON parsing, because the signature is over raw bytes.
- The shared signing secret — a per-endpoint secret provisioned out of band; never in the payload.
The signature envelope is the one the delivery layer emits: a header X-DVIR-Signature: t=<unix_ts>,v1=<hex_hmac> where the HMAC-SHA256 is taken over f"{ts}.".encode() + raw_body. Binding the timestamp into the signed material is what makes replay protection possible — the timestamp cannot be altered without breaking the signature.
Step-by-Step Implementation
Anchor link to "Step-by-Step Implementation"Step 1 — Sender: HMAC-sign over a timestamped raw body
Anchor link to "Step 1 — Sender: HMAC-sign over a timestamped raw body"The sender signs the exact bytes it will transmit, prefixed with the timestamp, so the receiver can bind freshness to authenticity. This is the same routine the delivery layer uses.
import hmac
import hashlib
from datetime import datetime, timezone
def sign(raw_body: bytes, secret: bytes, ts: int | None = None) -> str:
ts = ts or int(datetime.now(timezone.utc).timestamp())
mac = hmac.new(secret, f"{ts}.".encode() + raw_body, hashlib.sha256)
return f"t={ts},v1={mac.hexdigest()}"
Step 2 — Sender: exponential backoff with jitter and a max-attempts cap
Anchor link to "Step 2 — Sender: exponential backoff with jitter and a max-attempts cap"Retries are what create duplicates, so cap them and spread them. Full jitter prevents a reconnect storm from synchronizing; the cap is what eventually routes a permanently failing event to the dead-letter queue described in the delivery layer.
import random
MAX_ATTEMPTS = 8
def backoff_delay(attempt: int, base: float = 2.0, cap: float = 900.0) -> float:
"""Full-jitter exponential backoff, capped at 15 minutes."""
return random.uniform(0, min(cap, base * (2 ** attempt)))
def should_retry(attempt: int, status: int | None) -> bool:
if status is not None and 200 <= status < 300:
return False
if status in {400, 401, 403, 422}: # non-retryable client errors
return False
return attempt + 1 < MAX_ATTEMPTS # otherwise retry until the cap
Step 3 — Receiver: verify the signature in constant time
Anchor link to "Step 3 — Receiver: verify the signature in constant time"Read the raw body before parsing, recompute the HMAC, and compare with hmac.compare_digest so the check leaks no timing information. A mismatch is a hard 401 — never parse or act on an unverified body.
from fastapi import Request, HTTPException
def parse_sig_header(header: str) -> tuple[int, str]:
parts = dict(p.split("=", 1) for p in header.split(","))
return int(parts["t"]), parts["v1"]
def verify_signature(raw_body: bytes, header: str, secret: bytes) -> int:
ts, provided = parse_sig_header(header)
expected = hmac.new(secret, f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, provided): # constant-time
raise HTTPException(status_code=401, detail="signature verification failed")
return ts
Step 4 — Receiver: enforce the timestamp acceptance window
Anchor link to "Step 4 — Receiver: enforce the timestamp acceptance window"A valid signature on a captured request is still replayable forever unless freshness is bounded. Reject any event whose timestamp is outside a tolerance window (300 seconds here) in either direction — future-dated as well as stale.
from datetime import datetime, timezone
ACCEPT_WINDOW_S = 300
def check_freshness(ts: int, window: int = ACCEPT_WINDOW_S) -> None:
now = int(datetime.now(timezone.utc).timestamp())
if abs(now - ts) > window:
raise HTTPException(status_code=401, detail="timestamp outside accept window")
Step 5 — Receiver: reject a replayed nonce, then dedup the event_id
Anchor link to "Step 5 — Receiver: reject a replayed nonce, then dedup the event_id"Within the acceptance window a captured request could still be replayed, so record each nonce and reject a repeat with SET NX. Separately, dedup on event_id: a genuine retry of a processed event is not an attack but must be a no-op that returns the original result. Store the result so the duplicate reply is identical.
import redis
r = redis.Redis()
def guard_replay(nonce: str, ts: int, window: int = ACCEPT_WINDOW_S) -> None:
# SET NX succeeds only the first time; a seen nonce means replay.
if not r.set(f"nonce:{nonce}", ts, nx=True, ex=window * 2):
raise HTTPException(status_code=409, detail="replayed nonce rejected")
def process_once(event_id: str, apply) -> dict:
"""event_id dedup: a retry of an already-processed event is a no-op
that returns the stored result, so the effect applies exactly once."""
cached = r.get(f"dvir:event:{event_id}")
if cached is not None:
return {"status": "duplicate", "result": cached.decode()}
result = apply() # the actual compliance effect
r.set(f"dvir:event:{event_id}", result, ex=86400) # 24h dedup TTL
return {"status": "applied", "result": result}
Verification and Testing
Anchor link to "Verification and Testing"The four gates are cheap to assert exhaustively: a valid signature is accepted, a tampered body is rejected, a replayed nonce is rejected, and a duplicate event_id is a no-op.
import hmac
import hashlib
import pytest
SECRET = b"test-endpoint-secret"
def _sig(body: bytes, ts: int) -> str:
mac = hmac.new(SECRET, f"{ts}.".encode() + body, hashlib.sha256)
return f"t={ts},v1={mac.hexdigest()}"
def test_valid_signature_accepted():
body = b'{"event_id":"e1","severity_score":82}'
ts = int(__import__("time").time())
assert verify_signature(body, _sig(body, ts), SECRET) == ts
def test_tampered_body_rejected():
body = b'{"event_id":"e1","severity_score":82}'
ts = int(__import__("time").time())
header = _sig(body, ts)
tampered = b'{"event_id":"e1","severity_score":10}' # score lowered
with pytest.raises(HTTPException) as exc:
verify_signature(tampered, header, SECRET)
assert exc.value.status_code == 401
def test_replayed_nonce_rejected():
r.flushdb()
guard_replay("nonce-abc", ts=1) # first use: ok
with pytest.raises(HTTPException) as exc:
guard_replay("nonce-abc", ts=1) # replay: rejected
assert exc.value.status_code == 409
def test_duplicate_event_id_is_noop():
r.flushdb()
calls = []
first = process_once("evt-1", lambda: (calls.append(1), "OOS_HOLD")[1])
second = process_once("evt-1", lambda: (calls.append(1), "OOS_HOLD")[1])
assert first["status"] == "applied"
assert second["status"] == "duplicate"
assert len(calls) == 1 # effect applied exactly once
Common Failure Modes and Gotchas
Anchor link to "Common Failure Modes and Gotchas"- Verifying over parsed JSON instead of raw bytes. Re-serializing a parsed body reorders keys and changes whitespace, so the recomputed HMAC will not match even for an authentic event. Capture
await request.body()before any parsing and sign and verify those exact bytes. - Using
==to compare signatures. A plain string comparison short-circuits on the first differing byte and leaks timing that can be used to forge a signature. Always usehmac.compare_digest. - Nonce TTL shorter than the acceptance window. If the replay cache expires before the timestamp window closes, a captured request becomes replayable in the gap. Set the nonce TTL to at least twice the acceptance window so no replay slips through.
- Dedup TTL shorter than the max retry span. The
event_iddedup key must outlive the longest possible retry sequence, or a slow retry arriving after the key expires re-applies the effect. With backoff capped at 15 minutes over 8 attempts, a 24-hour dedup TTL leaves ample margin; the dead-letter path from the delivery layer catches anything that exhausts the cap.
Frequently Asked Questions
Anchor link to "Frequently Asked Questions"Why both a nonce and an event_id — is that not redundant?
They defend different things. The nonce plus the timestamp window is replay protection: it stops an attacker from re-sending a captured, validly signed request. The event_id dedup is idempotency: it makes a legitimate retry of an already-processed event a harmless no-op. An honest retry carries the same event_id deliberately, so it must be accepted-but-ignored, not rejected as an attack — which is exactly why the two checks return different results, a 409 for a replayed nonce and a 200 no-op for a duplicate event.
What is the right acceptance window for the timestamp?
Wide enough to tolerate clock skew and normal network delay, narrow enough to limit the replay opportunity — 300 seconds is a common choice. Reject both stale and future-dated timestamps, because a skewed sender clock can produce either. Pair the window with a nonce cache whose TTL is at least twice the window so nothing becomes replayable in the interval between the window closing and the nonce expiring.
Does idempotent dedup make signature verification unnecessary?
No. Dedup only prevents an authentic event from being applied twice; it says nothing about whether the event is authentic. Without signature verification, an attacker could forge a fresh event_id and inject a fabricated out-of-service clearance that dedup would happily accept as new. Verify the signature first, then bound freshness, then dedup — in that order.
Related
Anchor link to "Related"- Webhook Event Delivery for DVIR Pipelines — the sender-side outbox, signing, and retry that produce these deliveries.
- Syncing DVIR Severity Scores to Samsara and Geotab via Webhook — the vendor payloads that ride on this verified channel.
- Audit Logging and Tamper-Evident Event Chains — the SHA-256 chaining that records every verified delivery.
- Compliance Boundary Enforcement in Cloud Workflows — the broader gating framework these receiver checks belong to.
This page sits under Webhook Event Delivery for DVIR Pipelines; for the full outbound architecture, see Telematics, ELD & Maintenance Platform Integration.