Webhook Handling skill for Claude Code

Verified, deduplicated webhooks that never get lost. Claude verifies webhook signatures over the exact raw bytes with constant-time comparison and timestamp checks, stores each event under a unique provider ID, and acknowledges quickly while a worker does the real processing idempotently and order-safely. For outbound webhooks it applies Standard Webhooks signing, backoff retries and SSRF guards, and ships a stdlib script to sign and replay test payloads.

Name
webhook-handling
Size
174 lines · ≈2,250 tokens
Files
2
License
MIT
Updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Implements webhook receivers and senders correctly - HMAC signature verification over the raw body, timestamp tolerance and replay protection, fast acknowledgement with queued processing, deduplication by event ID, out-of-order events, and for outbound webhooks signing, retries with backoff, endpoint disabling, SSRF protection and the Standard Webhooks format. Use when integrating Stripe, GitHub, Slack, Shopify or other provider webhooks, when signature checks fail, events are processed twice, or when building a webhook delivery system for your own API.

Try asking

  • Stripe signature verification keeps failing in our Express app
  • We process some GitHub webhook events twice. Make it idempotent
  • Design outbound webhooks for our API with retries and signing

Use it when

  • Integrating webhooks from Stripe, GitHub, Slack, Shopify or similar
  • Signature checks fail or events get processed twice
  • Events arrive out of order or occasionally go missing
  • Building a webhook delivery system for your own customers

Skip it when

How to check the result

Signed requests pass and tampered, mis-keyed or stale ones are rejected, a duplicate delivery produces one side effect, and the handler answers in under a second.

The common mistake it prevents

Parsing the JSON body before verifying, so the re-serialized bytes never match the signature and someone disables verification to make it work.

~/.claude/skills/webhook-handling/SKILL.md

--- name: webhook-handling description: Implements webhook receivers and senders correctly - HMAC signature verification over the raw body, timestamp tolerance and replay… license: MIT ---

A webhook receiver is correct when it rejects anything not signed by the sender, answers 2xx within a few seconds, processes each event exactly once in effect even though it arrives at least once and in any order, and never loses an event it acknowledged.

Receiving: workflow

- [ ] 1. Read the provider's signature docs: header names, signed content, algorithm, encoding, tolerance
- [ ] 2. Route gets the raw request body (bytes) before any JSON parser
- [ ] 3. Verify signature with constant-time compare; check timestamp freshness
- [ ] 4. Persist the event (unique on provider event ID), enqueue processing, return 2xx
- [ ] 5. Worker processes idempotently; tolerate out-of-order and missing events
- [ ] 6. Test locally with real signed payloads (provider CLI or scripts/webhook_sig.py)
- [ ] 7. Monitor failures and set up reconciliation for missed events

2. Raw body first

Signatures are computed over exact bytes. Re-serializing parsed JSON changes whitespace and key order and breaks verification.

StackGet the raw body
Expressapp.post("/webhooks/stripe", express.raw({ type: "application/json" }), handler) mounted before express.json(); req.body is a Buffer
Next.js App Routerconst body = await req.text() in the route handler
FastifyaddContentTypeParser('application/json', { parseAs: 'buffer' }, ...) scoped to the webhook route, or the fastify-raw-body plugin
FastAPI / Starlettebody = await request.body()
Djangorequest.body (bytes), view marked @csrf_exempt
Railsrequest.raw_post; skip CSRF for the controller
Goio.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
Hono / Workersawait c.req.text() or await c.req.arrayBuffer()

3. Verify

Provider schemes (verify against current docs; these are stable):

ProviderHeaderSigned contentAlgorithm / encoding
StripeStripe-Signature: t=..,v1=.."{t}.{rawBody}"HMAC-SHA256, hex; tolerance 300 s; may contain several v1 during secret rotation
GitHubX-Hub-Signature-256: sha256=..raw bodyHMAC-SHA256, hex
SlackX-Slack-Signature: v0=.. + X-Slack-Request-Timestamp"v0:{ts}:{rawBody}"HMAC-SHA256, hex; reject if older than 5 min
ShopifyX-Shopify-Hmac-Sha256raw bodyHMAC-SHA256, base64
Standard Webhooks (Svix, Resend, many others)webhook-id, webhook-timestamp, webhook-signature: v1,<b64>"{id}.{ts}.{rawBody}"HMAC-SHA256 with base64-decoded whsec_ secret, base64
TwilioX-Twilio-Signaturefull URL + sorted POST paramsHMAC-SHA1, base64; use the SDK validator

