Stripe Webhook Signature Verification
Concept of the skill
What it is: The security check that proves an incoming Stripe webhook was signed by Stripe before any payment logic runs.
Mental model: The raw request body, signature header, and webhook secret form one verification tuple; change any part and the event is untrusted.
Why it exists: Webhook routes are public endpoints that can trigger billing and fulfillment, so authenticity has to be established before routing.
What it is NOT: It is not payment-event routing, general HTTP signature validation, or Stripe API usage outside webhook delivery.
Adjacent concepts: HMAC verification, raw request bodies, replay tolerance, idempotency keys.
One-line analogy: It is the seal check before opening the payment envelope.
Common misconception: Parsing JSON first is harmless; transforming the raw bytes invalidates the signature comparison.
Coverage
- The raw-body requirement — why
stripe.webhooks.constructEvent() requires the unparsed Buffer from the request body, and how Next.js App Router routes expose it via request.arrayBuffer()
- HMAC-SHA256 verification — how
constructEvent(rawBody, signature, secret) reconstructs and compares the Stripe signature internally
- Replay protection — the 300-second tolerance window Stripe checks against the
t= timestamp embedded in the stripe-signature header; when to tighten it
- Environment-specific secrets —
STRIPE_WEBHOOK_SECRET for production vs whsec_... from the Stripe CLI --forward-to session in development; why they must never be swapped
- Idempotency key pattern — recording the
event.id in Postgres before processing so a retried delivery does not double-charge or double-fulfill
Philosophy of the skill
A webhook that skips signature verification is an unauthenticated public endpoint that can trigger payment processing. The verification step is load-bearing security, not a convenience check. Stripe's SDK makes verification a single call, but two failure modes are common in practice: the request body gets parsed (by a body-parser middleware) before the raw bytes reach the verification call, which silently corrupts the HMAC comparison; and the wrong webhook secret is loaded from environment variables, producing a 400 that is hard to distinguish from a replay rejection. Both failures look the same to the caller — a rejected webhook — and both are invisible until a real event is dropped.
Verification
Confirm raw body access. In Next.js App Router: const rawBody = Buffer.from(await request.arrayBuffer()). Do NOT pass await request.json() or await request.text() — both transform the bytes.
Retrieve and verify.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const sig = request.headers.get("stripe-signature") ?? "";
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return Response.json({ error: "Signature verification failed" }, { status: 400 });
}
Check idempotency before processing.
INSERT INTO webhook_events (event_id, processed_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
If the RETURNING clause returns no rows, the event was already processed — return 200 immediately without re-running side effects.
Route the verified event to payment-provider-router.
Failure Mode Reference
| Failure |
Symptom |
Fix |
| Body parsed before verification |
400 on every real Stripe event |
Use arrayBuffer(), not json() or text() |
| Wrong webhook secret |
400 with "No signatures found matching the expected signature" |
Verify STRIPE_WEBHOOK_SECRET matches the endpoint in the Stripe dashboard |
| Replay attack |
400 with "Timestamp too old" |
Legitimate if tolerance is tight; check t= value in the stripe-signature header |
| Secret from wrong environment |
Events verify in dev but fail in production |
Use per-environment secrets; never share between environments |
Do NOT Use When
| Use instead |
When |
payment-provider-router |
You have a verified event and need to decide which handler processes it |
nextjs-server-action-validation |
You are validating user-submitted form data, not a Stripe webhook |
| (a generic HTTP signature skill) |
You are verifying webhooks from a non-Stripe provider with a different signing scheme |
1---2# name: stable skill identifier. Match the skill directory name or the final namespace segment.3# Lowercase letters/numbers with hyphen, slash, or colon separators.4name: stripe-webhook-signature-verification5# description: routing-facing summary of what the skill covers and when it activates.6# Include concrete triggers and an explicit negative boundary; keep routing semantics out of prose-only ambiguity.7description: "Use when validating incoming Stripe webhook requests in a Node.js or Next.js backend before processing any payment event. Verifies the `stripe-signature` header against `STRIPE_WEBHOOK_SECRET` using Stripe's HMAC-SHA256 scheme, and rejects replays older than 300 seconds. Do NOT use for general HTTP signature validation (use a generic crypto-signature skill), for processing the webhook payload after signature is confirmed (use payment-provider-router), or for Stripe API calls that are not webhook-driven."89# === v8 Classification (subject + public; polyhierarchy via subjects[]) — see ADR-0017 ===10# subject: primary browse shelf — what the skill teaches. One of twelve closed values:11# backend-engineering / frontend-engineering / software-architecture / data-engineering / agent-ops / ai-engineering /12# quality-assurance / design / reasoning-strategy / software-engineering-method / knowledge-organization / product-domain.13subject: backend-engineering14# public: publishability/private-data gate. Boolean.15# true = publishable/shareable; false = private and excluded from public export.16# Project anchoring is carried separately by non-empty `project[]` plus `grounding`.17public: true18# scope: free-text PRD-style statement of what the skill teaches and what it excludes.19# (v8 required; not an enum). Mirrors Coverage + Do NOT Use When at frontmatter level.20scope: "Verifying the authenticity of an incoming Stripe webhook via signature check before any processing in the saas-stripe-postgres example. Excludes routing the verified event (payment-provider-router) and the downstream database writes (postgres-rls-pattern)."21# taxonomy_domain: optional hierarchical sub-path within `subject`. Slash-delimited22# lowercase kebab-case segments. rename of the original v8 `domain`. Remove when the flat23# `subject` is sufficient.24taxonomy_domain: engineering/payments2526# stability: lifecycle marker. One of:27# experimental (active development) / stable (production-ready) /28# frozen (no further changes expected) / deprecated.29# When `deprecated`, schema's allOf REQUIRES `superseded_by: <real-skill-name>`.30stability: experimental31# license: SPDX license identifier (e.g., MIT, Apache-2.0).32license: MIT33# compatibility: runtime compatibility object. Prefer structured fields34# (`agent_runtimes`, `node_version`) over free-text `notes`.35compatibility:36 agent_runtimes:37 - node38 node_version: ">=20"39 notes: "Stripe SDK >=14; expects raw request body (not parsed JSON) for signature verification."40allowed-tools: Read Grep41# keywords: semantic phrases for fuzzy router activation. v8 cap: max 10.42# Keep terms a user would actually type when starting a task in this skill's domain.43keywords:44 - stripe webhook signature verification45 - stripe-signature header46 - webhook hmac verification47 - STRIPE_WEBHOOK_SECRET48 - stripe constructEvent49 - replay attack prevention50 - webhook security51 - payment webhook validation52 - stripe webhook secret53# triggers: explicit-match activation phrases the router fires on literally.54# Use when label-based routing is intended; usually keywords + examples are enough.55triggers:56 - stripe-webhook-signature-verification57# paths: glob array of code surfaces this skill governs. Supports gitignore-style58# negation. Each glob should map to ONE canonical skill. Omit if purely conceptual.59paths:60 - "app/api/webhooks/stripe/route.ts"61 - "lib/stripe/webhook.ts"62# examples: 2-5 realistic user prompts the skill SHOULD activate for.63# Written in the user's voice. Improves retrieval recall beyond keywords alone.64examples:65 - "how do I verify that a webhook is really from Stripe?"66 - "my webhook handler is returning 400 — is the signature verification failing?"67 - "set up the Stripe webhook endpoint in a Next.js App Router API route"68 - "reject replayed webhook events older than 5 minutes"69# anti_examples: near-miss prompts that should route ELSEWHERE.70# Pair with relations.suppresses (or legacy boundary alias) to name the confusable territory's owner.71anti_examples:72 - "process the Stripe payment_intent.succeeded event payload"73 - "call the Stripe API to create a payment intent"74 - "validate a generic HTTP signature that is not from Stripe"75# relations: typed graph edges to sibling skills. Current fields:76# related (adjacency for browse / co-routing expansion) /77# suppresses (exclude listed skills from co-routing when THIS skill wins; write reason78# as "I own this exclusively over X", not "use X instead") /79# boundary (DEPRECATED alias of suppresses, retained for unmigrated skills) /80# verify_with (cross-check; co-loaded as one-hop expansion) /81# depends_on (composition; transitive — A→B→C loads all three) /82# broader / narrower (SKOS-style generalization) /83# disjoint_with (mutual exclusion for incompatible ownership).84relations:85 related:86 - payment-provider-router87 - nextjs-server-action-validation88 depends_on:89 - postgres-rls-pattern90 verify_with:91 - nextjs-server-action-validation92---9394# Stripe Webhook Signature Verification9596## Concept of the skill9798**What it is:** The security check that proves an incoming Stripe webhook was signed by Stripe before any payment logic runs.99**Mental model:** The raw request body, signature header, and webhook secret form one verification tuple; change any part and the event is untrusted.100**Why it exists:** Webhook routes are public endpoints that can trigger billing and fulfillment, so authenticity has to be established before routing.101**What it is NOT:** It is not payment-event routing, general HTTP signature validation, or Stripe API usage outside webhook delivery.102**Adjacent concepts:** HMAC verification, raw request bodies, replay tolerance, idempotency keys.103**One-line analogy:** It is the seal check before opening the payment envelope.104**Common misconception:** Parsing JSON first is harmless; transforming the raw bytes invalidates the signature comparison.105106## Coverage107108- The raw-body requirement — why `stripe.webhooks.constructEvent()` requires the unparsed `Buffer` from the request body, and how Next.js App Router routes expose it via `request.arrayBuffer()`109- HMAC-SHA256 verification — how `constructEvent(rawBody, signature, secret)` reconstructs and compares the Stripe signature internally110- Replay protection — the 300-second tolerance window Stripe checks against the `t=` timestamp embedded in the `stripe-signature` header; when to tighten it111- Environment-specific secrets — `STRIPE_WEBHOOK_SECRET` for production vs `whsec_...` from the Stripe CLI `--forward-to` session in development; why they must never be swapped112- Idempotency key pattern — recording the `event.id` in Postgres before processing so a retried delivery does not double-charge or double-fulfill113114## Philosophy of the skill115116A webhook that skips signature verification is an unauthenticated public endpoint that can trigger payment processing. The verification step is load-bearing security, not a convenience check. Stripe's SDK makes verification a single call, but two failure modes are common in practice: the request body gets parsed (by a body-parser middleware) before the raw bytes reach the verification call, which silently corrupts the HMAC comparison; and the wrong webhook secret is loaded from environment variables, producing a 400 that is hard to distinguish from a replay rejection. Both failures look the same to the caller — a rejected webhook — and both are invisible until a real event is dropped.117118## Verification1191201. **Confirm raw body access.** In Next.js App Router: `const rawBody = Buffer.from(await request.arrayBuffer())`. Do NOT pass `await request.json()` or `await request.text()` — both transform the bytes.1211222. **Retrieve and verify.**123124 ```typescript125 import Stripe from "stripe";126 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);127128 const sig = request.headers.get("stripe-signature") ?? "";129 let event: Stripe.Event;130 try {131 event = stripe.webhooks.constructEvent(132 rawBody,133 sig,134 process.env.STRIPE_WEBHOOK_SECRET!135 );136 } catch (err) {137 return Response.json({ error: "Signature verification failed" }, { status: 400 });138 }139 ```1401413. **Check idempotency before processing.**142143 ```sql144 INSERT INTO webhook_events (event_id, processed_at)145 VALUES ($1, now())146 ON CONFLICT (event_id) DO NOTHING147 RETURNING event_id;148 ```149150 If the `RETURNING` clause returns no rows, the event was already processed — return 200 immediately without re-running side effects.1511524. **Route the verified event** to `payment-provider-router`.153154## Failure Mode Reference155156| Failure | Symptom | Fix |157|---------|---------|-----|158| Body parsed before verification | 400 on every real Stripe event | Use `arrayBuffer()`, not `json()` or `text()` |159| Wrong webhook secret | 400 with "No signatures found matching the expected signature" | Verify `STRIPE_WEBHOOK_SECRET` matches the endpoint in the Stripe dashboard |160| Replay attack | 400 with "Timestamp too old" | Legitimate if tolerance is tight; check `t=` value in the `stripe-signature` header |161| Secret from wrong environment | Events verify in dev but fail in production | Use per-environment secrets; never share between environments |162163## Do NOT Use When164165| Use instead | When |166|---|---|167| `payment-provider-router` | You have a verified event and need to decide which handler processes it |168| `nextjs-server-action-validation` | You are validating user-submitted form data, not a Stripe webhook |169| (a generic HTTP signature skill) | You are verifying webhooks from a non-Stripe provider with a different signing scheme |