Webhook Receivers skill for Codex
Verify, store, ack fast, process exactly once. Codex builds webhook endpoints that verify HMAC signatures against the untouched raw body, store each event under its provider ID, return 200 in well under a second and hand the work to an idempotent worker. A bundled offline script checks Stripe, GitHub, Slack, Shopify and Standard Webhooks signatures and explains mismatches, which is usually the fastest way to fix 'all signatures invalid'.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- webhook-receivers Installs to ~/.agents/skills/webhook-receivers/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install webhook-receivers 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-receivers Installs to .agents/skills/webhook-receivers/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/webhook-receivers.zipunzip -o webhook-receivers.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-receivers 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-receivers, 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
Builds endpoints that receive third-party webhooks (Stripe, GitHub, Slack, Shopify, Standard Webhooks/Svix senders) correctly - raw-body signature verification with timestamp tolerance, fast 2xx acknowledgement, deduplication by event ID, asynchronous processing, out-of-order handling, secret rotation and replay. Includes an offline signature checker script. Use when adding a webhook endpoint, when signature verification fails, when events are processed twice or out of order, or when the provider reports timeouts and disables the endpoint.
Use it when
- Adding an endpoint for a provider's webhooks
- Signature verification fails or fails only in production
- Events are processed twice, out of order, or time out
- You need to replay stored events after fixing a bug
Use something else when
- You are the one sending webhooks to customers: use webhook-delivery
- Tunnelling and inspecting webhooks during local development: use webhook-testing-tools
How to check the result
Tests accept a valid signature and reject tampered, stale and wrong-secret requests, and a duplicated delivery yields one stored row and one side effect.
The mistake it prevents
Parsing JSON with global middleware before verification, so the signature is computed over re-serialized bytes and every request is rejected.
A webhook endpoint is done when it rejects anything not signed by the sender, answers 2xx within a couple of seconds, processes each event exactly once even though delivery is at-least-once and unordered, and can replay any stored event after a bug fix.
Workflow
- [ ] 1. Read the provider contract and existing handlers
- [ ] 2. Capture the raw body and verify the signature
- [ ] 3. Persist and deduplicate, then acknowledge
- [ ] 4. Process asynchronously and idempotently
- [ ] 5. Handle ordering, unknown types and failures
- [ ] 6. Test with real and forged payloads
1. Read the contract
- Check
AGENTS.mdand any existing webhook routes. Prefer extending one shared receiver pattern over a new ad hoc handler per provider. - From the provider docs, record: signature header and algorithm, what exactly is signed, timestamp tolerance, retry schedule, response timeout, event ID field and whether the provider SDK has a verify helper. Fetching docs needs network; if it is off, use the table below and the SDK already in the lockfile.
| Sender | Header | Signed content | Encoding |
|---|---|---|---|
| Stripe | Stripe-Signature: t=...,v1=... | {t}.{raw body} | hex HMAC-SHA256 |
| GitHub | X-Hub-Signature-256: sha256=... | raw body | hex HMAC-SHA256 |
| Slack | X-Slack-Signature: v0=... + X-Slack-Request-Timestamp | v0:{ts}:{raw body} | hex HMAC-SHA256 |
| Shopify | X-Shopify-Hmac-Sha256 | raw body | base64 HMAC-SHA256 |
| Standard Webhooks (Svix and others) | webhook-id, webhook-timestamp, webhook-signature: v1,... | {id}.{ts}.{raw body} | base64 HMAC-SHA256, secret is base64 after whsec_ |
2. Raw body and verification
Signatures cover the exact bytes sent. Any JSON parse-and-reserialize step breaks them.
| Framework | Get raw bytes |
|---|---|
| Express | app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler) registered before any global express.json() touches that route |
| Next.js App Router | const raw = await request.text() in the route handler |
| Fastify | register a content-type parser with parseAs: 'buffer' for the webhook route |
| FastAPI / Starlette | raw = await request.body() |
| Django | request.body in a @csrf_exempt view |
| Rails | request.raw_post in a controller that skips CSRF verification |
| Go | io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) |
Use the provider SDK’s verifier when available (stripe.webhooks.constructEvent,
@octokit/webhooks verify, the standardwebhooks libraries). Otherwise:
import hashlib, hmac, 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)
sigs = [v for k, v in parts if k == "v1"]
if not ts or not sigs or abs(time.time() - int(ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
Rules:
- Compare with a constant-time function (
hmac.compare_digest,crypto.timingSafeEqual). - Enforce the timestamp tolerance (typically 5 minutes) to block replays.
- During secret rotation accept either the old or new secret, then drop the old one.
- Reject invalid signatures with 400 or 401 and log the event ID and reason, never the secret or full payload.
- Cap body size (1 MB is generous for most providers).
To debug a failing signature offline, save the exact received body and run:
WEBHOOK_SECRET='whsec_...' python3 scripts/verify_signature.py \
--scheme stripe --body-file captured_body.json --signature 't=1700000000,v1=...'
It supports stripe, github, slack (--timestamp), shopify and standard
(--id, --timestamp), exits 0 when valid, prints expected vs received with hints
(trailing newline, pretty-printed body, wrong environment secret), and with --sign
produces headers for test fixtures. Pass --now when checking old captures.
3. Persist, dedupe, acknowledge
CREATE TABLE webhook_events (
provider text NOT NULL,
event_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
attempts int NOT NULL DEFAULT 0,
last_error text,
PRIMARY KEY (provider, event_id)
);
const inserted = await db.query(
`INSERT INTO webhook_events (provider, event_id, event_type, payload)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING RETURNING event_id`,
['stripe', event.id, event.type, event],
);
if (inserted.rowCount === 1) await queue.add('process-webhook', { provider: 'stripe', eventId: event.id });
return res.sendStatus(200); // duplicate deliveries also get 200
- Respond within the provider timeout (Slack expects about 3 s, many others 10-30 s); aim for under 1 s. Never call slow third-party APIs before responding.
- If the insert itself fails, return 5xx so the provider retries.
- Use the provider’s event ID (
webhook-idheader,event.id, GitHubX-GitHub-Delivery). If none exists, hash the raw body.
4. Process asynchronously
- A worker loads the stored event, performs the side effects, and sets
processed_atin the same transaction as the business write. The primary key already guarantees one stored copy; theprocessed_atcheck guarantees one processing. - Make handlers idempotent at the business level as well (upserts keyed by the provider’s object ID), because a worker can crash after the side effect.
- Route by
event_typewith an explicit map; unknown types are stored and marked ignored, never errors.
5. Ordering and failures
- Delivery order is not guaranteed. For state-bearing objects, either compare the object’s own version or timestamp before applying, or treat the event as a signal and fetch the current object from the provider API (needs network and credentials).
- “Thin” events (ID only) always require a fetch; “fat” events may still be stale.
- Retry processing failures with backoff in your own queue; do not rely on the provider re-sending, since you already returned 200.
- Replay: a command or admin action that re-enqueues stored events by ID or time range. Re-running after a bug fix is only safe because processing is idempotent.
Gotchas
- Global body-parser middleware that runs before the webhook route makes every signature fail; the fix is route-level raw parsing, not disabling verification.
- Using the test-mode secret in production (or a secret from a different endpoint) is the most common cause of “all signatures invalid” after a deploy.
- Returning 4xx for events you do not handle makes some providers retry, then disable the endpoint; return 200 and ignore.
- Doing the work inline, then timing out, causes the provider to resend while the first attempt is still running, so the side effect happens twice.
- CSRF middleware, auth middleware and WAF bot rules often block webhook routes; exempt only the specific path, and rely on the signature for authentication.
- Logging full payloads leaks PII and payment data into log storage.
Verification
- Unit tests: valid signature accepted; tampered body, wrong secret, stale timestamp and missing header rejected.
- Sending the same event twice produces one stored row and one side effect.
- Process two events for the same object in reverse order; final state is correct.
- End to end with the provider’s tooling where available (for example
stripe listen --forward-to localhost:3000/webhooks/stripe; needs network and a logged-in CLI, so ask before running it in the sandbox).
Output template
## Webhook receiver: <provider>
- Route: <path>, raw body via <mechanism>
- Verification: <SDK helper | manual HMAC>, tolerance <s>, rotation support <yes/no>
- Dedup key: <field>, storage <table>
- Ack time (p95): <ms>
- Processing: <queue/worker>, idempotent via <upsert key>
- Ordering strategy: <version compare | refetch>
- Replay: <command> 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 Receivers"
short_description: "Verify, store, ack fast, process exactly once"
default_prompt: "$webhook-receivers Add a Stripe webhook endpoint that verifies signatures and handles retries" scripts/verify_signature.py
#!/usr/bin/env python3
"""Verify (or produce) webhook signatures offline, using only the standard library.
Supported schemes:
stripe Stripe-Signature: t=<ts>,v1=<hex> signs "<ts>.<body>"
github X-Hub-Signature-256: sha256=<hex> signs "<body>"
slack X-Slack-Signature: v0=<hex> signs "v0:<ts>:<body>" (needs --timestamp)
shopify X-Shopify-Hmac-Sha256: <base64> signs "<body>"
standard webhook-signature: v1,<base64> ... signs "<id>.<ts>.<body>" (needs --id, --timestamp)
(Standard Webhooks / Svix style; secret "whsec_<base64>")
The secret is read from an environment variable (default WEBHOOK_SECRET) or a file, never
from the command line, so it does not land in shell history.
Exit codes: 0 = signature valid, 1 = invalid or stale, 2 = usage error.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import os
import sys
import time
from typing import NoReturn
def usage_error(msg: str) -> NoReturn:
print(f"error: {msg}", file=sys.stderr)
sys.exit(2)
def hmac_sha256(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:
usage_error("standard-webhooks secret must be base64 (optionally prefixed whsec_)")
def compute(scheme: str, secret: str, body: bytes, ts: str | None, msg_id: str | None) -> str:
"""Return the header value a sender would produce for this body."""
key = secret.encode()
if scheme == "stripe":
assert ts is not None
return f"t={ts},v1=" + hmac_sha256(key, f"{ts}.".encode() + body).hex()
if scheme == "github":
return "sha256=" + hmac_sha256(key, body).hex()
if scheme == "slack":
assert ts is not None
return "v0=" + hmac_sha256(key, f"v0:{ts}:".encode() + body).hex()
if scheme == "shopify":
return base64.b64encode(hmac_sha256(key, body)).decode()
if scheme == "standard":
assert ts is not None and msg_id is not None
sig = hmac_sha256(standard_key(secret), f"{msg_id}.{ts}.".encode() + body)
return "v1," + base64.b64encode(sig).decode()
usage_error(f"unknown scheme {scheme}")
def candidates(scheme: str, header: str) -> tuple[str | None, list[str]]:
"""Split a received header into (timestamp if embedded, list of signature strings)."""
header = header.strip()
if scheme == "stripe":
parts = dict()
sigs = []
for item in header.split(","):
k, _, v = item.strip().partition("=")
if k == "v1":
sigs.append(v)
else:
parts[k] = v
return parts.get("t"), sigs
if scheme == "standard":
return None, [s.split(",", 1)[1] for s in header.split() if s.startswith("v1,")]
if scheme == "github":
return None, [header.removeprefix("sha256=")]
if scheme == "slack":
return None, [header.removeprefix("v0=")]
return None, [header]
def expected_value(scheme: str, full: str) -> str:
"""Strip the prefix from a computed header so it compares with candidates()."""
if scheme == "stripe":
return full.split(",v1=", 1)[1]
if scheme == "standard":
return full.split(",", 1)[1]
if scheme == "github":
return full.removeprefix("sha256=")
if scheme == "slack":
return full.removeprefix("v0=")
return full
def read_secret(args: argparse.Namespace) -> str:
if args.secret_file:
with open(args.secret_file, encoding="utf-8") as fh:
return fh.read().strip()
value = os.environ.get(args.secret_env)
if not value:
usage_error(f"set ${args.secret_env} or pass --secret-file")
return value
def main() -> int:
p = argparse.ArgumentParser(description="Verify or produce webhook signatures offline.")
p.add_argument("--scheme", required=True, choices=["stripe", "github", "slack", "shopify", "standard"])
p.add_argument("--body-file", required=True, help="exact raw request body as received (bytes)")
p.add_argument("--signature", help="received signature header value (omit with --sign)")
p.add_argument("--timestamp", help="timestamp header (slack, standard; stripe when signing)")
p.add_argument("--id", dest="msg_id", help="webhook-id header (standard)")
p.add_argument("--secret-env", default="WEBHOOK_SECRET", help="env var holding the secret")
p.add_argument("--secret-file", help="file holding the secret")
p.add_argument("--tolerance", type=int, default=300, help="max age in seconds (0 disables)")
p.add_argument("--now", type=int, help="override current unix time (for fixtures)")
p.add_argument("--sign", action="store_true", help="print the header a sender would send")
args = p.parse_args()
try:
with open(args.body_file, "rb") as fh:
body = fh.read()
except OSError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
secret = read_secret(args)
now = args.now if args.now is not None else int(time.time())
if args.sign:
ts = args.timestamp or str(now)
if args.scheme == "standard" and not args.msg_id:
print("error: --id is required for the standard scheme", file=sys.stderr)
return 2
print(compute(args.scheme, secret, body, ts, args.msg_id))
return 0
if not args.signature:
print("error: --signature is required unless --sign is used", file=sys.stderr)
return 2
embedded_ts, received = candidates(args.scheme, args.signature)
ts = embedded_ts or args.timestamp
if args.scheme in ("stripe", "slack", "standard") and not ts:
print("error: this scheme needs a timestamp (--timestamp or t= in the header)", file=sys.stderr)
return 2
if args.scheme == "standard" and not args.msg_id:
print("error: --id is required for the standard scheme", file=sys.stderr)
return 2
if not received:
print("INVALID: no signature of the expected version found in header")
return 1
expected = expected_value(args.scheme, compute(args.scheme, secret, body, ts, args.msg_id))
match = any(hmac.compare_digest(expected.encode(), r.encode()) for r in received)
stale = False
if ts and args.tolerance > 0:
try:
age = now - int(ts)
except ValueError:
print(f"INVALID: timestamp {ts!r} is not an integer")
return 1
stale = abs(age) > args.tolerance
if match and not stale:
print(f"VALID: {args.scheme} signature matches ({len(body)} body bytes)")
return 0
if not match:
print(f"INVALID: {args.scheme} signature does not match")
print(f" expected: {expected}")
print(f" received: {', '.join(received)}")
hints = []
if body.endswith(b"\n"):
hints.append("body ends with a newline; was it added when saving the file?")
if b"\r\n" in body:
hints.append("body contains CRLF line endings; check the capture preserved raw bytes")
if body[:1] in (b"{", b"[") and b": " in body and b'":"' not in body:
hints.append("body looks pretty-printed; the sender signed the compact raw bytes")
hints.append("confirm the secret belongs to this endpoint and environment (test vs live)")
for h in hints:
print(f" hint: {h}")
if stale:
print(f"INVALID: timestamp is {abs(now - int(ts))}s from now (tolerance {args.tolerance}s)")
return 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.