The single webhook-testing home, sender AND receiver: build-an-X for webhook delivery + receiver tests per Standard Webhooks (standardwebhooks.com) - HMAC-SHA256 signature verification, retry semantics with exponential backoff + jitter, replay-window check via timestamp tolerance, ordering guarantees, dead-letter handling for permanent failures, content-type + body-encoding fidelity - plus inbound capture-and-replay hardening (runtime-signed fixtures, tampered-payload and future-timestamp rejection, key-rotation acceptance, sanitized production captures) in references/inbound-replay.md. Use when authoring tests for webhook senders OR receivers in any system (Stripe / Twilio / SendGrid / GitHub / GitLab outbound webhooks; SaaS app inbound webhooks), including payment and realtime integrations.
75
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
The Standard Webhooks spec (standardwebhooks.com) formalizes the signing, retry, and replay patterns most production systems converged on. This skill covers tests for both sides:
| 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 |
Per Standard Webhooks (stdwh) 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_sigPer stdwh 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.
Standard Webhooks signature verification per stdwh:
{webhook-id}.{webhook-timestamp}.{payload}webhook-signature header value.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 == 200A captured signed payload should NOT be re-replayable indefinitely. Per stdwh 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.
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() == 1For 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.
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.
For sender:
For receiver:
| 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 |
X-Hub-Signature from GitHub, Stripe-Signature
from Stripe). Adapt the verification logic per vendor; the
workflow is the same.idempotency-test-author - companion: receivers must be idempotent (cross-plugin)mailpit-testing (email flows in its references),
sms-test-author - sister channels
(bounce/complaint webhooks + STOP-keyword webhooks reuse these
patterns)