How to Integrate Stripe Payments Without Losing Track of Who Paid

    5 min read3 approaches compared

    The dangerous assumption in every payment integration is that the customer returning to your success page means the payment succeeded. It doesn't. They may close the tab, lose connection, or complete a bank authentication step that resolves minutes later. If provisioning happens on that redirect, you will have customers who paid and got nothing, and customers who got everything without paying.

    The webhook is the source of truth. The redirect is a user experience detail. Almost every hard-won lesson in payments follows from that distinction.

    Never provision on the redirect

    Grant access when Stripe tells your server the money moved, not when the browser arrives at a URL. The redirect is unreliable and forgeable; the webhook is signed.

    • Provisioning happens in the webhook handler. The success page shows a pending state until the webhook lands.
    • Verify the webhook signature with the raw request body. Parsed JSON re-serialized will not match, and verification fails confusingly.
    • Every webhook handler must be idempotent. Stripe retries, and duplicate delivery is normal.
    • Stripe holds the truth about subscription state. Mirror it locally for queries, but reconcile rather than compute it yourself.
    • Amounts are integers in the smallest currency unit. $10.00 is `1000`. Floats have no place anywhere near money.

    The approaches

    Stripe Checkout (hosted)

    // best for: Nearly everyone, and especially anyone who'd rather not scope PCI compliance.

    • Stripe
    • Next.js
    • Express.js
    • Django
    • Laravel
    • Ruby on Rails

    Create a Checkout Session server-side, redirect the customer to a Stripe-hosted page, and they return when done. Card details never touch your infrastructure, which keeps you in the simplest PCI category.

    What you get free is substantial and easy to underestimate: localized payment methods, Apple and Google Pay, 3D Secure handling, tax calculation, promotion codes, and a checkout page Stripe continuously optimizes. Rebuilding that is months of work that earns nothing.

    Put your own identifiers in `client_reference_id` or `metadata` when creating the session. The webhook arrives with the session, and without those you're matching on email — which breaks the moment someone pays with a different address than they signed up with.

    typescript
    // Verify against the RAW body. Parsed-then-re-serialized JSON
    // produces a different byte sequence and verification fails.
    app.post("/webhooks/stripe",
      express.raw({ type: "application/json" }),
      async (req, res) => {
        const event = stripe.webhooks.constructEvent(
          req.body, req.headers["stripe-signature"]!, process.env.STRIPE_WEBHOOK_SECRET!
        );
    
        // Stripe retries, so duplicates are normal, not exceptional.
        if (await alreadyProcessed(event.id)) return res.json({ received: true });
    
        // Ack fast — Stripe times out at ~20s and retries.
        // Slow work belongs on a queue.
        await handle(event);
        await markProcessed(event.id);
        res.json({ received: true });
      });

    The gotcha

    Body parsing middleware breaks signature verification. If `express.json()` runs before your webhook route, `req.body` is a parsed object and the signature check fails on every event. The webhook route needs the raw body, mounted before the global JSON parser.

    Payment Intents with Stripe Elements

    // best for: Checkout flows that must stay inside your UI.

    • Stripe
    • React
    • Vue.js
    • Next.js
    • Angular

    Elements embeds Stripe-hosted iframes for the card fields, so you control the surrounding page while the sensitive inputs remain in Stripe's origin. You create a Payment Intent server-side, pass the client secret to the browser, and confirm from there.

    The reason to choose this is a genuine product requirement — a multi-step flow, an unusual layout, payment as part of a larger form. It is not the default, and it costs you the payment methods and optimizations Checkout ships automatically.

    The state machine is the part to respect. A Payment Intent can require additional action for 3D Secure, which means the confirmation is asynchronous and may complete after the user has left. `requires_action` is a normal state, not an error, and handling it as a failure is how you decline legitimate European cards.

    The gotcha

    3D Secure means a payment can succeed minutes after the user closes the tab. Code that treats anything other than an immediate `succeeded` as a failure will reject a large share of European transactions. Rely on the `payment_intent.succeeded` webhook, not the client-side confirm result.

    Subscriptions and the Billing Portal

    // best for: Any recurring revenue product.

    • Stripe
    • PostgreSQL
    • Supabase
    • Django
    • Laravel

    Subscriptions add a long-lived state machine: trialing, active, past due, canceled, paused, plus proration on plan changes. Reimplementing that is a mistake — Stripe's Billing Portal handles plan changes, payment method updates, invoice history, and cancellation, in a hosted page you link to.

    The architectural rule is that Stripe owns subscription state and you mirror it. Store `stripe_customer_id`, `stripe_subscription_id`, the status, and the current period end. Update that mirror from webhooks, never by computing it from your own dates.

    The event that matters most operationally is `invoice.payment_failed`. Cards expire constantly, and involuntary churn from expired cards is often larger than voluntary cancellation. Stripe's Smart Retries plus a dunning email recovers a meaningful share of it.

    The gotcha

    Deriving access from your own `expires_at` column drifts from reality the moment anything happens in Stripe that you didn't process — a failed webhook, a refund issued from the dashboard, a manual cancellation. Treat the mirror as a cache and reconcile it against Stripe on a schedule.

    Common pitfalls

    Provisioning on the success redirect

    The redirect can be missed, and the URL can be visited directly by anyone who knows it. Grant access in the webhook handler; show a pending state on the success page until it lands.

    Non-idempotent webhook handlers

    Stripe retries on any non-2xx response or timeout, so a handler that grants credits will grant them twice. Record processed event IDs and return early on a repeat.

    Storing money as a float

    Floating point cannot represent 0.1 exactly, and the errors accumulate across a ledger. Stripe uses integer minor units and so should your database. Use integers or a decimal type, never a float.

    Testing only the happy path

    Stripe provides test cards for declines, insufficient funds, and required 3D Secure. Most payment bugs live in those branches, and they're the ones that surface as customers who were charged without receiving anything.

    Questions people actually ask

    Checkout or Elements?

    Checkout unless you have a specific product reason not to. It ships payment methods, 3D Secure, tax, and conversion optimizations you would otherwise build and maintain, and it minimizes your PCI scope.

    Why is my webhook signature verification failing?

    Almost always because a JSON body parser ran first and `req.body` is now an object. Verification needs the exact raw bytes Stripe signed. Mount the webhook route with a raw body parser before any global JSON middleware.

    Should I store subscription state in my database?

    Mirror it for fast queries, but treat Stripe as authoritative and update the mirror from webhooks. Reconcile periodically, because a dropped webhook otherwise leaves a customer with access they cancelled or without access they paid for.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides