Definition
A webhook is an HTTP request that a vendor (Stripe, GitHub, Resend, etc.) sends to a URL you control when an event happens in their system. Instead of your app polling them every minute asking “did anything happen?”, they push the event to you the moment it occurs.

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:

[Event happens at vendor]
    ↓
[Vendor POSTs JSON payload to https://yourapp.com/api/webhooks/stripe]
    ↓
[Your handler verifies the signature, parses the payload, updates your DB]
    ↓
[Your handler returns HTTP 200 within ~5 seconds]
    ↓
[Vendor marks the delivery as successful and stops retrying]

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.

Webhooks vs polling

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.

ApproachLatencyCostWhen it makes sense
PollingEqual to your interval (often 60s+)High — you call the API even when nothing changedVendor has no webhooks; data changes rarely; you can tolerate stale data
Webhooks<1 second typicallyLow — you only do work when something happensYou 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.

Common webhook providers solo founders see

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.

How to consume webhooks safely

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.

Signature verification (the non-negotiable)

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.”

Idempotency keys

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.

Retries and replay capability

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.

5 webhook mistakes that break in production

  1. Missing idempotency. Vendor retries a webhook on a network blip; your code provisions the same subscription twice; you ship a customer two welcome emails and double-credit their account. Always dedupe on event ID.
  2. No signature check. An attacker who finds your /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.
  3. Slow handler. Most vendors give you 5–30 seconds before they treat the request as failed. Doing heavy work inline (sending emails, calling external APIs) inside the handler is the most common cause of phantom retries. Acknowledge fast, then enqueue the real work.
  4. Swallowed errors. Returning HTTP 200 on errors so the vendor stops retrying — but never logging the failure — is how revenue silently disappears. Log every failure, alert on repeat failures, and only return 200 when the work actually succeeded.
  5. No replay capability. When a bug in your handler corrupts state, you need to be able to re-run the last 24 hours of events against fixed code. Build a replay script before you need it.

Webhooks in your Vercel or Railway deployment

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.

Testing webhooks locally

You cannot point a vendor’s webhook at localhost:3000 from the public internet, so testing is its own subgenre. The two dominant approaches:

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.

The takeaway

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.

Get one SaaS build breakdown every week

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