Skip to content
Telematics & Integration

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.

  • Python 3.10+match/case and X | Y unions.
  • hmac / hashlib / secrets (stdlib) — HMAC-SHA256 and constant-time comparison.
  • redis-py — a Redis instance for the nonce and event_id dedup 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.

Receiver verification gauntlet for a signed, deduplicated webhook A top-to-bottom sequence of four gates a received webhook must pass before its compliance effect is applied. First, signature verification recomputes the HMAC-SHA256 over the raw body and compares it in constant time; a mismatch returns 401. Second, the timestamp is checked against a bounded acceptance window; an expired timestamp returns 401. Third, the nonce is checked against a Redis set; a seen nonce is a replay and returns 409. Fourth, the event_id is checked against a Redis dedup key; a duplicate is a no-op that returns 200 with the original result. Only an event that clears all four gates applies its effect. 1 · Verify HMAC signature constant-time compare 2 · Check timestamp window |now - t| <= 300s 3 · Reject seen nonce Redis SET NX · TTL 4 · Dedup event_id duplicate = no-op 200 401 tampered 401 expired 409 replay apply effect pass all four → once
Four gates in order: signature, timestamp window, nonce, and event_id dedup. A tampered or expired event is rejected, a replayed nonce is a 409, and a duplicate event_id is a harmless no-op — only a clean event applies its effect once.

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.

python
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.

python
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.

python
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.

python
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.

python
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}

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.

python
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 use hmac.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_id dedup 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.
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.

This page sits under Webhook Event Delivery for DVIR Pipelines; for the full outbound architecture, see Telematics, ELD & Maintenance Platform Integration.