Webhooks explained: how apps talk to Stripe and other services
What a webhook is, how Stripe and GitHub deliver them, how to verify signatures, handle retries and duplicates, and build a payment webhook handler that doesn’t lose or double-count orders.
Key Takeaways
- A webhook is an HTTP POST another service sends you when something happens. You register a URL; the service sends a JSON description of each event to it. It replaces polling the service’s API on a timer.
- Verify every request before acting on it. Anyone can POST to your URL. Stripe signs each event with an HMAC-SHA256 signature in the Stripe-Signature header, computed over the raw body; verify it with the endpoint’s secret.
- Expect duplicates and any order. Stripe says endpoints may receive the same event more than once and doesn’t guarantee order. Record processed event IDs and fetch the latest object when order matters.
- Reply fast, work later. Stripe asks for a 2xx response before complex logic; GitHub expects one within 10 seconds. Save the event, respond, then process it in the background.
- Failed deliveries are retried, so fix and catch up. In live mode Stripe retries for up to three days with exponential backoff. GitHub’s docs advise redelivering missed webhooks yourself once your server is back.
Quick-Start Prioritization Framework
| Rule | Why | How |
|---|---|---|
| Verify the signature | Stops forged events | Official library, raw request body, endpoint secret |
| Reject stale timestamps | Stops replayed events | Stripe libraries default to a 5-minute tolerance |
| Return 2xx quickly | Avoids timeouts and retries | Store the event, respond, process in a queue |
| Deduplicate by event ID | Same event can arrive twice | Unique constraint on a processed_events table |
| Don’t rely on order | Events can arrive out of sequence | Re-fetch the object from the API when it matters |
| Subscribe only to events you use | Less load, fewer surprises | Pick event types when creating the endpoint |
Your app asks Stripe to charge a card, and the customer is sent to a checkout page. A minute later they pay. How does your app find out? Not by asking Stripe every few seconds. Stripe calls your app. That call is a webhook, and almost every service an app connects to uses them: payments, source control, email delivery, form tools. This guide explains how webhooks work, what Stripe and GitHub document about delivering them, and the handful of rules that make a webhook handler reliable.
Polling versus webhooks
There are two ways for your app to learn that something changed in another service.
Polling: you keep asking
Your server calls the service’s API every minute: “any new payments?”. It is simple, but wasteful when nothing has changed, slow when something has, and easy to hit rate limits with.
Webhooks: they tell you
You give the service a URL. When an event happens, such as a payment succeeding, a pull request opening or an email bouncing, the service sends an HTTP POST with the details to that URL. Your backend handles it like any other request.
Why some things only arrive by webhook
Stripe’s docs list cases that happen long after your code ran: a customer’s bank confirming a payment, a dispute, a recurring subscription payment succeeding. There is no user on your site at that moment. The webhook is how your app hears about it.
Anatomy of a webhook delivery
Using Stripe as the example, a delivery has three parts.
The endpoint
A public HTTPS URL on your backend, such as https://example.com/webhooks/stripe, registered in the provider’s dashboard or API. Stripe requires HTTPS in live mode, TLS 1.2 or higher, and treats 3xx redirects as failures, so register the final URL.
The payload
A JSON body describing the event: an id (evt_…), a type such as checkout.session.completed or invoice.paid, a created timestamp and the related object. Stripe now offers “thin” events, which carry a reference you use to fetch the latest object, and “snapshot” events, which carry the object as it was.
The signature header
Stripe adds a Stripe-Signature header with a timestamp (t) and one or more v1 signatures. Each v1 value is an HMAC with SHA-256 over the timestamp, a full stop, and the exact request body, keyed with your endpoint’s signing secret, which starts with whsec_. GitHub uses a shared secret the same way; its header is X-Hub-Signature-256.
Verifying that a webhook is real
Without verification, Stripe warns, an attacker could send fake events to your endpoint to trigger actions like fulfilling orders or granting access. Verification takes a few lines with the provider’s library.
Use the raw body
The signature covers the exact bytes Stripe sent. If your framework parses the JSON and you re-serialise it, spacing or key order can change and verification fails. Read the raw body for this route.
Let the library check the timestamp
Stripe’s libraries reject events whose timestamp is more than 5 minutes from your server’s clock by default, which blocks replay of an old, intercepted request. Stripe says never to set the tolerance to 0, because that disables the check. Keep your server clock synced.
Rotate secrets without downtime
When you roll a Stripe signing secret, you can keep the old one valid for up to 7 days. During that time Stripe sends one signature per active secret, and your code should accept the event if any v1 signature matches.
Extra layers
Stripe also publishes the IP addresses it sends from, for an allowlist. If your framework enforces CSRF tokens on every POST, exempt the webhook route; the signature is its protection instead.
Retries, duplicates and ordering
Webhook delivery is “at least once”, not “exactly once”. Design for that from the start.
Retries
If your endpoint doesn’t return a 2xx status, Stripe retries live-mode events for up to three days with exponential backoff, and sandbox events three times over a few hours. You can also resend an event from the Dashboard for up to 15 days, or with the CLI for up to 30 days. GitHub ends the connection if you don’t respond within 10 seconds and counts the delivery as failed; its best-practice page tells you to redeliver missed events yourself.
Duplicates
Stripe says an endpoint might occasionally receive the same event more than once, and recommends logging processed event IDs and skipping ones you have seen. GitHub suggests the X-GitHub-Delivery header for the same purpose.
Ordering
Stripe doesn’t guarantee events arrive in the order they were created. A new subscription can produce customer.subscription.created, invoice.created, invoice.paid and charge.created, in any order. Stripe also notes that created is in whole seconds, so distinct events can share it. If your logic depends on current state, fetch the object from the API instead of trusting the order of arrival.
Worked example: a subscription webhook that can’t double-count
A small app sells a $12 monthly plan with Stripe Checkout. The goal: mark the user as paid exactly once per invoice, no matter how many times or in what order events arrive.
- Create a table processed_events(event_id text primary key, received_at timestamp). The primary key is the duplicate guard.
- In the webhook route, read the raw body and the Stripe-Signature header, and verify with the endpoint secret. If verification fails, return 400 and stop.
- Insert the event ID into processed_events. If the insert fails because the ID already exists, return 200 and stop: you have handled this one.
- Put the event on a background queue and return 200 immediately.
- In the background worker, for invoice.paid, retrieve the invoice and subscription from Stripe’s API so you act on current state, then update the user’s plan and paid_until date in one transaction.
- If the worker fails, let it retry from the queue; the event is already safely stored. Alert if an event stays unprocessed for more than an hour.
- Test locally with stripe listen --forward-to localhost:4242/webhook and stripe trigger invoice.paid before going live.
Testing webhooks before launch
Webhooks are hard to test by clicking around because the provider has to reach your machine.
Forward events to localhost
The Stripe CLI’s stripe listen --forward-to command relays events from your account to a local URL and prints a temporary signing secret to use while testing. stripe trigger payment_intent.succeeded creates a test event on demand. For other providers, a tunnelling tool gives your local server a temporary public HTTPS URL.
Watch the delivery log
Stripe’s Dashboard shows each delivery’s status and HTTP code, and when the next retry will happen. A 4xx usually means your URL, method or verification is wrong; a 5xx means your code threw; a timeout means you did too much work before replying.
Replay real failures
When you fix a bug, resend the failed events rather than waiting for the next real one. Your duplicate guard makes this safe.
Standard Webhooks
Every provider used to invent its own header names and signing scheme. Standard Webhooks is an open set of guidelines and libraries for sending and verifying webhooks the same way, with a technical steering committee that includes people from Zapier, Twilio, Supabase, Svix, ngrok and others. Its goals are security against spoofing, replay and SSRF, interoperability, and reliability. If you are sending webhooks from your own app, following it saves your consumers work.
Common mistakes
Most webhook incidents trace back to one of these.
Fulfilling orders on the success page
The redirect back to “/thanks” happens in the customer’s browser. They might close the tab first, or a bot might visit the URL. Grant access from the webhook, and use the success page only to show status.
Skipping verification in production
Verification often gets switched off while debugging and never switched back on. Make the handler refuse unsigned requests unconditionally.
Doing slow work before responding
Sending emails, generating PDFs or calling other APIs inside the webhook request leads to timeouts, retries and duplicate work. Store, respond, then process. Stripe also warns that spikes, such as the start of the month when subscriptions renew, can overwhelm synchronous handlers.
Using one secret for test and live
Stripe generates a different signing secret for each endpoint and for test and live modes. Mixing them up is a frequent cause of “signature verification failed” on launch day.
If you’d rather not wire Stripe webhooks yourself
If you’d rather not build the payment webhook handling yourself, Floot Payments adds checkout and subscriptions to apps built on Floot, paid out via Stripe, so the agent you build with can add a paid plan without you writing this handler.
Frequently Asked Questions
What is the difference between a webhook and an API?
With an API, your app calls the service. With a webhook, the service calls your app. Most integrations use both: webhooks to hear about events, and the API to fetch details or act.
Why does Stripe say my webhook signature is invalid?
The usual causes are reading a parsed body instead of the raw body, using the test secret for live events (or the reverse), or using the secret from a different endpoint. Stripe’s signature header may include a redacted secret and payload hint to help you diagnose a mismatch.
How long does Stripe keep retrying a failed webhook?
In live mode, up to three days with exponential backoff. In a sandbox, three times over a few hours.
Do I need a queue for webhooks?
For low volume, storing the event and processing it right after responding is enough. Stripe recommends an asynchronous queue so spikes, such as monthly renewals, don’t overwhelm your endpoint.
Can I test webhooks without deploying?
Yes. stripe listen --forward-to sends events from Stripe to your local server, and stripe trigger creates test events. For other services, use a tunnel that gives localhost a temporary public URL.
Should my webhook return an error if I don’t handle that event type?
No. Return 2xx for events you don’t use, or the provider will keep retrying them. Better still, subscribe only to the event types you handle.
Sources
- Stripe, Receive Stripe events in your webhook endpoint, 2026 https://docs.stripe.com/webhooks
- GitHub Docs, Best practices for using webhooks, 2026 https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks
- GitHub Docs, Validating webhook deliveries, 2026 https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
- Standard Webhooks, Standard Webhooks, 2026 https://www.standardwebhooks.com/