CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/webhook-delivery-tester

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

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

inbound-replay.mdreferences/

Inbound capture-and-replay hardening

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

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 helpers

The svix-* header variant

The Standard Webhooks reference implementation (svix) ships the same scheme under svix-prefixed headers:

HeaderMeaning
svix-idUnique webhook identifier
svix-timestampUnix timestamp (seconds)
svix-signaturev1,<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 == 200

Capture 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-patternWhy it failsFix
Skip signature test in dev (mock the verifier)Prod-only signature bug shipsUse the same verifier in test as prod
Hard-code timestamps in fixturesOld fixtures fail windowed-replay protectionSign at test runtime
Commit raw production payloadsPII leak in repoSanitize before commit
Use single key, no rotation pathForced re-signing at rotation; outage riskMulti-key acceptance test

Sources

SKILL.md

tile.json