Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 078 / 100

Common Stripe integration mistakes: a step-by-step guide

Bugs that cost real money.

Expona Admin9 min read

Introduction

By day 78 of a payment integration project, you've wired up Stripe, handled the happy path, and deployed to production. Then the support tickets arrive: customers charged twice, webhooks silently failing, or orders marked complete but never fulfilled. We've seen these bugs drain thousands in chargebacks, refunds, and lost trust across e-commerce, SaaS, and marketplace platforms.

This guide covers the common Stripe integration mistakes: a step-by-step guide that teams repeatedly make—not exotic edge cases, but predictable errors that cost real money. Each mistake has a fix, and each fix takes minutes to implement.

Fulfilling on the success URL

The redirect URL after a successful payment feels like the natural place to mark an order as fulfilled. The customer lands there, payment succeeded—ship the item, send the confirmation email, grant access. It feels right. It is dangerously wrong.

Here's why: the success URL is not a reliable delivery mechanism. The customer's browser can close, crash, or lose network before the redirect completes. ISP connection drops. They accidentally click back. Their corporate proxy interferes. You never see that request hit your server.

Meanwhile, the charge went through. Payment succeeded at Stripe. The order sits in your database marked "processing" forever, or worse, you never create the order at all. The customer never receives their invoice, product access, or shipment. You have a charge with no matching fulfillment—a support nightmare and potential chargeback.

The correct flow uses webhooks: Stripe publishes an event to you, you fulfill based on that event. Your server receives payment_intent.succeeded and creates the order then, idempotently. The redirect just shows a "thank you" message; fulfillment happens server-to-server, reliably.

typescript
// ❌ WRONG: Fulfilling on redirect
app.get('/checkout/success', async (req, res) => {
  const { session_id } = req.query;
  const session = await stripe.checkout.sessions.retrieve(session_id);
  
  // If this request fails, order is never created
  const order = await db.orders.create({
    customer_id: session.customer_details.email,
    amount: session.amount_total,
    status: 'fulfilled'
  });
  
  res.send('Thank you! Order ' + order.id);
});

// ✅ CORRECT: Fulfilling on webhook
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;
  
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send('Webhook signature failed');
  }
  
  if (event.type === 'payment_intent.succeeded') {
    const { id, amount, customer } = event.data.object;
    
    // Fulfill idempotently
    await db.orders.upsert(
      { stripe_payment_intent_id: id },
      { stripe_payment_intent_id: id, amount, customer, status: 'fulfilled' }
    );
  }
  
  res.json({ received: true });
});

// Redirect just shows a page
app.get('/checkout/success', (req, res) => {
  res.send('Thank you! Check your email for order details.');
});

The redirect is for UX. Webhooks are for truth. Keep them separate.

Trusting client-side amounts

A junior engineer adds a form field for "quantity" and "price per item," multiplies them on the frontend, and passes total_amount to the Stripe API. Looks good in staging. Ships to production. Two days later, someone modifies the JavaScript in their browser console, changes amount from 9999 cents to 99 cents, and completes checkout. Your system processes the order for the wrong price.

This is not theoretical. We've seen customers charged $0.99 for $99 products. It's a simple tampering attack: any amount calculated on the client is a suggestion, not a rule.

The rule: always calculate the amount on your server, never on the client. Your backend retrieves the product price from your database, multiplies by quantity, applies tax and discounts based on your own rules, and passes only the final amount to Stripe. The client can display the amount, but it doesn't compute it.

typescript
// ❌ WRONG: Trusting client-side amount
app.post('/create-checkout-session', async (req, res) => {
  const { product_id, quantity, amount } = req.body;
  
  // Client says amount is 9999. We just... accept it?
  const session = await stripe.checkout.sessions.create({
    line_items: [{
      price_data: {
        currency: 'usd',
        unit_amount: amount, // TRUST ISSUE
        product_data: { name: 'Product' }
      },
      quantity
    }],
    mode: 'payment'
  });
  
  res.json({ sessionId: session.id });
});

// ✅ CORRECT: Calculate on server
app.post('/create-checkout-session', async (req, res) => {
  const { product_id, quantity } = req.body;
  
  // Fetch product from DB, verify it exists
  const product = await db.products.findById(product_id);
  if (!product) {
    return res.status(404).json({ error: 'Product not found' });
  }
  
  // Calculate amount on server
  const unit_amount = product.price_cents;
  const total_amount = unit_amount * quantity;
  
  const session = await stripe.checkout.sessions.create({
    line_items: [{
      price_data: {
        currency: 'usd',
        unit_amount, // From database, not client
        product_data: { name: product.name }
      },
      quantity
    }],
    mode: 'payment',
    success_url: '...',
    cancel_url: '...'
  });
  
  res.json({ sessionId: session.id });
});

