Skip to content
Back to skills

Webhook Delivery Tester

ASecurity

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

  • 10 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 12, 2026
testingpythongotestinggitsecuritydocumentation

Works with

  • cli

Security analysis

A100/100

Pro scans all 14 files and shows the line behind each finding

Scanned October 12, 2026

npx -y skills add testland/qa --skill webhook-delivery-tester --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Webhook Delivery Tester?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Webhook Delivery Tester
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/testland-webhook-delivery-tester/badge)](https://www.skillsdirectory.com/skills/testland-webhook-delivery-tester)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: webhook-delivery-tester
description: "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."
---

# webhook-delivery-tester

## Overview

The Standard Webhooks spec ([standardwebhooks.com][stdwh]) formalizes the
signing, retry, and replay patterns most production systems converged on. This
skill covers tests for both sides:

- **Sender** - your service emits webhooks to customers (Stripe-style
  outbound webhook).
- **Receiver** - your service receives webhooks from a vendor
  (handler for Stripe / Twilio / SendGrid / GitHub events).

[stdwh]: https://www.standardwebhooks.com/

## When to use

- The repo emits or receives webhooks.
- A regression suite needs to verify signature handling, retry
  semantics, replay-window enforcement.
- Compliance review (SOC 2, security audit) requires evidence of
  signature verification.
- The team adopts Standard Webhooks per [stdwh][stdwh].

## 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][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:**

```python
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_sig
```

## Step 3 - Sender: retry semantics

Per [stdwh][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](references/vendor-payloads-and-retries.md).

**Sender-side test:**

```python
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 3
```

For 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][stdwh]:

1. Reconstruct the to-sign string: `{webhook-id}.{webhook-timestamp}.{payload}`
2. Compute expected signature with the shared secret.
3. Constant-time compare with the `webhook-signature` header value.
4. Reject if mismatch.

**Receiver-side test:**

```python
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 == 200
```

## Step 5 - Receiver: replay-window defense

A captured signed payload should NOT be re-replayable indefinitely.
Per [stdwh][stdwh] the receiver should reject payloads with
timestamps outside a short window (typically 5 minutes).

```python
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 == 400
```

If 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](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):

```python
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() == 1
```

## Step 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](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:

```python
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 wins
```

If your handler can't survive out-of-order, mark **critical** - 
production will encounter this.

## Step 9 - End-to-end test recipe

For sender:
1. ✅ Payload shape matches spec (Step 1)
2. ✅ Signature correctly computed (Step 2)
3. ✅ Retry on 5xx with backoff (Step 3)
4. ✅ Dead-letter after max attempts (Step 3)

For receiver:
1. ✅ Reject invalid signature (Step 4)
2. ✅ Accept valid signature (Step 4)
3. ✅ Reject stale timestamp (Step 5)
4. ✅ Idempotent processing on retry (Step 6)
5. ✅ Per-vendor sample payload tests (Step 7)
6. ✅ Out-of-order handling if applicable (Step 8)

## 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

- This is a build-an-X workflow. Tests use the application's HTTP
  server + an HMAC library.
- Standard Webhooks adoption varies - many vendors use legacy
  signature schemes (`X-Hub-Signature` from GitHub, `Stripe-Signature`
  from Stripe). Adapt the verification logic per vendor; the
  workflow is the same.
- Signature secret rotation is out of scope; pair with a
  secrets-management workflow.
- For vendors that don't sign (legacy / cheap webhooks),
  IP-allowlisting is the only protection; not addressed here.

## References

- Inbound capture-and-replay hardening (tamper / future-timestamp /
  key-rotation cases, sanitized captures):
  [references/inbound-replay.md](references/inbound-replay.md)
- [stdwh][stdwh] - Standard Webhooks specification
- IETF RFC 2104 - HMAC: Keyed-Hashing for Message Authentication
- stripe.com/docs/webhooks - Stripe webhooks reference (de-facto standard for many patterns)
- docs.github.com/en/webhooks - GitHub webhooks reference
- `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)

Files in this skill

  • .tessl-plugin/plugin.json943 B
  • SKILL.md10.8 KB
  • evals/customers-cannot-verify-the-signatures-we-send/criteria.json7.6 KB
  • evals/customers-cannot-verify-the-signatures-we-send/task.md11.1 KB
  • evals/duplicate-credits-after-a-vendor-event-replay/criteria.json6.3 KB
  • evals/duplicate-credits-after-a-vendor-event-replay/task.md9.6 KB
  • evals/handler-written-from-docs-prose-before-go-live/criteria.json7.4 KB
  • evals/handler-written-from-docs-prose-before-go-live/task.md11.6 KB
  • evals/replay-suite-built-on-last-quarters-captures/criteria.json5.9 KB
  • evals/replay-suite-built-on-last-quarters-captures/task.md9.7 KB
  • evals/secret-rotation-tuesday-and-a-clock-skew-ticket/criteria.json8.6 KB
  • evals/secret-rotation-tuesday-and-a-clock-skew-ticket/task.md10.1 KB
  • references/inbound-replay.md5.2 KB
  • references/vendor-payloads-and-retries.md1.4 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.