The five things every webhook handler must do, the mistakes that cause real outages, and a checklist you can use to audit any handler in your codebase.
Research-based overview. This article synthesizes vendor documentation (Stripe, Lemon Squeezy, GitHub) and OWASP guidance into a single security checklist. How we research.
If your webhook handler is missing any one of these, you have a known production risk. None of them are optional, and none of them require advanced security knowledge to implement — just discipline.
2xx immediately and process the event asynchronously.Without signature verification, anyone with your endpoint URL can forge events. Stripe uses HMAC-SHA256 over the raw request body plus a timestamp, signed with a secret you store. Lemon Squeezy and most other modern providers use the same pattern.
Two implementation rules matter. First: verify against the raw request body, not the parsed JSON. If your framework parses the body before you read it, verification will fail in confusing ways — the byte order changes, whitespace gets normalized, and the signature no longer matches. Second: use a constant-time comparison, not ==. Timing attacks are unlikely in practice but the fix is one line.
Here’s what a Stripe webhook handler looks like in TypeScript on Next.js App Router:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-04-30.basil',
});
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // critical: raw, not JSON
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'no signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, WEBHOOK_SECRET);
} catch (err) {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
// event is now verified — safe to process
await enqueueEvent(event);
return NextResponse.json({ received: true });
}
For Lemon Squeezy, the equivalent is HMAC-SHA256 of the raw body with the signing secret you set in the dashboard, compared in constant time:
import crypto from 'crypto';
function verifyLemonSqueezy(rawBody: string, signature: string, secret: string) {
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(rawBody).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(digest, 'hex'),
Buffer.from(signature, 'hex'),
);
}
An attacker who captured a valid webhook payload once could replay it to your endpoint forever — the signature still verifies. The fix is checking the timestamp Stripe (and most providers) include in the signed payload.
Stripe’s constructEvent already enforces a default 5-minute tolerance. If you’re writing manual verification for a different provider, do this explicitly:
const eventTimestamp = parseInt(req.headers.get('webhook-timestamp') ?? '0');
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 300;
if (eventTimestamp < fiveMinutesAgo) {
return NextResponse.json({ error: 'too old' }, { status: 400 });
}
Don’t use a wider window than you need. Five minutes is enough to absorb clock skew and network delays without exposing replay-attack surface for hours.
Webhooks are delivered at least once, never exactly once. The vendor’s retry logic kicks in if your handler is slow, returns a non-2xx, or times out. Without idempotency, the same payment event can credit the customer twice, or send two welcome emails, or activate a subscription twice.
Stripe and most providers send a unique event ID with every delivery. Store it on a unique-indexed table (or a Redis set with TTL) the first time you process it; reject duplicates on retry:
// SQL migration
CREATE TABLE processed_webhook_events (
event_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
// In your handler
async function processEvent(event: Stripe.Event) {
const { error } = await db
.from('processed_webhook_events')
.insert({ event_id: event.id, provider: 'stripe' });
if (error?.code === '23505') {
// duplicate; we’ve already processed this event
return;
}
if (error) throw error;
// first time seeing this event — do the actual work
await handleEvent(event);
}
Combine this with database transactions: insert the idempotency record and apply the side effects in one transaction. If the side effect fails, the idempotency record gets rolled back and the next retry can succeed.
Webhook providers expect a fast response. Stripe times out at 30 seconds and retries on anything that looks unhealthy — slow handlers, 5xx errors, network drops. If your handler does heavy work synchronously (sending emails, generating PDFs, running migrations), you’ll trigger retry storms that compound the problem.
The pattern is: verify, persist the raw event to a queue, return 2xx, and process the event in a separate worker. On Vercel + Supabase the queue can be a Postgres table polled by a Vercel Cron job. On Railway it can be Inngest or a BullMQ + Redis pair.
export async function POST(req: NextRequest) {
// ...verify signature, check timestamp, dedupe...
await db.from('webhook_jobs').insert({
event_id: event.id,
payload: event,
status: 'pending',
});
return NextResponse.json({ received: true }); // fast 2xx
}
// Separate worker (cron, Inngest, etc.)
async function processPending() {
const jobs = await db.from('webhook_jobs')
.select('*').eq('status', 'pending').limit(50);
for (const job of jobs.data ?? []) {
try {
await handleEvent(job.payload);
await db.from('webhook_jobs').update({ status: 'done' }).eq('id', job.id);
} catch (err) {
await db.from('webhook_jobs').update({ status: 'failed' }).eq('id', job.id);
}
}
}
The choice between Vercel and Railway often comes down to whether you have meaningful background processing — webhooks are one of the clearest signals you do.
Stripe and Lemon Squeezy publish the IP ranges their webhook deliveries originate from. If your hosting platform supports IP-level filtering (Cloudflare, AWS WAF, Nginx allow directives), apply it as a defense-in-depth layer on top of signature verification.
This isn’t a substitute for verification — an attacker who somehow obtains your webhook secret could still spoof requests if they happened to control an IP in the allowlisted range. But it shrinks the attack surface significantly with low operational cost.
Five patterns that have caused real downtime in production webhook handlers, year after year:
The mistake: A debug log at the top of the handler that prints the parsed event. The consequence: Stripe payloads contain customer email addresses, partial card data, and IP addresses — all of which end up in your log aggregator (Datadog, Logtail, Vercel logs) where they’re searchable by anyone with read access. The fix: log only the event ID, type, and timestamp. Never the full payload.
Many frameworks parse the request body automatically. If you call req.json() before req.text(), the second call returns an empty string — and your signature check fails on every event. The fix is reading the raw body first, verifying, then parsing.
If your code does customer.credit_balance += amount on a payment event without idempotency, every retry double-credits the customer. The fix is idempotency keys (above) and using set-style operations rather than increment-style where possible.
If your webhook handler takes 25 seconds because it’s sending a welcome email synchronously, Stripe retries on the timeout. Each retry restarts the email job. Now you have three pending welcome emails, three idempotency-key conflicts, and a customer wondering why they got the same message three times. The fix is the async pattern above.
If you return 500 because your business logic encountered an expected condition (the customer already has this subscription, etc), the vendor will retry forever. Distinguish between “the webhook was received and processed” (return 2xx, even if the business outcome was a no-op) and “the webhook system is broken” (return 5xx, retry-worthy). Most application-level conditions should return 2xx.
Three approaches that work for solo founders without setting up complex test infrastructure:
stripe listen --forward-to localhost:3000/api/webhooks/stripe forwards real Stripe test events to your local server. stripe trigger payment_intent.succeeded fires specific event types on demand. This is the fastest way to iterate locally.Before shipping a webhook handler, walk through this list:
HMAC-SHA256 + constant-time comparison.2xx within 5 seconds; defers actual work to an async worker.If you’re setting up Stripe webhooks for the first time, start with the complete Stripe + Supabase tutorial — it walks through the verification, idempotency, and async patterns above with full working code. If you’re evaluating whether to use Stripe at all vs Lemon Squeezy, the payment processor comparison has the tradeoff in detail.
For founders who want to understand the broader concept, the what is a webhook primer covers the basics, and the what is Stripe Checkout page covers the related event flow.
Cited references in this article: Stripe webhook documentation, Lemon Squeezy webhook signing, OWASP API Security Top 10 (2023 edition), and Stripe’s engineering blog on webhook reliability.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.