---
name: webhook-safety-review
description: Review incoming webhook handlers for the four failures that cost money — unverified signatures, missing idempotency, work done before acknowledging, and errors that make the sender retry forever. Use when adding or changing a webhook, integrating Stripe/GitHub/Slack, or after a duplicate charge or missed event.
---
<!--
  webhook-safety-review — from Toolbay
  https://toolbay.ai/product/webhook-safety-review
  Free to use, modify, and share. Keep this line and others can find it too.
-->


# Webhook Safety Review

## Install

Save this file as `~/.claude/skills/webhook-safety-review/SKILL.md`, or
`.claude/skills/webhook-safety-review/SKILL.md` to scope it to one repo. Claude
Code auto-discovers it. Invoke with `/webhook-safety-review` or by asking "is my
Stripe webhook safe?".

## Why this exists

A webhook handler is an unauthenticated public endpoint that a stranger can call,
which reacts to it by granting entitlements or moving money. Almost every serious
bug in one falls into four buckets, and all four are invisible in normal testing
because your own test events are well-behaved.

Rank findings by money and access, not by tidiness.

## Step 1 — Find every handler

```
rg --files -g '**/webhook*' -g '**/webhooks/**' -g '**/hooks/**'
rg -ln "stripe|github|slack|shopify|twilio|resend|clerk|paddle" -g '**/route.ts' -g '**/*.py' -g '**/*.go'
```

For each, note the sender and what it causes: creating an order, granting a
licence, sending mail, updating a subscription. That consequence sets the severity
of everything below.

## Step 2 — Signature verification, before anything else

The single highest-severity check. Confirm, with file and line:

1. **A signature is verified at all.** No verification means anyone who learns
   the URL can post a fake `payment_succeeded` and grant themselves goods.
2. **It runs BEFORE the body is parsed or acted on.** Verifying after you have
   already created a record is decoration.
3. **It uses the RAW body.** Frameworks that auto-parse JSON break signatures
   silently, because re-serialising changes bytes. Look for the explicit raw-body
   escape hatch, and be suspicious if you cannot find one.
4. **It is constant-time.** `===` on a signature is a timing oracle; expect
   `timingSafeEqual` or the SDK's own verifier.
5. **The secret comes from the environment**, is not a fallback default, and is
   not the publishable/public key by mistake.
6. **A timestamp is checked** where the sender provides one, so a captured
   request cannot be replayed later.

```
rg -n "constructEvent|verifySignature|timingSafeEqual|createHmac|x-hub-signature|stripe-signature" -g '**/route.ts'
```

If verification is missing on a handler that grants anything, stop and report it
immediately. Nothing else in this review matters as much.

## Step 3 — Idempotency

**Senders retry. Assume every event arrives more than once.** Stripe retries for
days; a network blip mid-response guarantees a duplicate.

For each handler, find what makes reprocessing safe:

- A unique constraint on the event id, or on a natural key like the order id.
- An upsert rather than a create.
- A processed-events table checked before doing the work.

```
rg -n "@@unique|@unique|ON CONFLICT|upsert|findUnique.*event" 
```

The failure is not theoretical: without this, a retried `checkout.completed`
creates a second order, a second licence, or a second payout. If you have ever
seen a duplicate charge or a double-granted entitlement, this is where it came
from.

Check the guard is on the RIGHT key. Deduplicating on your own generated id while
the sender retries with the same event id protects nothing.

## Step 4 — Acknowledge fast, work after

Senders time out, and a timeout is treated as failure, so slow work causes
duplicate deliveries and eventually disables the endpoint.

Flag any handler doing heavy work inline before responding: sending email,
generating files, calling other APIs, long transactions. The shape you want is
verify, record, return 200, then process out of band (a queue, or a
platform-native after-response hook).

## Step 5 — Return the right status

This is where handlers quietly become incident generators:

- **200** — received and either handled or safely ignored. **Return 200 for event
  types you do not care about**, otherwise the sender retries them forever.
- **400** — signature invalid or body malformed. Permanent failure; do not invite
  a retry.
- **5xx** — YOUR transient failure, where you genuinely want a retry.

The classic bug is a handler that throws on an unrecognised event type, returns
500, and gets retried forever until the provider disables the endpoint. The
second classic is catching everything and returning 200, so real failures are
silently dropped and the sender never retries something you actually needed.

Read the catch blocks specifically. Both mistakes live there.

## Step 6 — Check what the handler trusts from the payload

Treat the body as attacker-controlled even after signature verification, because
a legitimate sender still sends fields you should not blindly trust.

- **Never trust an amount, price, or currency from the payload.** Re-read it from
  your own record or from the provider's API using the id.
- Resolve the user from your stored mapping, not from an email in the body.
- Validate the shape with a schema; do not index into nested fields hopefully.

## Step 7 — Report

```
Handlers reviewed: <n>

CRITICAL
  <file:line>  <endpoint>  — <no signature check | verified after acting | trusts payload amount>
                              attack: <what a stranger posts, and what they get>

HIGH
  <file:line>  — not idempotent on <key>  -> retry causes <duplicate order/licence/charge>

MEDIUM
  <file:line>  — returns <status> for <case>  -> <infinite retry | silent drop>
  <file:line>  — <slow work> before responding

VERIFIED SAFE
  <file:line>  — signature verified pre-parse, idempotent on <key>, correct statuses
```

For every CRITICAL, write the concrete attack in one sentence. "Anyone who learns
this URL can post a fake payment event and receive a paid product for free" gets
fixed today.

## Rules

- Signature verification before parsing. Everything else is secondary.
- Assume duplicate delivery, always. Idempotency is not optional.
- 200 means "received", not "succeeded". Unknown event types get 200.
- Never trust money amounts from a payload, even a signed one.
- Do not report a handler safe without citing where each of the four properties
  is enforced.