Always derive amounts from your source of truth—your database. Assume the client is either confused or malicious. Both cases deserve the same response: ignore it and recompute.

Parsing the body before webhook verification

A webhook arrives. Your code deserializes the JSON body to read the event type, checks if it's payment-related, then verifies the signature. It seems efficient: fail fast if it's not a payment event.

Except an attacker can forge a webhook. They send you a fake event that looks like a payment succeeded. Your code parses it, sees the type is payment_intent.succeeded, and fulfills an order—all before signature verification stops it. You've just gifted a product to someone who never paid.

Verify the signature first, always. Parse the verified body second. Stripe's webhook secret is your only proof the event came from Stripe. Everything else is hearsay.

typescript
// ❌ WRONG: Parse first, verify second
app.post('/webhooks/stripe', express.json(), async (req, res) => {
  const event = req.body;
  
  // Attacker sends us a fake event. We read it.
  if (event.type === 'payment_intent.succeeded') {
    // We already made a decision before verifying!
    await fulfillOrder(event.data.object);
  }
  
  // Verification happens too late
  const sig = req.headers['stripe-signature'];
  try {
    stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
  } catch (err) {
    // Oops, signature failed. But we already fulfilled!
    return res.status(400).send('Verification failed');
  }
  
  res.json({ received: true });
});

// ✅ CORRECT: Verify first, parse second
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;
  
  // Verify before reading
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
  } catch (err) {
    // Signature failed. Reject immediately. Never parse.
    return res.status(400).send('Signature verification failed');
  }
  
  // Now it's safe to read
  if (event.type === 'payment_intent.succeeded') {
    await fulfillOrder(event.data.object);
  }
  
  res.json({ received: true });
});

Use express.raw to capture the raw body bytes. Pass those bytes to Stripe's constructEvent, which verifies the signature and deserializes only if valid. If verification fails, throw an error and return a 400 immediately. No event data is trusted until after verification.

No idempotency on retries

Your webhook handler fulfills an order, but the response hangs. Stripe times out. Stripe retries the webhook. Your handler runs again, creates a duplicate order, charges the customer twice in your system (even though Stripe itself only charged once). The customer is furious.

Webhooks can arrive multiple times. Network timeouts, server crashes, Stripe retries—all cause duplicate deliveries. Your code must handle the same event twice and produce the same result both times. That's idempotency.

The pattern: store the event ID in your database before processing. On every webhook, check if you've seen that event ID. If yes, return success immediately without reprocessing. If no, process it and record the ID.

typescript
// ❌ WRONG: No idempotency
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
  
  if (event.type === 'payment_intent.succeeded') {
    // First call: creates order #123
    // Retry call: creates order #124 (duplicate!)
    const order = await db.orders.create({
      stripe_payment_intent_id: event.data.object.id,
      amount: event.data.object.amount,
      status: 'fulfilled'
    });
  }
  
  res.json({ received: true });
});

// ✅ CORRECT: Idempotent processing
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
  
  // Check if we've processed this event before
  const existingEvent = await db.webhookEvents.findOne({
    stripe_event_id: event.id
  });
  
  if (existingEvent) {
    // Already processed. Return success.
    return res.json({ received: true });
  }
  
  try {
    if (event.type === 'payment_intent.succeeded') {
      const order = await db.orders.create({
        stripe_payment_intent_id: event.data.object.id,
        amount: event.data.object.amount,
        status: 'fulfilled'
      });
    }
    
    // Mark this event as processed
    await db.webhookEvents.create({
      stripe_event_id: event.id,
      type: event.type,
      processed_at: new Date()
    });
  } catch (err) {
    // Log and return 500. Stripe will retry.
    console.error('Webhook processing failed:', err);
    return res.status(500).json({ error: 'Processing failed' });
  }
  
  res.json({ received: true });
});

Store the event ID in a unique-indexed column. If the same ID arrives twice, the second insert fails gracefully. Return HTTP 200 both times. Stripe sees success and stops retrying. Your order is never duplicated.

Common mistakes summary

  • Fulfilling on the success URL: Browser requests are unreliable. Use webhooks for fulfillment.
  • Trusting client-side amounts: Calculate pricing on your server. The client is untrusted.
  • Parsing before verification: Verify the webhook signature first. Only parse if verification passes.
  • No idempotency: Store event IDs. Webhook retries must produce the same result.

Key takeaways

  • Webhooks are the reliable channel for fulfillment. Redirects are for UX only.
  • Server-side calculations are your source of truth for amounts. Never trust the client.
  • Signature verification is the first step in webhook handling, not the last. Verify before parsing.
  • Idempotency prevents duplicate orders when webhooks retry. Store event IDs and check before processing.
  • Test webhook delivery failures and retries in staging. These bugs are hard to catch otherwise.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles