API integration · Guide
Stripe Webhook Integration: How to Handle Payment Events Correctly
By Paul Walsh Updated 9 min read
Webhooks are the core of any serious Stripe integration. They’re how Stripe tells your system what happened — a payment succeeded, a subscription renewed, a card expired, an invoice failed. Get webhook handling right and your integration runs itself. Get it wrong and you have race conditions, duplicate fulfilment, missed cancellations and support tickets that are hard to diagnose.
This post covers what Stripe webhooks are, which events matter most, and what a production-ready webhook endpoint actually looks like — including the parts most tutorials skip.
What Stripe webhooks are
When something happens in Stripe — any event — Stripe sends an HTTP POST request to the URL you’ve registered as your webhook endpoint. The request contains a JSON payload describing the event: what happened, when, and the full state of the relevant Stripe object at that point in time.
Your webhook endpoint receives this request and processes it — updating your database, granting or revoking access, sending a confirmation email, triggering fulfilment, whatever your system needs to do in response to that event. Stripe expects a 200 response. If it doesn’t get one within 30 seconds, it retries. It retries up to 25 times over the following 72 hours.
This means your webhook handler must be fast, reliable and — crucially — idempotent.
Idempotency: the most important concept in webhook handling
Idempotency means that processing the same event twice produces the same result as processing it once. It matters because Stripe will retry events if your endpoint returns an error or times out — and it’s entirely possible for the same event to arrive twice even in normal operation, due to network retries or Stripe’s at-least-once delivery guarantee.
A non-idempotent webhook handler sends two confirmation emails, creates two database records, grants access twice, or in the worst case charges a customer twice. A properly idempotent handler checks whether it’s already processed a given event ID before doing anything, and skips processing if it has.
The implementation is straightforward: store processed event IDs in your database with a unique constraint. Before processing any event, attempt to insert the event ID. If the insert succeeds, the event is new — process it. If it fails due to the unique constraint, the event has already been processed — return 200 and do nothing. This check should be the first thing your webhook handler does, before any business logic runs.
The event ID from Stripe follows the format evt_ followed by a unique string. It’s stable — the same event always has the same ID across retries. This is the key you index on.
Signature verification
Every webhook request from Stripe includes a Stripe-Signature header containing a timestamp and a hash signature. Stripe generates this signature using your webhook signing secret — a key unique to each webhook endpoint configuration, starting with whsec_.
Your endpoint should verify this signature before processing any event, for two reasons. First, it ensures that the request actually came from Stripe and hasn’t been tampered with in transit. Second, it prevents replay attacks — the timestamp component lets you reject requests older than a configurable tolerance window, typically five minutes. Without signature verification, any malicious party who discovers your webhook URL can send fake events to your endpoint.
Stripe’s official libraries handle signature verification correctly. In PHP, it’s StripeWebhook::constructEvent($payload, $sig_header, $endpoint_secret). In Node, stripe.webhooks.constructEvent(payload, sig, secret). These methods throw an exception if verification fails — catch the exception, return a 400 and stop processing.
One common mistake: reading the request body through a framework that parses it as JSON before the raw body is available. Stripe’s signature is computed against the raw request body. If your framework transforms the body before you can read it raw, signature verification will fail even for legitimate Stripe events. Always read the raw request body for webhook signature verification.
Which events actually matter
Stripe has over 200 event types. For most integrations, a much smaller set is relevant. Here’s what matters and why, for the most common integration patterns.
payment_intent.succeeded — a one-time payment completed successfully. Grant access, trigger fulfilment, send confirmation. This is the event to listen for on non-subscription payment flows, not charge.succeeded (which is lower-level and fires in more contexts).
payment_intent.payment_failed — a one-time payment failed. Notify the customer and prompt them to retry. Include the failure reason from the PaymentIntent’s last_payment_error field — “Your card was declined” is more useful than “payment failed”.
invoice.payment_succeeded — a subscription invoice was paid. This fires on the initial subscription payment and on every subsequent renewal. This is the event that keeps your subscribers active — on receipt, extend the access period for the subscription’s current period end date.
invoice.payment_failed — a subscription invoice failed. Start your dunning flow — notify the customer, begin a grace period, schedule follow-up communication. Don’t revoke access immediately on the first failure unless your business policy specifically requires it. Stripe’s Smart Retries will attempt again; your job is to prompt the customer to update their payment method.
customer.subscription.created — a new subscription was created. Provision the initial access or trigger onboarding. Note: if the subscription has a trial, the customer hasn’t paid yet. Grant trial access, not paid access.
customer.subscription.updated — a subscription changed. This is the most complex event to handle because it fires for many different changes: plan upgrade, plan downgrade, trial ending, quantity change, cancellation scheduled, cancellation unscheduled, and more. Read the previous_attributes field carefully to understand what specifically changed and respond accordingly.
customer.subscription.deleted — a subscription has fully ended. Revoke access. This fires when the cancellation takes effect at period end, not when the customer clicks cancel (that’s an updated event with cancel_at_period_end: true). Handle the distinction — a subscription scheduled for cancellation at period end is still active and the customer should retain access until then.
customer.subscription.trial_will_end — fires three days before a trial ends. Excellent trigger for conversion emails: “Your trial ends in 3 days — add a payment method to continue.”
charge.refunded — a charge was refunded, partially or fully. May require access revocation, order cancellation, or inventory adjustment depending on your business logic.
payment_method.automatically_updated — Stripe automatically updated a card number or expiry date through its network updater service. Worth logging — useful for diagnosing why a customer’s payment succeeded when you might have expected it to fail.
The ordering problem
Stripe does not guarantee that events arrive in the order they were created. In high-throughput scenarios or after a period of delivery failures, a customer.subscription.updated event might arrive before a customer.subscription.created event for the same subscription. Your webhook handler needs to handle this gracefully — not assuming that events will be processed in a logical sequence.
One approach is to fetch the current state of the Stripe object directly when processing an event rather than relying solely on the event payload. The event tells you something happened; a direct API call to retrieve the current subscription state tells you the current reality. This is slower — it adds an extra API call per event — but more reliable for complex subscription state management where ordering matters.
For simpler flows where ordering is unlikely to cause problems, using the event payload directly is fine. The key is being aware of the possibility and having a plan for the edge cases where it occurs.
Responding fast, processing asynchronously
Stripe times out webhook deliveries after 30 seconds. If your endpoint takes longer than that to process an event — because it’s sending emails, making third-party API calls, running complex database queries, or generating PDFs — Stripe marks the delivery as failed and retries.
The right pattern for slow processing is to acknowledge receipt immediately and process the event asynchronously via a queue. The webhook endpoint does three things: verify the signature, store the event ID for idempotency, push the event onto a queue, return 200. A separate worker process picks up events from the queue and handles the actual business logic at its own pace.
This decouples event receipt from event processing, keeps your endpoint reliably fast, and means your processing logic can fail and retry independently of the Stripe delivery. If your email service is temporarily unavailable, the queue retains the events until it recovers. Without a queue, those events get retried by Stripe 25 times and then abandoned.
Testing webhooks locally and in staging
Stripe’s CLI makes local webhook testing straightforward. stripe listen --forward-to localhost:3000/webhook creates a tunnel that forwards Stripe test events to your local endpoint and prints the webhook signing secret for that session. stripe trigger payment_intent.succeeded fires a test event through that tunnel — useful for testing specific handlers without creating real test data.
In staging environments, configure a separate webhook endpoint in your Stripe test mode dashboard pointing at your staging URL. Use different signing secrets for each environment — test mode, staging, production. This means a misconfigured staging webhook can’t accidentally process production events or vice versa.
Monitoring and alerting
Stripe’s dashboard shows webhook delivery history — which events were delivered, which failed, and the response your endpoint returned. For production integrations, monitoring this is important. A spike in webhook failures often indicates a deployment broke something in your endpoint before it becomes visible through customer complaints.
Log every webhook event your endpoint receives, along with the processing outcome. When something goes wrong — a subscription that should have been revoked wasn’t, an access grant that shouldn’t have fired did — the webhook log is where you diagnose it. Log the event ID, event type, timestamp received, processing status and any error messages. Keep these logs for at least 90 days.
You can see a live demonstration of webhook events arriving and being processed in real time — including the event type, timestamp and parsed payload — in the Stripe webhook event viewer demo. For what a full Stripe integration costs, see the Stripe API integration service. For how webhooks fit into the broader subscription billing picture, see Stripe subscription billing integration.
Related posts
- Stripe subscription billing integration: how it works
- Stripe Connect integration: building marketplace payments
- How much does Stripe API integration cost?
- Stripe plugin vs custom integration: when you need to go custom
If you need help setting up Stripe webhooks or building a custom Stripe integration for your website or SaaS platform, I can help. I have built production Stripe integrations including subscription billing, Connect marketplaces and webhook-driven order management. See API integration pricing or get in touch to discuss your project.
About Paul Walsh
Developer based in Blackburn with 25+ years in electronics and tech. I've run online businesses of my own, and now build and fix websites for small businesses across the UK.