Prefer the provider SDK’s verifier (stripe.webhooks.constructEvent, @octokit/webhooks verify, standardwebhooks package, Shopify and Twilio SDK helpers). If hand-rolling:

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyGithub(raw: Buffer, header: string | undefined, secret: string): boolean {
  if (!header?.startsWith("sha256=")) return false;
  const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(raw).digest("hex"));
  const got = Buffer.from(header);
  return got.length === expected.length && timingSafeEqual(got, expected);
}
import hmac, hashlib, time

def verify_stripe(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    ts = next((v for k, v in parts if k == "t"), None)
    if ts is None or abs(time.time() - int(ts)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in parts)

Rules: constant-time comparison only (== leaks timing); reject before parsing JSON; return 400/401 on failure without detail; accept multiple signatures so secrets can be rotated; keep secrets in the secret store, one per endpoint/environment.

4. Acknowledge fast, process later

Providers time out in 5-30 s and retry, so heavy work in the request causes duplicate deliveries. In the handler:

INSERT INTO webhook_events (provider, event_id, type, payload, received_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (provider, event_id) DO NOTHING;

Then enqueue {provider, event_id} (or let a worker poll unprocessed rows) and return 200. If the insert was a no-op, it is a duplicate: still return 200. Return 5xx only when you failed to persist, so the provider retries.

5. Process idempotently and out of order

  • Mark processed (processed_at) in the same transaction as the side effect, or make the effect itself idempotent (conditional update, downstream idempotency key).
  • Order is not guaranteed: invoice.paid can arrive before invoice.created. Either compare the object’s version or timestamp to what you stored and ignore stale updates, or treat the event as a notification and fetch the current object from the provider API (stripe.invoices.retrieve(id)), which is order-proof.
  • Unknown event types: store and acknowledge, do not error (providers add types).
  • Respond 2xx even for events you ignore, or the provider retries and may disable the endpoint.

6. Local testing

stripe listen --forward-to localhost:3000/webhooks/stripe     # signs with a CLI secret
stripe trigger payment_intent.succeeded
python3 scripts/webhook_sig.py send --scheme github --secret "$SECRET" \
  --body-file fixtures/push.json --url http://localhost:3000/webhooks/github
python3 scripts/webhook_sig.py verify --scheme stripe --secret "$SECRET" \
  --body-file captured_body.bin --header "Stripe-Signature=t=...,v1=..."

scripts/webhook_sig.py signs, verifies, and sends payloads for the Standard Webhooks, Stripe, GitHub, Slack and Shopify schemes using the standard library; use it to reproduce signature failures with the exact captured bytes.

7. Reconciliation

Webhooks get lost (endpoint down past the retry window, disabled endpoint, bugs). For money or state that matters, run a periodic job that lists recent objects from the provider API and repairs anything missed. Alert on: verification failures (wrong secret or attack), processing failures, and backlog age.

Sending webhooks

When your system emits webhooks:

  • Format: follow Standard Webhooks headers and signing so customers can use existing libraries. Payload: { "id", "type", "created_at", "data": {...} }. Event IDs are unique and stable across retries so receivers can dedupe.
  • Thin vs fat payloads: include the object snapshot for convenience but tell receivers to fetch current state for ordering-sensitive logic; thin events (“object X changed”) avoid leaking data to stale endpoints.
  • Delivery: from a queue, never inline in the request that caused the event. Timeout per attempt 10-15 s; success = 2xx only; do not follow redirects.
  • Retries: exponential backoff with jitter over roughly 1-3 days (e.g. 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, then every 10 h). Retry on network errors, 5xx, 408, 429 (honor Retry-After); not on other 4xx except where documented.
  • Disabling: after sustained failure (e.g. all attempts failing for 3-5 days), disable the endpoint and notify the owner; offer manual replay of the failed range.
  • SSRF protection: customers supply URLs. Require HTTPS; resolve DNS and block private, loopback, link-local and metadata ranges (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, ::1, fc00::/7, fe80::/10) at connect time, not just at registration (DNS rebinding); send from egress IPs you publish so customers can allowlist them.
  • Secrets: per-endpoint secrets, shown once, rotatable with an overlap period where both signatures are sent.
  • Observability: delivery log per attempt (status, latency, response snippet) visible to customers; a “send test event” button.

Gotchas

  • A body-parsing middleware mounted globally runs before your webhook route and consumes the raw body; mount the raw parser on the webhook route first.
  • Proxies or frameworks that decompress or re-encode bodies break signatures; verify at the application with the bytes actually received.
  • Clock skew on your servers makes valid signatures look stale; run NTP.
  • Test-mode and live-mode secrets differ; so do per-endpoint secrets in the same account.
  • Returning 200 before persisting means an acknowledged event can be lost on crash.

Verification

  1. A correctly signed request returns 200; a tampered body, wrong secret, or timestamp older than the tolerance returns 400/401 (check with scripts/webhook_sig.py).
  2. Sending the same event twice produces one row and one side effect.
  3. Delivering events out of order leaves the final state correct.
  4. The handler responds in under a second with processing happening in the worker.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/webhook_sig.py 166 lines · 7.4 KB
#!/usr/bin/env python3
"""Sign, verify, and send test webhooks for common signature schemes.

Schemes:
  standard  Standard Webhooks (webhook-id / webhook-timestamp / webhook-signature, v1,<b64>)
  stripe    Stripe-Signature: t=<ts>,v1=<hex HMAC-SHA256 of "<ts>.<body>">
  github    X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of body>
  slack     X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<ts>:<body>">, X-Slack-Request-Timestamp
  shopify   X-Shopify-Hmac-Sha256: <base64 HMAC-SHA256 of body>

Usage:
  python3 webhook_sig.py sign   --scheme stripe --secret whsec_x --body-file event.json
  python3 webhook_sig.py verify --scheme github --secret s3cr3t --body-file body.bin \
        --header "X-Hub-Signature-256=sha256=abc..."
  python3 webhook_sig.py send   --scheme standard --secret whsec_BASE64 --body-file event.json \
        --url http://localhost:3000/webhooks/test

Exit codes: 0 success/valid, 1 invalid signature or HTTP error, 2 usage error.
Standard library only.
"""
from __future__ import annotations

import argparse
import base64
import hashlib
import hmac
import sys
import time
import urllib.error
import urllib.request
import uuid


def _hmac(key: bytes, msg: bytes) -> bytes:
    return hmac.new(key, msg, hashlib.sha256).digest()


def _standard_key(secret: str) -> bytes:
    raw = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
    try:
        return base64.b64decode(raw, validate=True)
    except Exception:
        sys.exit("standard scheme expects a base64 secret, optionally prefixed with whsec_")


def sign(scheme: str, secret: str, body: bytes, ts: int, msg_id: str) -> dict[str, str]:
    if scheme == "standard":
        content = f"{msg_id}.{ts}.".encode() + body
        sig = base64.b64encode(_hmac(_standard_key(secret), content)).decode()
        return {"webhook-id": msg_id, "webhook-timestamp": str(ts), "webhook-signature": f"v1,{sig}"}
    if scheme == "stripe":
        sig = _hmac(secret.encode(), f"{ts}.".encode() + body).hex()
        return {"Stripe-Signature": f"t={ts},v1={sig}"}
    if scheme == "github":
        return {"X-Hub-Signature-256": "sha256=" + _hmac(secret.encode(), body).hex()}
    if scheme == "slack":
        sig = _hmac(secret.encode(), f"v0:{ts}:".encode() + body).hex()
        return {"X-Slack-Request-Timestamp": str(ts), "X-Slack-Signature": f"v0={sig}"}
    if scheme == "shopify":
        return {"X-Shopify-Hmac-Sha256": base64.b64encode(_hmac(secret.encode(), body)).decode()}
    sys.exit(f"unknown scheme {scheme}")


def verify(scheme: str, secret: str, body: bytes, headers: dict[str, str], tolerance: int) -> tuple[bool, str]:
    h = {k.lower(): v for k, v in headers.items()}
    now = int(time.time())

    def fresh(ts_str: str) -> bool:
        try:
            return abs(now - int(ts_str)) <= tolerance
        except ValueError:
            return False

    if scheme == "standard":
        mid, ts, sigs = h.get("webhook-id"), h.get("webhook-timestamp"), h.get("webhook-signature", "")
        if not (mid and ts):
            return False, "missing webhook-id or webhook-timestamp"
        if not fresh(ts):
            return False, f"timestamp outside tolerance of {tolerance}s"
        expected = sign(scheme, secret, body, int(ts), mid)["webhook-signature"].split(",", 1)[1]
        for part in sigs.split():                       # space-separated list, e.g. "v1,abc v1,def"
            version, _, value = part.partition(",")
            if version == "v1" and hmac.compare_digest(value, expected):
                return True, "valid"
        return False, "no matching v1 signature"
    if scheme == "stripe":
        items = [p.split("=", 1) for p in h.get("stripe-signature", "").split(",") if "=" in p]
        ts = next((v for k, v in items if k == "t"), None)
        if not ts:
            return False, "missing t= in Stripe-Signature"
        if not fresh(ts):
            return False, f"timestamp outside tolerance of {tolerance}s"
        expected = _hmac(secret.encode(), f"{ts}.".encode() + body).hex()
        ok = any(k == "v1" and hmac.compare_digest(v, expected) for k, v in items)
        return ok, "valid" if ok else "no matching v1 signature"
    if scheme == "github":
        got = h.get("x-hub-signature-256", "")
        ok = hmac.compare_digest(got, sign(scheme, secret, body, 0, "")["X-Hub-Signature-256"])
        return ok, "valid" if ok else "signature mismatch"
    if scheme == "slack":
        ts = h.get("x-slack-request-timestamp", "")
        if not fresh(ts):
            return False, f"timestamp outside tolerance of {tolerance}s"
        ok = hmac.compare_digest(h.get("x-slack-signature", ""), sign(scheme, secret, body, int(ts), "")["X-Slack-Signature"])
        return ok, "valid" if ok else "signature mismatch"
    if scheme == "shopify":
        ok = hmac.compare_digest(h.get("x-shopify-hmac-sha256", ""), sign(scheme, secret, body, 0, "")["X-Shopify-Hmac-Sha256"])
        return ok, "valid" if ok else "signature mismatch"
    return False, f"unknown scheme {scheme}"


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("action", choices=["sign", "verify", "send"])
    ap.add_argument("--scheme", required=True, choices=["standard", "stripe", "github", "slack", "shopify"])
    ap.add_argument("--secret", required=True)
    ap.add_argument("--body-file", required=True, help="exact raw bytes of the payload")
    ap.add_argument("--timestamp", type=int, default=None, help="unix seconds (default: now)")
    ap.add_argument("--id", default=None, help="message id for the standard scheme")
    ap.add_argument("--header", action="append", default=[], help="Name=Value, repeatable (verify)")
    ap.add_argument("--tolerance", type=int, default=300, help="max clock skew in seconds")
    ap.add_argument("--url", help="target URL (send)")
    args = ap.parse_args()

    body = open(args.body_file, "rb").read()
    ts = args.timestamp if args.timestamp is not None else int(time.time())
    msg_id = args.id or f"msg_{uuid.uuid4().hex}"

    if args.action == "sign":
        for k, v in sign(args.scheme, args.secret, body, ts, msg_id).items():
            print(f"{k}: {v}")
        return 0

    if args.action == "verify":
        headers = {}
        for item in args.header:
            if "=" not in item:
                print(f"bad --header {item!r}, expected Name=Value", file=sys.stderr)
                return 2
            k, v = item.split("=", 1)
            headers[k] = v
        ok, reason = verify(args.scheme, args.secret, body, headers, args.tolerance)
        print(("VALID: " if ok else "INVALID: ") + reason)
        return 0 if ok else 1

    if not args.url:
        print("--url is required for send", file=sys.stderr)
        return 2
    headers = {"Content-Type": "application/json", **sign(args.scheme, args.secret, body, ts, msg_id)}
    req = urllib.request.Request(args.url, data=body, headers=headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            print(f"HTTP {resp.status}")
            print(resp.read(2000).decode(errors="replace"))
            return 0 if 200 <= resp.status < 300 else 1
    except urllib.error.HTTPError as e:
        print(f"HTTP {e.code}")
        print(e.read(2000).decode(errors="replace"))
        return 1
    except urllib.error.URLError as e:
        print(f"request failed: {e.reason}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc