Webhook Testing Tools skill for Codex
Real events on localhost, signed replays in CI. Codex picks the lightest way to get real webhook events onto localhost, from provider CLIs to cloudflared or ngrok tunnels, then turns captured payloads into signed fixtures. A bundled standard-library script replays any saved event with a valid Stripe, GitHub, Standard Webhooks or HMAC signature, so fixes are proven against the exact event that failed and CI tests run offline.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- webhook-testing-tools Installs to ~/.agents/skills/webhook-testing-tools/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install webhook-testing-tools The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project webhook-testing-tools Installs to .agents/skills/webhook-testing-tools/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/webhook-testing-tools.zipunzip -o webhook-testing-tools.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) webhook-testing-tools PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $webhook-testing-tools, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Tests webhook integrations locally and in CI - provider CLIs (stripe listen and trigger, gh webhook forward, Shopify CLI triggers), tunnels (ngrok, cloudflared quick tunnels, Tailscale Funnel, smee.io), request inspectors, captured payload fixtures, and signed replay of saved events with a bundled script that supports Stripe, GitHub, Standard Webhooks and generic HMAC signatures. Use when a webhook handler needs to be exercised on localhost, signatures fail only in development, events must be replayed after a bug fix, or webhook handling needs automated tests.
Use it when
- A webhook handler must be exercised on localhost before deploy
- Signature verification fails only in development or only in production
- A failed event must be replayed after a bug fix
- Webhook handling needs automated, offline tests
Use something else when
- You are writing the receiver's verification and queueing logic: use webhook-receivers
- Your service sends webhooks to customers: use webhook-delivery
How to check the result
A real test-mode event reaches the local handler, signed fixture replays pass in CI while tampered ones fail, and tunnels and temporary endpoints are removed afterwards.
The mistake it prevents
Running a JSON body parser before the webhook route, so the signature is computed over re-serialised JSON and never matches.
Done well means: the handler is exercised with real provider payloads before deploy, signature verification is tested with the same raw bytes the provider sends, every bug fix is replayed against the exact event that broke it, CI runs signed fixtures without a network, and no tunnel is left open afterwards.
Building the receiver itself (verification, idempotency, queues) is $webhook-receivers; sending
webhooks to others is $webhook-delivery.
Inspect first
- Read
AGENTS.mdfor the dev server command and port; find the handler route (rg -n "webhook" src app routes --glob '!node_modules'). - Check how the handler reads the body. Signature checks need the raw bytes (Express
express.raw(), Next.js route handlerawait req.text(), FastAPIawait request.body()). A JSON body parser in front of the route is the most common reason local tests fail. - Note the provider’s signature scheme and where the test signing secret comes from (
.env.local). - Tunnels and provider CLIs need outbound network access, which the Codex sandbox may block; ask for approval or have the user run the tunnel in their own terminal.
Pick the tool
| Situation | Use | Why |
|---|---|---|
| Provider has a forwarding CLI | Stripe CLI, gh webhook forward, Shopify CLI, Twilio CLI | Real signed events, no public URL to manage |
| Provider needs a public HTTPS URL | cloudflared quick tunnel or ngrok | Stable HTTPS in one command |
| URL must survive restarts (provider dashboard config) | ngrok reserved domain, named Cloudflare Tunnel, Tailscale Funnel | Same URL every session |
| GitHub or any provider, no account wanted | smee.io channel + smee-client | Persistent channel, replays from browser |
| Just see what the provider sends | Tunnel inspector (ngrok http://127.0.0.1:4040) or a private request bin | Headers and raw body |
| Regression test after a fix, CI | Saved fixture + scripts/replay_webhook.py | Deterministic, offline |
| Slack apps in development | Socket Mode | No inbound URL needed at all |
Do not paste production payloads into public inspection sites; they contain customer data. Use test mode events or a private bin.
Provider CLIs
# Stripe: forwards test-mode events and prints a whsec_ secret for this session
stripe listen --forward-to localhost:3000/api/webhooks/stripe --events checkout.session.completed,invoice.paid
stripe trigger checkout.session.completed
stripe events resend evt_1Q... # resend a specific past test event to the listener
# GitHub: forward repo or org webhooks (needs admin on the repo)
gh extension install cli/gh-webhook
gh webhook forward --repo=acme/api --events=pull_request,push \
--url=http://localhost:3000/webhooks/github --secret="$GITHUB_WEBHOOK_SECRET"
# Shopify app webhooks to a local or tunnelled address
shopify app webhook trigger --topic orders/create --address https://<tunnel-host>/webhooks/shopify
The secret printed by stripe listen differs from the dashboard endpoint secret; put it in the
local env file while testing.
Tunnels
cloudflared tunnel --url http://localhost:3000 # random https://<words>.trycloudflare.com, no login
ngrok http 3000 # random URL; inspector at http://127.0.0.1:4040
ngrok http 3000 --url https://acme-dev.ngrok.app # reserved domain on your ngrok account
tailscale funnel 3000 # public URL on your tailnet's ts.net domain
npx smee-client --url https://smee.io/<channel> --target http://localhost:3000/webhooks/github
Rules:
- Expose only the dev server port, never a database or admin service. The tunnel URL is public.
- Register the tunnel URL in the provider’s test or sandbox environment only. Changing a production webhook endpoint redirects live traffic; that needs explicit user confirmation and is rarely right.
- Many frameworks reject unknown hosts (Vite
server.allowedHosts, DjangoALLOWED_HOSTS, Railsconfig.hosts); add the tunnel host in development config only. - Stop the tunnel when done and remove the temporary endpoint from the provider dashboard.
Capture and replay
- Capture: log the raw body and relevant headers in development (or copy from the provider’s
delivery log / the ngrok inspector). Save to
test/fixtures/webhooks/<provider>/<event>.json. Scrub personal data and keep the bytes otherwise unchanged. - Replay against the local handler with a valid signature computed from a test secret:
python3 scripts/replay_webhook.py --scheme stripe --secret "$STRIPE_WEBHOOK_SECRET" \
test/fixtures/webhooks/stripe/checkout.session.completed.json http://localhost:3000/api/webhooks/stripe
python3 scripts/replay_webhook.py --scheme github --secret "$GITHUB_WEBHOOK_SECRET" \
test/fixtures/webhooks/github/pull_request.opened.json http://localhost:3000/webhooks/github \
--header X-GitHub-Event=pull_request
python3 scripts/replay_webhook.py --scheme standard --secret "$SVIX_SECRET" payload.json http://localhost:8000/hooks
python3 scripts/replay_webhook.py --scheme hmac --sig-header X-Signature --secret "$HOOK_SECRET" \
payload.json http://localhost:3000/hooks --dry-run
The script sends the file byte for byte, signs it with the chosen scheme (stripe, github,
standard for Standard Webhooks / Svix, hmac for hex HMAC-SHA256 in a named header, or none),
prints the status and response body, warns when the target is not localhost, and exits 1 on a
non-2xx response. --dry-run prints the headers without sending. Schemes that use base64 HMAC
(Shopify) or timestamps in other formats need a small adaptation; check the provider docs.
- Replay twice to prove idempotency: the second delivery must not create a second side effect.
- Replay in production only through the provider’s own “resend” feature, after the user confirms; side effects (emails, fulfilment, charges) run again.
Automated tests
Put signed fixtures in the normal test suite so CI needs no network:
import crypto from 'node:crypto';
import request from 'supertest';
const raw = fs.readFileSync('test/fixtures/webhooks/stripe/invoice.paid.json');
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac('sha256', 'whsec_test').update(`${t}.${raw}`).digest('hex');
it('processes invoice.paid once', async () => {
for (let i = 0; i < 2; i++) {
await request(app).post('/api/webhooks/stripe')
.set('Stripe-Signature', `t=${t},v1=${v1}`).set('Content-Type', 'application/json')
.send(raw).expect(200);
}
expect(await db.invoices.count({ status: 'paid' })).toBe(1);
});
it('rejects a tampered body', async () => {
await request(app).post('/api/webhooks/stripe')
.set('Stripe-Signature', `t=${t},v1=${v1}`).set('Content-Type', 'application/json')
.send(Buffer.concat([raw, Buffer.from(' ')])).expect(400);
});
Cover at least: valid signature, tampered body, expired timestamp, duplicate delivery, unknown event type (should return 2xx and be ignored), and handler failure (should return 5xx so the provider retries).
Debugging failed deliveries
| Symptom | Likely cause |
|---|---|
| Signature mismatch locally, works nowhere | Body parsed and re-serialised before hashing; wrong secret (dashboard vs CLI) |
| Works locally, fails in production | Proxy or framework changes the body (compression, charset), or prod secret not set |
| Provider shows timeouts | Handler does work inline; acknowledge first, process in a queue |
| 404 or 405 through the tunnel | Wrong path or trailing slash; tunnel pointing at the wrong port |
| 403 / “Invalid host” through the tunnel | Host allowlist in the dev server |
| Events arrive out of order | Normal; fetch current state from the API instead of trusting event order |
Verification
- One real test-mode event from the provider reached the local handler and returned 2xx.
- Signed fixture replays pass in CI with no network; tampered and stale variants fail.
- Duplicate replay leaves exactly one side effect.
- Tunnels stopped and temporary provider endpoints removed; no production endpoint changed.
Output
Report: tools used, endpoints registered (and removed), fixtures added, test cases covering signature, idempotency and failure paths, and any production replays the user approved.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Webhook Testing Tools"
short_description: "Real events on localhost, signed replays in CI"
default_prompt: "$webhook-testing-tools Get Stripe webhooks hitting my local Next.js server so I can test checkout" scripts/replay_webhook.py
#!/usr/bin/env python3
"""Replay a saved webhook payload to a local endpoint with a valid signature.
Usage:
python3 replay_webhook.py --scheme stripe --secret whsec_xxx payload.json http://localhost:3000/webhooks/stripe
python3 replay_webhook.py --scheme github --secret s3cret payload.json http://localhost:3000/webhooks/github --header X-GitHub-Event=push
python3 replay_webhook.py --scheme standard --secret whsec_BASE64 payload.json http://localhost:8000/hooks
python3 replay_webhook.py --scheme hmac --sig-header X-Signature --secret k payload.json URL
add --dry-run to print the request instead of sending it.
The payload file is sent byte for byte, so signatures match what the receiver hashes.
Schemes: stripe (Stripe-Signature t=,v1=), github (X-Hub-Signature-256 sha256=),
standard (Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature v1,),
hmac (hex HMAC-SHA256 of the body in a header you name), none.
Only the standard library is used. Exit code 0 on a 2xx response, 1 otherwise.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
def sign(scheme: str, secret: str, body: bytes, sig_header: str) -> dict[str, str]:
ts = str(int(time.time()))
if scheme == "none":
return {}
if scheme == "stripe":
mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
return {"Stripe-Signature": f"t={ts},v1={mac}"}
if scheme == "github":
mac = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return {"X-Hub-Signature-256": f"sha256={mac}", "X-GitHub-Delivery": str(uuid.uuid4())}
if scheme == "standard":
key = base64.b64decode(secret.removeprefix("whsec_"))
msg_id = f"msg_{uuid.uuid4().hex}"
mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
return {
"webhook-id": msg_id,
"webhook-timestamp": ts,
"webhook-signature": "v1," + base64.b64encode(mac).decode(),
}
if scheme == "hmac":
mac = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return {sig_header: mac}
raise SystemExit(f"unknown scheme {scheme}")
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("payload", type=Path)
p.add_argument("url")
p.add_argument("--scheme", choices=["stripe", "github", "standard", "hmac", "none"], default="none")
p.add_argument("--secret", default="", help="signing secret (use a test secret, never production)")
p.add_argument("--sig-header", default="X-Signature", help="header name for --scheme hmac")
p.add_argument("--header", action="append", default=[], help="extra header Name=Value (repeatable)")
p.add_argument("--content-type", default="application/json")
p.add_argument("--dry-run", action="store_true")
a = p.parse_args()
if a.scheme != "none" and not a.secret:
p.error("--secret is required for signed schemes")
host = a.url.split("/")[2] if "://" in a.url else ""
if not (host.startswith("localhost") or host.startswith("127.0.0.1") or host.startswith("[::1]")):
print(f"warn: {host or a.url} is not a local address; make sure this endpoint is a test target")
body = a.payload.read_bytes()
headers = {"Content-Type": a.content_type, "User-Agent": "replay-webhook/1.0"}
headers.update(sign(a.scheme, a.secret, body, a.sig_header))
for h in a.header:
name, _, value = h.partition("=")
headers[name.strip()] = value.strip()
if a.dry_run:
print(f"POST {a.url}")
for k, v in headers.items():
print(f"{k}: {v}")
print(f"\n<{len(body)} bytes from {a.payload}>")
return 0
req = urllib.request.Request(a.url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=15) as resp:
status, text = resp.status, resp.read(2000).decode(errors="replace")
except urllib.error.HTTPError as e:
status, text = e.code, e.read(2000).decode(errors="replace")
except urllib.error.URLError as e:
print(f"error: could not reach {a.url}: {e.reason}")
return 1
print(f"{status} from {a.url}")
if text.strip():
print(text.strip())
return 0 if 200 <= status < 300 else 1
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.