Blog/Behind the Code/Stripe Payment Flows in Production: Lessons from Three Projects
POST
December 12, 2025
LAST UPDATEDDecember 12, 2025

Stripe Payment Flows in Production: Lessons from Three Projects

Production lessons from implementing Stripe payments across three projects, covering checkout flows, subscription billing, webhook reliability, refund handling, and PCI compliance strategies.

Tags

StripePaymentsProductionE-Commerce
Stripe Payment Flows in Production: Lessons from Three Projects
8 min read

Stripe Payment Flows in Production: Lessons from Three Projects

TL;DR

After integrating Stripe across three distinct production projects -- an e-commerce marketplace, a SaaS subscription platform, and a service booking system -- I learned that the difference between a working Stripe integration and a production-grade one comes down to idempotency, webhook reliability, and a well-defined payment state machine. The patterns I developed eliminated duplicate charges and lost payments entirely.

The Challenge

Payment integration sounds straightforward until you hit production. My first Stripe project went live with a basic checkout flow, and within 48 hours I was dealing with duplicate charges from retry logic, orders stuck in "pending" because the success redirect failed, and a customer who got charged but never received their order because a webhook silently failed.

Across three projects, the problems were variations of the same themes: network unreliability between client and server, race conditions between webhooks and redirect flows, and the fundamental challenge of keeping payment state consistent across Stripe's systems and our own database.

Project 1 was an e-commerce marketplace where vendors listed products and buyers paid through the platform. Split payments, refunds, and dispute handling were all in scope.

Project 2 was a SaaS platform with tiered subscription billing, trial periods, and usage-based add-ons. Customers could upgrade, downgrade, and cancel mid-cycle.

Project 3 was a service booking platform where customers paid deposits upfront and final amounts after service completion. Partial captures and delayed charges added complexity.

The Architecture

Payment State Machine

The single most impactful architectural decision was modeling payments as a state machine rather than treating them as a binary paid/unpaid flag. Every payment record in our database tracked its lifecycle explicitly.

typescript
enum PaymentStatus {
  CREATED = 'created',
  PROCESSING = 'processing',
  REQUIRES_ACTION = 'requires_action',
  SUCCEEDED = 'succeeded',
  FAILED = 'failed',
  REFUNDED = 'refunded',
  PARTIALLY_REFUNDED = 'partially_refunded',
  DISPUTED = 'disputed',
}
 
interface PaymentRecord {
  id: string;
  stripePaymentIntentId: string;
  status: PaymentStatus;
  amount: number;
  currency: string;
  idempotencyKey: string;
  metadata: Record<string, string>;
  attempts: number;
  lastWebhookEventId: string | null;
  createdAt: Date;
  updatedAt: Date;
}

Transitions between states were enforced. A payment in SUCCEEDED could only move to REFUNDED, PARTIALLY_REFUNDED, or DISPUTED. A payment in FAILED could not be refunded. This prevented impossible state transitions that I had seen cause real issues in Project 1 before I introduced this pattern.

Payment Intents vs. Stripe Checkout

I used both approaches across the three projects and learned where each excels.

For the e-commerce marketplace, I used Payment Intents with Stripe Elements. The checkout experience was deeply integrated into the product page -- buyers could select variants, apply coupons, and see real-time price breakdowns without leaving the page. Stripe Elements gave me full control over the payment form styling while keeping the sensitive card data out of my server.

