Overview
The Standard Webhooks spec (standardwebhooks.com (opens in new window)) formalizes the signing, retry, and replay patterns most production systems converged on. This skill covers tests for both sides:
When to use
Step 1 - Sender vs receiver test patterns
| Test side | Layer | Tools |
|---|---|---|
| Sender - payload shape | Unit | Mock HTTP client; assert POST body |
| Sender - signing | Unit | Verify HMAC matches expected per Standard Webhooks |
| Sender - retries | Integration | Mock server returning 5xx, assert retry+backoff |
| Receiver - signature verify | Unit | Construct signed payload; assert handler accepts/rejects |
| Receiver - replay defense | Unit | Construct payload with stale timestamp; assert rejected |
| Receiver - handler logic | Unit | Per-event-type handler tests with vendor sample payloads |
Step 2 - Sender: signature signing
Per Standard Webhooks (stdwh (opens in new window)) the canonical signature scheme:
signature = HMAC-SHA256(secret, "{webhook_id}.{timestamp}.{payload}")The signature accompanies the payload via the webhook-signature header (with v1, prefix for the version):
webhook-id: msg_2KkD9ApUQYKLn9ouQOFKjC
webhook-timestamp: 1714838400
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=Sender-side test:
import hmac, hashlib, base64
def sign_webhook(secret, webhook_id, timestamp, payload):
to_sign = f"{webhook_id}.{timestamp}.{payload}".encode()
sig = hmac.new(
base64.b64decode(secret),
to_sign,
hashlib.sha256,
).digest()
return f"v1,{base64.b64encode(sig).decode()}"
def test_outbound_webhook_signed_correctly():
payload = '{"type":"order.created","data":{"id":123}}'
webhook_id = "msg_test"
timestamp = "1714838400"
expected_sig = sign_webhook(SECRET, webhook_id, timestamp, payload)
sent_request = capture_outbound_webhook()
assert sent_request["headers"]["webhook-id"] == webhook_id
assert sent_request["headers"]["webhook-timestamp"] == timestamp
assert sent_request["headers"]["webhook-signature"] == expected_sigStep 3 - Sender: retry semantics
Per stdwh (opens in new window) the canonical retry pattern is exponential backoff with jitter, capped at N attempts, then dead-letter. The typical delay schedule is in references/vendor-payloads-and-retries.md.
Sender-side test:
def test_webhook_retries_on_5xx(mock_receiver):
mock_receiver.return_status(503) # first 3 attempts fail
mock_receiver.return_status_after_n(4, 200) # 4th succeeds
send_webhook(...)
# Assert delivery succeeded after retries
assert mock_receiver.attempt_count == 4
# Assert backoff between attempts (mock can record timestamps)
deltas = mock_receiver.attempt_deltas()
assert deltas[1] >= 5 # at least 5s between attempt 1 and 2
assert deltas[2] >= 5 * 60 # 5 min between 2 and 3For dead-letter: assert that after max attempts, the webhook is recorded in a dead-letter store + not retried.
Step 4 - Receiver: signature verification
Standard Webhooks signature verification per stdwh (opens in new window):
Receiver-side test:
def test_receiver_rejects_invalid_signature(client):
response = client.post(
"/webhooks/orders",
headers={
"webhook-id": "msg_1",
"webhook-timestamp": str(int(time.time())),
"webhook-signature": "v1,deliberately-wrong",
},
data='{"type":"order.created"}',
)
assert response.status_code == 400
def test_receiver_accepts_valid_signature(client):
payload = '{"type":"order.created"}'
timestamp = str(int(time.time()))
sig = sign_webhook(SECRET, "msg_1", timestamp, payload)
response = client.post(
"/webhooks/orders",
headers={
"webhook-id": "msg_1",
"webhook-timestamp": timestamp,
"webhook-signature": sig,
},
data=payload,
)
assert response.status_code == 200Step 5 - Receiver: replay-window defense
A captured signed payload should NOT be re-replayable indefinitely. Per stdwh (opens in new window) the receiver should reject payloads with timestamps outside a short window (typically 5 minutes).
def test_receiver_rejects_stale_timestamp(client):
stale_timestamp = str(int(time.time()) - 600) # 10 min ago
payload = '{"type":"order.created"}'
sig = sign_webhook(SECRET, "msg_1", stale_timestamp, payload)
response = client.post(
"/webhooks/orders",
headers={
"webhook-id": "msg_1",
"webhook-timestamp": stale_timestamp,
"webhook-signature": sig,
},
data=payload,
)
assert response.status_code == 400If receiver doesn't enforce this, mark critical: replay vulnerable. The harder receiver attack cases - tampered payloads, future-dated timestamps, key rotation, and a capture-and-replay framework with runtime-signed fixtures - are in references/inbound-replay.md.
Step 6 - Receiver: idempotent processing
Webhooks are sent at-least-once (vendor retries on 5xx); receiver must be idempotent. Cross-ref idempotency-test-author (in the qa-async-jobs plugin):
def test_receiver_idempotent_via_webhook_id(client):
payload = '{"type":"order.created","data":{"id":123}}'
webhook_id = "msg_unique"
sig, ts = sign_for_now(payload, webhook_id)
# Send twice (simulating vendor redelivery)
response1 = client.post("/webhooks/orders", headers=..., data=payload)
response2 = client.post("/webhooks/orders", headers=..., data=payload)
assert response1.status_code == 200
assert response2.status_code == 200 # both OK, but only one side-effect
assert Order.objects.filter(external_id=123).count() == 1Step 7 - Per-vendor sample payloads
For receiver tests of specific vendors, use the vendor's official sample payloads (NOT hand-rolled) - making up payloads risks field-name drift. The per-vendor documentation links are in references/vendor-payloads-and-retries.md.
Step 8 - Ordering guarantees
Webhooks are typically NOT ordered (concurrent retries → out-of-order delivery). If your handler relies on order (e.g., processing order.created before order.updated), tests should:
def test_handler_handles_out_of_order_events(client):
# Send "updated" event BEFORE "created"
send_webhook(client, type="order.updated", id=123, status="shipped")
send_webhook(client, type="order.created", id=123, status="placed")
# Handler should reconcile via fetch-from-vendor + apply latest state
order = Order.objects.get(external_id=123)
assert order.status == "shipped" # latest winsIf your handler can't survive out-of-order, mark critical - production will encounter this.
Step 9 - End-to-end test recipe
For sender:
For receiver:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Skip signature verification | Webhooks accept arbitrary attacker payloads | Step 4 negative + positive tests |
| Skip replay-window check | Captured webhook replayable forever | Step 5 |
Use == instead of constant-time compare for signature | Timing-attack vulnerable | hmac.compare_digest() |
| Hand-roll vendor sample payloads | Field names drift from real vendor sends | Use vendor's sample (Step 7) |
| Receiver returns 200 before processing | Vendor stops retrying; data lost on processing failure | Process synchronously OR queue + return 200 + handle failures via internal queue with idempotency |
Limitations
References
Deep reference for the receiver side of the SKILL.md: a capture-and-replay framework that signs fixtures at runtime and drives the receiver through the attack cases the core Step 4-6 tests don't cover - tampered payloads, future-dated timestamps, and key rotation. Per the Standard Webhooks spec (opens in new window), "every webhook implementation needs to protect themselves and their users from SSRF, spoofing, and replay attacks."
Capture-and-replay framework structure
tests/webhook-replay/
├── fixtures/
│ ├── stripe-charge-succeeded.json # full request body
│ ├── stripe-charge-succeeded.headers.json # incl. svix-* headers
│ └── github-pr-opened.json
├── replay.py # replay loop
└── conftest.py # signing helpersThe svix-* header variant
The Standard Webhooks reference implementation (svix) ships the same scheme under svix-prefixed headers:
| Header | Meaning |
|---|---|
svix-id | Unique webhook identifier |
svix-timestamp | Unix timestamp (seconds) |
svix-signature | v1,<base64-hmac-sha256> (one or more, space-separated) |
Signature input: HMAC-SHA256 over {id}.{timestamp}.{payload} with the shared secret as key - identical math to the SKILL.md's webhook-* headers.
Sign fixtures at runtime
Never hard-code timestamps in fixtures - old fixtures fail the replay window. Sign at test runtime:
import hmac, hashlib, base64, time, json
def sign_webhook(secret_b64: str, msg_id: str, payload: bytes,
timestamp: int | None = None) -> dict[str, str]:
timestamp = timestamp or int(time.time())
secret = base64.b64decode(secret_b64.removeprefix("whsec_"))
signed_payload = f"{msg_id}.{timestamp}.".encode() + payload
sig = base64.b64encode(hmac.new(secret, signed_payload, hashlib.sha256).digest()).decode()
return {
"svix-id": msg_id,
"svix-timestamp": str(timestamp),
"svix-signature": f"v1,{sig}",
"Content-Type": "application/json",
}Future-timestamp rejection
The SKILL.md Step 5 rejects stale timestamps; clock-skewed future timestamps must also reject:
def test_future_timestamp_rejected():
payload = b'{"event":"x"}'
future_ts = int(time.time()) + 600
headers = sign_webhook("whsec_<test-secret>", "msg_test_future",
payload, timestamp=future_ts)
resp = requests.post("http://localhost:8080/webhooks/stripe",
data=payload, headers=headers)
assert resp.status_code in (400, 401)Tampered-payload rejection
def test_tampered_payload_rejected():
payload = b'{"amount":100}'
headers = sign_webhook("whsec_<test-secret>", "msg_test_tamper", payload)
# Tamper after signing
tampered = b'{"amount":1000000}'
resp = requests.post("http://localhost:8080/webhooks/stripe",
data=tampered, headers=headers)
assert resp.status_code in (400, 401)Multi-version signature (key rotation)
svix-signature / webhook-signature can carry multiple space-separated v1,... values so senders can rotate keys without an outage. The receiver accepts if any key validates:
def test_accepts_during_key_rotation():
payload = b'{"event":"x"}'
msg_id = "msg_rotate_1"
ts = int(time.time())
sig_old = compute_sig(secret_b64="whsec_OLD", msg_id=msg_id,
payload=payload, timestamp=ts)
sig_new = compute_sig(secret_b64="whsec_NEW", msg_id=msg_id,
payload=payload, timestamp=ts)
headers = {
"svix-id": msg_id,
"svix-timestamp": str(ts),
"svix-signature": f"v1,{sig_old} v1,{sig_new}",
"Content-Type": "application/json",
}
resp = requests.post("http://localhost:8080/webhooks/stripe",
data=payload, headers=headers)
assert resp.status_code == 200Capture from production (responsibly)
For captured payloads, sanitize before committing:
def sanitize_capture(payload: dict) -> dict:
SENSITIVE_KEYS = {"email", "phone", "ssn", "card", "address"}
def walk(node):
if isinstance(node, dict):
return {k: ("***" if k.lower() in SENSITIVE_KEYS else walk(v))
for k, v in node.items()}
if isinstance(node, list):
return [walk(x) for x in node]
return node
return walk(payload)Replaying captured production payloads is also the fastest outage-retro tool: was the failure a webhook storm or a real bug?
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Skip signature test in dev (mock the verifier) | Prod-only signature bug ships | Use the same verifier in test as prod |
| Hard-code timestamps in fixtures | Old fixtures fail windowed-replay protection | Sign at test runtime |
| Commit raw production payloads | PII leak in repo | Sanitize before commit |
| Use single key, no rotation path | Forced re-signing at rotation; outage risk | Multi-key acceptance test |
Sources
Webhook retry schedules and per-vendor payloads
View source (opens in a new tab) (opens in new window)Reference data for webhook-delivery-tester. The core signing, verification, and replay test code stays in SKILL.md; this file holds the lookup table and per-vendor specifics that do not need to sit in the workflow spine.
Canonical retry schedule
Per Standard Webhooks (opens in new window), senders retry with exponential backoff plus jitter, capped at N attempts, then dead-letter. A typical delay schedule:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 5s |
| 3 | 5min |
| 4 | 30min |
| 5 | 2h |
| 6 | 5h |
| 7 | 10h |
| 8 | (give up; dead-letter) |
Sender retry tests assert the attempt count and the backoff deltas between attempts (Step 3 in SKILL.md). Dead-letter tests assert that after the max attempts the webhook is recorded in a dead-letter store and not retried.
Per-vendor sample payloads
For receiver tests of specific vendors, use the vendor's official sample payloads (or captures from their webhook tester), never hand-rolled fixtures - field names drift from real sends.
- Three of our eleven integration customers cannot verify the signatures we send
- 1,880 duplicate credits after Loomis replayed a day of events, and ops wants to keep the tool that let us clean it up
- Carrier integration goes live Friday and the handler was written before we had an account
- The capture replay suite has been quietly green since someone widened a tolerance
- Rotating the payments endpoint secret on Tuesday, and an open ticket asking us to widen a check