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
- You need the full Stripe billing integration — use stripe-payments
- The question is generic job retries and queues — use background-jobs
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.
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.
| Stack | Get the raw body |
|---|---|
| Express | app.post("/webhooks/stripe", express.raw({ type: "application/json" }), handler) mounted before express.json(); req.body is a Buffer |
| Next.js App Router | const body = await req.text() in the route handler |
| Fastify | addContentTypeParser('application/json', { parseAs: 'buffer' }, ...) scoped to the webhook route, or the fastify-raw-body plugin |
| FastAPI / Starlette | body = await request.body() |
| Django | request.body (bytes), view marked @csrf_exempt |
| Rails | request.raw_post; skip CSRF for the controller |
| Go | io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) |
| Hono / Workers | await c.req.text() or await c.req.arrayBuffer() |
3. Verify
Provider schemes (verify against current docs; these are stable):
| Provider | Header | Signed content | Algorithm / encoding |
|---|---|---|---|
| Stripe | Stripe-Signature: t=..,v1=.. | "{t}.{rawBody}" | HMAC-SHA256, hex; tolerance 300 s; may contain several v1 during secret rotation |
| GitHub | X-Hub-Signature-256: sha256=.. | raw body | HMAC-SHA256, hex |
| Slack | X-Slack-Signature: v0=.. + X-Slack-Request-Timestamp | "v0:{ts}:{rawBody}" | HMAC-SHA256, hex; reject if older than 5 min |
| Shopify | X-Shopify-Hmac-Sha256 | raw body | HMAC-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 |
| Twilio | X-Twilio-Signature | full URL + sorted POST params | HMAC-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.paidcan arrive beforeinvoice.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
- 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). - Sending the same event twice produces one row and one side effect.
- Delivering events out of order leaves the final state correct.
- 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
#!/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.