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
Passed
No findings from the security scan
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, "every webhook implementation needs to protect themselves and their users from SSRF, spoofing, and replay attacks."
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 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.
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",
}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)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)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 == 200For 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-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 |