← Back to articles

    Stripe Webhooks in Production: Building Payment Events You Can Trust

    October 18, 2026 · Devhuset Team

    Most payment bugs are not in the checkout form. They are in what happens after the customer closes the tab: the event that says the money arrived, the retry that arrives twice, the refund that lands before the original charge is recorded. Stripe webhooks are where a shop's real accounting lives, and they deserve the same care as the code that takes the card.

    This guide walks through the parts of a payment gateway integration that hold up under real traffic: what Stripe webhooks promise, what they do not, and how to build a handler that keeps the books straight when deliveries are late, duplicated, or missing. It assumes you already have a working checkout and want the fulfillment side to be trustworthy.

    The Event Is the Source of Truth

    A redirect back to your success page proves very little. Customers close browsers, networks drop, and anyone can open a success URL by hand. The webhook is the message from Stripe's systems saying that a payment actually succeeded.

    That distinction changes how you build. The success page should thank the customer and show an order status, not grant access, ship goods, or send a license key. Those actions belong to the event handler, which runs after Stripe has confirmed the state of the payment.

    Treat the event as a notification rather than as the record itself. When an event says a payment succeeded, the safest response is to fetch the object from the Stripe API and act on the current state. The payload can arrive late, and by then a refund may already exist. Reading the live object keeps your database aligned with the truth rather than with a snapshot from a minute ago.

    How Stripe Delivers Events

    Stripe posts a JSON body to the endpoint you register, with an event object that includes an id, a type such as `checkout.session.completed` or `invoice.paid`, a creation timestamp, and the affected object under `data.object`.

    There are three properties of that delivery you have to design around.

    Deliveries are at-least-once. The same event can reach you more than once, sometimes because Stripe retried after a timeout while your server was actually working.

    Ordering is not guaranteed. Two events about the same payment can arrive in either order, so an older state can overwrite a newer one if you simply apply whatever the payload says.

    Failures are retried. If your endpoint returns a non-2xx status or times out, Stripe retries with exponential backoff, for up to three days in live mode. That is generous enough to survive a deploy and long enough to bury you in duplicate work if the handler is not idempotent.

    Verify the Signature Before Anything Else

    Every request carries a `Stripe-Signature` header with a timestamp and one or more signatures. Verifying it proves the request came from Stripe and that nobody tampered with the body.

    Two details break most first attempts. First, the signature is computed over the raw request body, so a framework that parses JSON before your handler runs will leave you signing a re-serialized string that never matches. Read the raw bytes. Second, the check includes a timestamp tolerance, five minutes by default, which limits replay attacks. Skipping signature verification to "get it working" leaves an endpoint where anyone can claim an order was paid.

    Use the official library's helper rather than hand-rolling HMAC logic. Each environment needs its own endpoint secret, and the secret from a test endpoint will not validate live traffic. When verification fails, return a 400 and log the request id. A sudden run of failures usually means a rotated secret, not an attack.

    Make the Handler Idempotent

    The cheapest way to handle duplicate events is a unique constraint. Store the Stripe event id in a table with a unique index, and let the insert fail on the second delivery. If the insert succeeds, do the work. If it fails, return 200 and move on.

    That protects against exact replays but not against two different events describing the same business outcome, such as a payment succeeding and a checkout completing. For those, make the domain action idempotent as well: a conditional update that moves an order from `pending` to `paid` only once, guarded by the current status in the `WHERE` clause.

    For ordering, compare timestamps or, better, let the state machine decide. An order that is already `refunded` should not be moved back to `paid` by a late success event. Writing the rules as allowed transitions keeps the database consistent no matter what order the messages arrive in.

    Respond Quickly, Work Afterwards

    Stripe expects a 2xx response within seconds. Anything slower looks like a failure, which triggers a retry even though your handler may have done the work correctly.

    The pattern that satisfies both requirements is small: verify the signature, persist the event, enqueue a job, return 200. The queue worker then does the slow parts, such as generating invoices, sending email, provisioning accounts, or calling a warehouse API.

    This also decouples your payment reliability from the reliability of everything downstream. If the email provider is down for ten minutes, Stripe does not need to know. Your queue retries, and the payment record stays correct.

    Return 200 for event types you do not handle. A handler that errors on unfamiliar events will be retried for three days over something it was never going to process.

    Cover the Awkward Payment Paths

    Card payments that require authentication, and many local payment methods, complete asynchronously. The customer finishes the redirect, but the payment settles later. `checkout.session.completed` can fire with a payment status that is still processing.

    Handle the follow-up events explicitly: `checkout.session.async_payment_succeeded` and `checkout.session.async_payment_failed`. Without them, those orders sit in limbo while the customer waits for a confirmation that never comes.

    Refunds and disputes need the same attention. A refund event should reverse fulfillment, revoke access, or open a support task, depending on what was sold. Subscriptions add their own set: failed invoice payments, subscription cancellations, and trials converting. Subscribe only to the events you act on, but make sure the list covers every state your product can end up in.

    Reconcile, Because Some Messages Never Arrive

    Even with retries, webhooks are a delivery mechanism, not an audit trail. Endpoints get deleted, DNS breaks, and a misconfigured deploy can return 500s for a day.

    Run a daily reconciliation job against Stripe's balance transactions or payouts. Compare what Stripe says you earned with what your database recorded, and report the differences. A mismatch is a bug report, and it is far easier to fix on a Tuesday morning than during a month-end close.

    Keep the raw event payloads for a while too. When a customer disputes a charge, the stored event and your processing log are the evidence that you fulfilled the order.

    Test the Failure Cases, Not Just the Happy Path

    The Stripe CLI forwards live test events to your local server with `stripe listen --forward-to localhost:4242/api/webhooks/stripe`, and `stripe trigger` fires specific event types on demand. That covers the happy path in seconds.

    Spend the extra effort on the cases that hurt. Send the same event twice and confirm only one order is marked paid. Send a refund before a success event and check the state machine holds. Stop the queue worker, fire a payment, and watch the retry arrive while the first job is still pending.

    You can also replay events from the Stripe dashboard, which is the fastest way to recover after an outage without asking customers to pay again.

    What to Watch in Production

    Alert on what happens to deliveries, not only on what happens inside your application. A rising count of non-2xx responses means you are about to lose events. A growing queue delay means fulfillment is falling behind even though Stripe thinks you are healthy.

    Log the event id, type, and outcome for every delivery, and include the Stripe request id so support can trace a single payment end to end. When something goes wrong at 2 a.m., that trail is worth more than any dashboard.

    A Short Checklist Before Going Live

    Verify signatures against the raw body with the correct endpoint secret. Store event ids under a unique constraint. Make order state changes conditional. Return 200 fast and do the work in a queue. Handle async payment outcomes, refunds, and disputes explicitly. Reconcile daily against Stripe's records. Test duplicates, out-of-order arrivals, and an outage.

    None of this is exotic. It is the difference between a payment integration that works in a demo and one that keeps the books straight a year later, when the person who wrote the checkout code has moved on.