The HTTP callback that lets your app react in real time when something happens inside someone else’s system.
Research-based overview. This guide is built from production patterns we have seen across Stripe, Lemon Squeezy, GitHub, and Supabase webhook implementations. How we research.
The flow looks like this in plain text:
That is the whole picture. Everything else in this article is about making sure each of those arrows actually works under load, under attack, and at 3am when you are not watching.
Before webhooks were common, the only way to know if something happened in another system was to ask repeatedly. Polling is fine for slow data; webhooks dominate for events that need a quick reaction.
| Approach | Latency | Cost | When it makes sense |
|---|---|---|---|
| Polling | Equal to your interval (often 60s+) | High — you call the API even when nothing changed | Vendor has no webhooks; data changes rarely; you can tolerate stale data |
| Webhooks | <1 second typically | Low — you only do work when something happens | You need fast reactions to events you don’t control (payments, signups, deploys) |
Most modern SaaS infrastructure now expects webhooks as the default and treats polling as a fallback for legacy use cases.
If you are building a typical solo SaaS in 2026, you will end up consuming webhooks from a handful of providers. Each has its own quirks, but the patterns repeat.
checkout.session.completed, invoice.payment_failed, customer.subscription.updated. Signed with HMAC-SHA256. Docs at stripe.com/docs/webhooks.order_created, subscription_created, subscription_cancelled. Signed with HMAC-SHA256 and a secret you set when creating the webhook. See docs.lemonsqueezy.com/help/webhooks.email.delivered, email.bounced, email.complained. Use these to update user state when emails fail.push, pull_request, workflow_run. Often the first webhook a developer ever consumes.A webhook handler is a public HTTP endpoint with no authentication. Anyone on the internet can hit it. That fact — and what you do about it — is the whole game.
Every reputable vendor signs its webhook payloads with a shared secret using HMAC. Your handler must verify the signature before trusting a single byte of the body. Here is the canonical pattern for a Stripe webhook in Node:
// Stripe webhook signature verification
import crypto from 'crypto';
function verifyStripeSignature(payload, signatureHeader, secret) {
// Stripe sends: t=timestamp,v1=hex_hmac
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
);
const signedPayload = `${parts.t}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time compare to prevent timing attacks
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
// Also reject events older than 5 minutes (replay protection)
const fresh = (Date.now() / 1000) - Number(parts.t) < 300;
return ok && fresh;
}
In practice you would just call stripe.webhooks.constructEvent() from the SDK, which does all of this for you. But knowing what is happening underneath is what separates “it works” from “it works under attack.”
Vendors retry. If your handler returns a 500, or times out, or your server briefly disappears, the same event will be delivered again — sometimes minutes later, sometimes hours later. If your handler grants access twice, sends two welcome emails, or charges a fulfillment cost twice, that is a bug.
The fix: every webhook payload contains a unique event ID (evt_1Oabcd… in Stripe; id in Lemon Squeezy). Before doing any work, insert that ID into a processed_webhooks table with a unique constraint. If the insert fails because the row already exists, you have already processed this event — return 200 and exit.
Vendors will retry failed deliveries on an exponential backoff for up to 3 days (Stripe) or 72 hours (Lemon Squeezy). If your endpoint was down longer than that, the event is gone forever — unless your provider exposes a replay UI. Stripe and Lemon Squeezy both do; lesser-known vendors often do not. Test the replay flow once before you actually need it.
/webhooks/stripe URL can POST a fake checkout.session.completed with any user ID they want and get a free subscription. Verify the signature, no exceptions.Where you put the handler matters more than founders realize.
On Vercel, webhook handlers live in app/api/webhooks/[provider]/route.ts. The default Edge runtime can read raw bodies but has a 30-second hard execution limit. The default Node runtime gives you 10 seconds on the Hobby plan and 60 seconds on Pro. For Stripe specifically, you must read the raw body before parsing — calling req.json() first will break signature verification because Stripe signs the exact byte sequence.
On Railway, you have no execution timeout, which is more forgiving but also more rope. Railway’s sleep-on-idle behavior on Hobby plans means cold starts can push you past a vendor’s timeout window — pin the service to always-on if you depend on tight latency. We compare these trade-offs in detail in our Vercel vs Railway breakdown.
The general rule: do the absolute minimum inside the handler — verify, dedupe, enqueue — then return 200. The actual provisioning, emails, and database joins should run in a background job (Inngest, QStash, Trigger.dev, or a Supabase queue). For a complete walkthrough that puts these patterns together end-to-end, see our guide on building an invoicing SaaS with Claude, which wires Stripe webhooks into a Postgres backend on Supabase — covered alongside the database trade-offs in Supabase vs Neon.
You cannot point a vendor’s webhook at localhost:3000 from the public internet, so testing is its own subgenre. The two dominant approaches:
stripe listen --forward-to localhost:3000/api/webhooks/stripe opens a tunnel and forwards real test-mode events to your local server. Free, official, what 90% of founders use.Whatever you pick, never hand-craft a fake webhook payload and POST it to your local server — it will skip signature verification and give you false confidence about a bug that only appears in production. For comparing payment vendors that emit these webhooks side-by-side, see our Lemon Squeezy vs Stripe piece.
A webhook is a one-line idea (“tell me when something happens”) with five layers of operational discipline underneath. Verify the signature. Dedupe on event ID. Acknowledge fast. Enqueue real work. Build a replay path. Get those right and webhooks become invisible plumbing. Skip any one of them and you will be debugging mystery double-charges and mysterious silent failures at 11pm.
The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.