typescript
// Server: Create a PaymentIntent with metadata for order tracking
async function createPaymentIntent(order: Order): Promise<string> {
  const idempotencyKey = `order_${order.id}_${order.version}`;
 
  const paymentIntent = await stripe.paymentIntents.create(
    {
      amount: order.totalInCents,
      currency: 'usd',
      metadata: {
        orderId: order.id,
        vendorId: order.vendorId,
        customerId: order.customerId,
      },
      // Enable automatic payment methods for broader card support
      automatic_payment_methods: { enabled: true },
    },
    { idempotencyKey }
  );
 
  // Persist the payment record before returning to client
  await db.payments.create({
    stripePaymentIntentId: paymentIntent.id,
    status: PaymentStatus.CREATED,
    amount: order.totalInCents,
    idempotencyKey,
    orderId: order.id,
  });
 
  return paymentIntent.client_secret!;
}
tsx
// Client: Stripe Elements integration
function CheckoutForm({ clientSecret }: { clientSecret: string }) {
  const stripe = useStripe();
  const elements = useElements();
  const [processing, setProcessing] = useState(false);
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;
 
    setProcessing(true);
 
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/order/confirmation`,
      },
    });
 
    if (error) {
      // Show error to customer -- do NOT fulfill the order here.
      // Wait for the webhook to confirm payment status.
      toast.error(error.message);
      setProcessing(false);
    }
    // If no error, the customer is redirected to return_url.
    // Order fulfillment happens in the webhook handler, not here.
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button type="submit" disabled={!stripe || processing}>
        {processing ? 'Processing...' : 'Pay Now'}
      </button>
    </form>
  );
}

For the SaaS subscription platform, I used Stripe Checkout Sessions. The subscription flow was standard enough that the hosted page saved weeks of development. Stripe Checkout handled proration previews, tax calculation, and the payment form -- all things I would have had to build manually with Payment Intents.

typescript
async function createSubscriptionCheckout(
  customerId: string,
  priceId: string,
  trialDays?: number
): Promise<string> {
  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    subscription_data: {
      trial_period_days: trialDays,
      metadata: { internalCustomerId: customerId },
    },
    success_url: `${BASE_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${BASE_URL}/pricing`,
    // Let Stripe handle tax calculation
    automatic_tax: { enabled: true },
  });
 
  return session.url!;
}

Webhook Handling

Webhooks were where most of my early bugs lived. The webhook handler I converged on across all three projects followed a strict pattern: verify, deduplicate, process, acknowledge.

typescript
async function handleWebhook(req: Request, res: Response) {
  const sig = req.headers['stripe-signature'] as string;
  let event: Stripe.Event;
 
  // 1. Verify the webhook signature
  try {
    event = stripe.webhooks.constructEvent(
      req.body, // raw body, not parsed JSON
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return res.status(400).send('Invalid signature');
  }
 
  // 2. Deduplicate -- check if we already processed this event
  const existing = await db.webhookEvents.findUnique({
    where: { stripeEventId: event.id },
  });
 
  if (existing) {
    // Already processed, return 200 so Stripe stops retrying
    return res.status(200).json({ received: true, duplicate: true });
  }
 
  // 3. Process the event inside a transaction
  try {
    await db.$transaction(async (tx) => {
      // Record the event first
      await tx.webhookEvents.create({
        data: {
          stripeEventId: event.id,
          type: event.type,
          processedAt: new Date(),
        },
      });
 
      // Route to the appropriate handler
      switch (event.type) {
        case 'payment_intent.succeeded':
          await handlePaymentSuccess(tx, event.data.object);
          break;
        case 'payment_intent.payment_failed':
          await handlePaymentFailure(tx, event.data.object);
          break;
        case 'customer.subscription.updated':
          await handleSubscriptionUpdate(tx, event.data.object);
          break;
        case 'customer.subscription.deleted':
          await handleSubscriptionCancellation(tx, event.data.object);
          break;
        case 'charge.dispute.created':
          await handleDisputeCreated(tx, event.data.object);
          break;
        default:
          console.log(`Unhandled event type: ${event.type}`);
      }
    });
 
    res.status(200).json({ received: true });
  } catch (err) {
    console.error(`Error processing webhook ${event.id}:`, err);
    // Return 500 so Stripe retries
    res.status(500).json({ error: 'Processing failed' });
  }
}

The critical insight: the webhook event ID check and the business logic processing must happen inside the same database transaction. Without that, a race condition exists where two concurrent webhook deliveries can both pass the deduplication check before either writes the event record.

Subscription Billing Lifecycle

The SaaS project taught me that subscription billing is where Stripe's complexity really shows up. Trial-to-paid conversion, mid-cycle upgrades with proration, failed renewal payments, and grace periods all needed explicit handling.

typescript
async function handleSubscriptionUpdate(
  tx: PrismaTransaction,
  subscription: Stripe.Subscription
) {
  const customerId = subscription.metadata.internalCustomerId;
 
  await tx.subscriptions.upsert({
    where: { stripeSubscriptionId: subscription.id },
    update: {
      status: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      priceId: subscription.items.data[0]?.price.id,
    },
    create: {
      stripeSubscriptionId: subscription.id,
      customerId,
      status: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      priceId: subscription.items.data[0]?.price.id,
    },
  });
 
  // If the subscription went past_due, trigger dunning flow
  if (subscription.status === 'past_due') {
    await triggerDunningEmail(tx, customerId);
  }
}

Testing with Stripe CLI

Local testing was essential for every project. The Stripe CLI forwards webhook events to your local server, which made it possible to test the full payment flow end-to-end without deploying.

bash
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
 
# Trigger specific events for testing
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
stripe trigger charge.dispute.created
 
# Test with specific payment methods that trigger 3D Secure
stripe trigger payment_intent.requires_action

I also wrote integration tests that created real PaymentIntents in Stripe's test mode, confirmed them with test card numbers, and verified that our webhook handlers processed the events correctly and updated the database state.

Key Decisions & Trade-offs

Idempotency keys tied to order version: I used order_{id}_{version} as the idempotency key format. The version number incremented whenever order details changed (price, items, etc.), which meant modifying an order and retrying payment would create a new PaymentIntent rather than reusing a stale one. The trade-off is that orphaned PaymentIntents accumulate in Stripe, but they expire automatically and the safety guarantee is worth it.

Webhook-driven fulfillment over redirect-driven: I chose to fulfill orders exclusively through webhook events rather than on the success redirect page. This means a brief delay between payment and the order appearing in the customer's dashboard. The trade-off was acceptable because it guaranteed every paid order was fulfilled, even when customers closed their browser before the redirect completed.

Database transactions for all webhook processing: Wrapping the deduplication check and business logic in a single transaction added latency to webhook processing. For the booking platform, where webhooks occasionally took 2-3 seconds to process during peak load, I had to increase Stripe's webhook timeout configuration. The alternative -- processing outside a transaction -- would have reintroduced the race conditions I was trying to eliminate.

Stripe Checkout for subscriptions, Payment Intents for one-time payments: This was a pragmatic split. Stripe Checkout handles subscription complexity (prorations, trials, tax) out of the box, but its customization is limited. Payment Intents required more code but gave me the custom checkout experience the marketplace needed.

Results & Outcomes

Across all three projects, the payment state machine and idempotent webhook handler eliminated payment discrepancies entirely after deployment. Before these patterns, Project 1 averaged a handful of support tickets per week related to payment issues -- charges without orders, orders without charges, duplicate charges on retry. After implementing the state machine, those tickets dropped to zero.

The subscription platform handled trial conversions, upgrades, downgrades, and cancellations without manual intervention. The dunning email flow recovered a meaningful percentage of failed renewal payments that would have otherwise churned.

The booking platform's deposit-then-final-charge flow worked reliably even when the final charge happened days after the initial deposit, because the PaymentIntent metadata linked the two transactions and the state machine tracked the full lifecycle.

What I'd Do Differently

Start with the state machine from day one. On Project 1, I bolted it on after encountering production issues. Retrofitting a state machine onto an existing payment flow is painful because you have to migrate existing records into the new state model.

Use Stripe's built-in retry logic more aggressively. I wrote custom retry logic for failed payments before realizing Stripe's Smart Retries handles this better for subscriptions. My custom code added complexity without improving recovery rates.

Invest in a webhook replay mechanism earlier. When a webhook handler bug caused events to be processed incorrectly, I had to manually replay events from Stripe's dashboard. A dead-letter queue for failed webhooks with a replay mechanism would have saved hours of manual recovery.

Set up Stripe's webhook event monitoring from the start. Stripe's dashboard shows webhook delivery failures, but I did not set up alerts for them until a silent failure on Project 1 caused missed order fulfillments. Proactive monitoring should be part of the initial setup, not an afterthought.

FAQ

Why are Stripe webhooks critical for production payments?

Webhooks are the only reliable way to confirm payment status because client-side redirects can fail, users can close browsers, and network issues can prevent success page loads. Always treat webhook events as the source of truth for order fulfillment and subscription status changes. In my projects, I saw redirect failures happen regularly -- enough that relying on the redirect for fulfillment would have caused multiple lost orders per week. The webhook handler ensures that every successful payment triggers fulfillment, regardless of what happens on the client side.

How do you handle duplicate webhook events?

Store processed event IDs in your database and check for duplicates before processing. Use database transactions to ensure the check-and-process operation is atomic. Stripe may send the same event multiple times, so idempotent handlers prevent duplicate charges or fulfillments. The key implementation detail is that the deduplication check and business logic must execute inside the same database transaction -- otherwise two concurrent webhook deliveries can both pass the check before either writes the event record.

What is the difference between Stripe Checkout and Payment Intents?

Stripe Checkout is a hosted payment page that handles the entire UI, reducing PCI scope. Payment Intents give you full control over the payment form using Stripe Elements. Use Checkout for faster implementation and Payment Intents when you need a fully custom payment experience. In practice, I found Checkout ideal for subscription billing because it handles prorations, tax, and trial periods automatically. Payment Intents were the right choice for the marketplace where the checkout flow was tightly integrated into the product browsing experience and needed custom styling that matched the platform's brand.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

Optimizing Core Web Vitals for e-Commerce
Mar 01, 202610 min read
SEO
Performance
Next.js

Optimizing Core Web Vitals for e-Commerce

Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.

Building an AI-Powered Interview Feedback System
Feb 22, 20269 min read
AI
LLM
Feedback

Building an AI-Powered Interview Feedback System

How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.

Migrating from Pages to App Router
Feb 15, 20268 min read
Next.js
Migration
Case Study

Migrating from Pages to App Router

A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.