Research-based overview. This article synthesizes vendor documentation (Stripe, Lemon Squeezy, GitHub) and OWASP guidance into a single security checklist. How we research.

A webhook handler is a public, writable endpoint that takes financial events and triggers irreversible side effects. Treat it like the highest-trust API surface you own. Most outages here are not exotic — they’re the same five oversights, made by the same kinds of teams.

The five things every webhook handler must do

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.

  1. Verify the request signature on every event.
  2. Reject events older than your replay window (typically 5 minutes).
  3. Use idempotency keys so duplicate deliveries don’t double-process.
  4. Return 2xx immediately and process the event asynchronously.
  5. Allowlist source IPs where the vendor publishes them.

1. Signature verification with HMAC-SHA256

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'),
  );
}

2. Replay protection via timestamp tolerance

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.

3. Idempotency keys

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.

4. Async processing with fast 2xx

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.

5. Source IP allowlist where available

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.

The mistakes that cause real outages

Five patterns that have caused real downtime in production webhook handlers, year after year:

Logging entire request payloads

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.

Reading the body twice and breaking signature verification

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.

Assuming exactly-once delivery

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.

Slow handlers that trigger retry storms

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.

Returning non-2xx for application-level failures

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.

How to test webhooks safely

Three approaches that work for solo founders without setting up complex test infrastructure:

  • Stripe CLI for local development. 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.
  • Ngrok or Cloudflare Tunnels for sharing local endpoints. If you need to receive webhooks from a third party that doesn’t support local forwarding (most non-Stripe providers), expose your local server via a temporary public URL.
  • Webhook replay tooling in production. Stripe’s dashboard shows every delivered event with replay buttons. Lemon Squeezy has a similar interface. When debugging a production issue, replay the actual event against a staging environment to reproduce the bug.

Webhook handler audit checklist

Before shipping a webhook handler, walk through this list:

  • Reads the raw request body before parsing JSON.
  • Verifies the signature with the vendor’s SDK or HMAC-SHA256 + constant-time comparison.
  • Rejects events older than 5 minutes.
  • Stores a unique event ID with a unique constraint to deduplicate retries.
  • Returns 2xx within 5 seconds; defers actual work to an async worker.
  • Logs only event ID, type, timestamp — never the full payload with PII.
  • Distinguishes business-logic conditions (return 2xx) from infrastructure failures (return 5xx).
  • Has a replay test that confirms re-processing the same event is a no-op.
  • Has a documented runbook for what to do when the handler fails (queue inspection, retry, manual replay).

Where to go from here

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.

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.