What Is a Webhook?
A webhook is an HTTP POST request that an external service sends to your server when an event occurs — payments, form submissions, status changes — without you having to poll.
Webhooks invert the normal request model. Instead of your app repeatedly asking "did anything change?", the external service notifies your app the instant something happens. The pattern is older than the term — phone numbers in the 1980s used the same model when an incoming call interrupted the receiver — but it became standard on the web around 2007 when GitHub introduced webhooks for repository events. Today, every serious SaaS platform offers webhooks because polling-based integrations do not scale and cannot support real-time UX.
How Webhooks Work Step By Step
-
You expose a public HTTPS endpoint on your server — for example
https://yourapp.com/api/webhooks/stripe. The endpoint must be reachable from the public internet, not localhost. During development, tunnel tools like ngrok, Cloudflare Tunnel, or Stripe CLI'sstripe listenexpose your local machine to the public internet. -
You register the endpoint URL with the external service through its dashboard or API. You usually also select which event types you want to receive — there is no benefit to subscribing to every event when you only handle three of them.
-
The external service sends a POST request to your endpoint when a subscribed event happens. The body is JSON. Headers typically include a signature for verification and an event ID for deduplication.
-
Your handler validates the signature, processes the event, and returns HTTP 200 within the provider's timeout (usually 5–30 seconds). The processing should be idempotent — see below.
-
If you return anything other than 2xx (or fail to respond within the timeout), most providers retry delivery with exponential backoff — Stripe retries up to 3 days, GitHub retries 8 times over 24 hours. After the retry window, the event is dropped or moved to a dead-letter queue.
Why Webhooks Matter For SaaS
Stripe's entire payment lifecycle is webhook-driven. A customer clicks Pay → Stripe processes the charge → Stripe sends checkout.session.completed → your webhook handler activates the subscription. If you rely on the browser-side redirect alone (waiting for the user to land on /success and then activating the subscription), you will miss payments when users close the tab early, when their network drops on the return trip, or when their bank takes 30 seconds to authorize. The redirect is a UX nicety; the webhook is the source of truth for whether the payment succeeded.
The same pattern applies elsewhere. Shopify webhooks notify your app of new orders so fulfillment can begin without polling. GitHub webhooks trigger CI builds the instant a push lands. Slack webhooks deliver message events to bot apps in real time. Calendar tools webhook into reservation systems to broadcast availability changes. Every modern SaaS platform that wants partner-built integrations exposes webhooks because the alternative — every partner polling every customer's account every minute — does not scale.
Security: Always Verify The Signature
Every webhook provider signs each payload with a secret shared between you and them. The signature is delivered in an HTTP header (Stripe-Signature, X-Hub-Signature-256 for GitHub, X-Shopify-Hmac-Sha256 for Shopify). Before processing the payload, your handler must verify the signature using the shared secret.
Without signature verification, any attacker who guesses your webhook URL can POST forged events to your endpoint. Imagine an attacker POSTing a fake checkout.session.completed for $1 that activates a $5000-tier subscription. Signature verification is non-negotiable — Stripe even rotates the signing secret on rolling intervals to limit blast radius if it leaks.
Use the official SDK rather than rolling your own verification: stripe.webhooks.constructEvent(payload, signature, secret) handles the HMAC, timestamp tolerance, and timing-safe comparison correctly. Custom verification code routinely gets at least one of these wrong.
Idempotency: Webhooks Are Delivered At Least Once
Webhook providers guarantee delivery, but they do not guarantee delivery exactly once. The same event can arrive twice if the network drops your response, if the provider's retry logic fires after your first 200 was lost, or if the provider's internal infrastructure replays an event during a recovery. Your handler must be idempotent — processing the same event twice must produce the same end state.
The standard pattern is to deduplicate by event ID. Every webhook payload includes a unique ID (evt_xxx for Stripe, the X-GitHub-Delivery header for GitHub). Maintain a table of processed event IDs (a Postgres table with a unique index on event_id works fine). At the top of the handler, INSERT the event ID with ON CONFLICT DO NOTHING. If the insert returned a duplicate, skip processing and return 200. Without this, customers receive duplicate emails, duplicate webhook-driven side effects, and duplicate billing actions.
Common Pitfalls In Production
Synchronous heavy work in the handler. Webhook providers expect a response within seconds. If your handler synchronously sends emails, generates PDFs, or calls a slow third-party API, you will hit the timeout and the provider will retry — turning one event into many. Acknowledge fast: validate, persist the raw payload to a queue or table, return 200, then process the queue with a background worker.
Localhost during development. Webhook providers cannot POST to your laptop directly. Use stripe listen --forward-to localhost:3000/api/webhooks/stripe or ngrok to tunnel. Without this, your local development hits the wall the first time you implement a webhook flow.
Missing the raw body for signature verification. Frameworks like Express auto-parse JSON bodies, which destroys the exact byte sequence needed for HMAC. For webhook routes, configure the framework to receive the raw body — in Next.js Route Handlers, read request.text() instead of request.json() before verification, then JSON-parse the verified string.
Order of events. Webhooks do not always arrive in the order they happened. A subscription.updated event can arrive before the original subscription.created if the network reroutes them differently. Handlers should be idempotent and tolerate out-of-order delivery — use the event timestamp in the payload to detect "this update is older than what I already have, skip it."
Lost events during deploys. If your webhook endpoint returns 5xx during a deploy window, providers retry but eventually give up. Maintain a dead-letter queue that captures any event whose final delivery attempt failed, alert on dead-letter volume, and provide a re-drive button that re-processes failed events from the provider's event history API.
Webhooks vs Polling vs Server-Sent Events
Polling is simplest but does not scale — your server asks the provider every minute "anything new?" Most calls return nothing. Latency is the polling interval. Acceptable for low-frequency, non-time-sensitive data.
Webhooks are the standard for server-to-server real-time events. Provider pushes events to your endpoint when they happen. Sub-second latency in most cases.
Server-Sent Events (SSE) and WebSockets are for browser-to-server real-time updates — your dashboard subscribing to live changes. Not the right tool for cross-service integration. Use webhooks for service-to-service, SSE/WebSockets for service-to-browser.
A well-architected SaaS will use all three at different layers: webhooks for inbound integrations from payment processors and platforms, polling for legacy APIs that do not offer webhooks, and SSE/WebSockets to push updates from your server to your users' browsers.