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

  • Backend & APIs
  • $webhook-receivers
  • 180 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- webhook-receivers

Installs to ~/.agents/skills/webhook-receivers/ for every project on this machine.

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

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.

~/.agents/skills/webhook-receivers/SKILL.md

--- name: webhook-receivers description: Builds endpoints that receive third-party webhooks (Stripe, GitHub, Slack, Shopify, Standard Webhooks/Svix senders) correctly - raw-body signature… license: MIT ---

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.md and 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.
SenderHeaderSigned contentEncoding
StripeStripe-Signature: t=...,v1=...{t}.{raw body}hex HMAC-SHA256
GitHubX-Hub-Signature-256: sha256=...raw bodyhex HMAC-SHA256
SlackX-Slack-Signature: v0=... + X-Slack-Request-Timestampv0:{ts}:{raw body}hex HMAC-SHA256
ShopifyX-Shopify-Hmac-Sha256raw bodybase64 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.

FrameworkGet raw bytes
Expressapp.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler) registered before any global express.json() touches that route
Next.js App Routerconst raw = await request.text() in the route handler
Fastifyregister a content-type parser with parseAs: 'buffer' for the webhook route
FastAPI / Starletteraw = await request.body()
Djangorequest.body in a @csrf_exempt view
Railsrequest.raw_post in a controller that skips CSRF verification
Goio.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-id header, event.id, GitHub X-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_at in the same transaction as the business write. The primary key already guarantees one stored copy; the processed_at check 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_type with 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.yamlgenerated from this page

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.py194 lines, 7.6 KB
#!/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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem