Nurture TechnologiesNurture Tech
Integrations13 min read·July 24, 2026

Stripe Checkout Successful but Order Was Never Created

Stripe CheckoutNode.jsTypeScriptPostgreSQLRedis

Payment succeeded, customer sees the success URL, but no order was created in your backend. This is one of the most costly bugs in an e-commerce integration here is exactly how it happens and how to fix it.

Problem Summary

Stripe Checkout handles the entire payment flow and redirects the customer to your success_url upon completion. A common mistake is creating the order when the customer lands on that success page by reading the session ID from the URL. This is unreliable: the user can close the browser before being redirected, your server can be down during the redirect, or the success URL can be visited multiple times. The correct approach is to create the order exclusively from the checkout.session.completed webhook event.

Symptoms

  • Stripe Dashboard shows charge.succeeded and checkout.session.completed but no order in your database
  • Customer emails support saying they were charged but received nothing
  • Success page occasionally fails to create orders when server load is high
  • Orders are created when user lands on success page but not when browser closes before redirect
  • Duplicate orders created when webhook fires and user also hits the success URL
  • checkout.session.completed received but session.payment_status is not 'paid' order creation proceeds anyway
  • Race condition: webhook arrives before success-page server finishes creating the order, causing duplicate
  • Webhook handler throws an unhandled exception silently and Stripe retries, creating duplicate orders later

Business Impact

Each missed order is a full revenue loss event the money is collected but the fulfillment never happens. For digital products this means customers who paid cannot access what they bought. For physical products it means lost inventory tracking and missed shipments. When duplicate orders occur, it can mean double-fulfillment. Both failure modes generate chargebacks and destroy customer trust.

Root Cause

The root cause is using the success URL redirect as the order creation trigger. The browser redirect is best-effort: Stripe issues it, but network failures, browser closures, and popup blockers can all prevent the user from actually hitting your success page. The webhook, by contrast, comes from Stripe's servers directly to yours and includes a retry mechanism if your server is down.

Never create orders in the success URL route handler. Always create orders from the checkout.session.completed webhook. The success page should only display a confirmation it should query your database for the order, not create it.

Diagnosis

Step 1 Verify checkout.session.completed Is Being Handled

$stripe events list --type checkout.session.completed --limit 5

Cross-reference the event IDs from the Stripe CLI output with your application logs. If the events exist in Stripe but not in your logs, the webhook is not being received. If they appear in logs but no order was created, there is a bug in the handler.

Step 2 Implement Order Creation Exclusively in Webhook Handler

src/webhooks/checkout-handler.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

export async function handleCheckoutSessionCompleted(
  event: Stripe.Event
): Promise<void> {
  const session = event.data.object as Stripe.Checkout.Session;

  // Only process sessions where payment was actually collected
  if (session.payment_status !== "paid") {
    console.log(
      `[Checkout] Session ${session.id} not paid (status: ${session.payment_status}), skipping`
    );
    return;
  }

  // Idempotency: check if order already exists for this session
  const existingOrder = await db.orders.findUnique({
    where: { stripeSessionId: session.id },
  });

  if (existingOrder) {
    console.log(
      `[Checkout] Order already exists for session ${session.id}, skipping duplicate`
    );
    return;
  }

  // Fetch full session with line items expanded
  const fullSession = await stripe.checkout.sessions.retrieve(session.id, {
    expand: ["line_items", "line_items.data.price.product"],
  });

  const lineItems = fullSession.line_items?.data ?? [];

  await db.$transaction(async (tx) => {
    const order = await tx.orders.create({
      data: {
        stripeSessionId: session.id,
        stripePaymentIntentId: session.payment_intent as string,
        customerId: session.metadata?.userId ?? null,
        customerEmail: session.customer_details?.email ?? "",
        amountTotal: session.amount_total ?? 0,
        currency: session.currency ?? "usd",
        status: "pending_fulfillment",
      },
    });

    for (const item of lineItems) {
      await tx.orderItems.create({
        data: {
          orderId: order.id,
          priceId: item.price?.id ?? "",
          productId: (item.price?.product as Stripe.Product)?.id ?? "",
          quantity: item.quantity ?? 1,
          unitAmount: item.price?.unit_amount ?? 0,
        },
      });
    }

    console.log(
      `[Checkout] Order ${order.id} created for session ${session.id}`
    );
  });

  await triggerFulfillment(session.id);
}

Step 3 Update the Success Page to Query, Not Create

src/pages/api/checkout/success.ts
import type { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { session_id } = req.query;

  if (!session_id || typeof session_id !== "string") {
    return res.status(400).json({ error: "Missing session_id" });
  }

  // Poll for the order  webhook may not have fired yet
  let order = null;
  let attempts = 0;
  const maxAttempts = 10;

  while (!order && attempts < maxAttempts) {
    order = await db.orders.findUnique({
      where: { stripeSessionId: session_id },
    });

    if (!order) {
      await new Promise((resolve) => setTimeout(resolve, 500));
      attempts++;
    }
  }

  if (!order) {
    // Webhook might be delayed  show a pending state
    return res.json({
      status: "processing",
      message: "Your order is being processed. Check your email for confirmation.",
    });
  }

  return res.json({
    status: "confirmed",
    orderId: order.id,
    orderStatus: order.status,
  });
}

Step 4 Pass Metadata Through the Checkout Session

The checkout.session.completed event payload does not include your application's user ID by default. Pass it as metadata when creating the session so it is available in the webhook.

src/api/create-checkout-session.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

export async function createCheckoutSession(
  userId: string,
  cartItems: { priceId: string; quantity: number }[]
): Promise<string> {
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: cartItems.map((item) => ({
      price: item.priceId,
      quantity: item.quantity,
    })),
    metadata: {
      userId, // available in checkout.session.completed webhook
      cartId: "cart_" + Date.now(),
    },
    success_url: `${process.env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/checkout/canceled`,
    customer_creation: "always",
    payment_intent_data: {
      metadata: {
        userId, // also attach to PaymentIntent for cross-referencing
      },
    },
  });

  return session.url!;
}

Step 5 Recover Missed Orders

To recover any orders missed before deploying the fix, query Stripe for recent checkout sessions and create orders for any that do not exist in your database.

$stripe checkout sessions list --limit 100 --status complete

Production Failure Scenarios

Scenario 1 Browser Closed Before Success Redirect

Stripe processes the payment and emits checkout.session.completed, then issues a redirect to your success_url. If the user closes the browser immediately after payment confirmation (before the redirect completes), the success URL is never hit. If order creation lives in the success-URL handler, the order is never created.

Scenario 2 Database Error in Webhook Handler Causes Retry Loop

The webhook handler throws a database exception. Stripe marks the delivery as failed and retries. The handler runs again. If idempotency is not implemented, a second order is created on retry and continues being created on subsequent retries for up to 72 hours.

Scenario 3 Race Condition Between Success Page and Webhook

Webhook arrives the same millisecond the success page handler runs. Both check for an existing order, both find none, both create one. Without a database-level unique constraint on stripeSessionId, you end up with two orders for one payment.

Prevention Checklist

  • Never create orders in the success URL handler only in checkout.session.completed webhook
  • Enforce a unique database constraint on the stripeSessionId column
  • Check session.payment_status === 'paid' before creating an order in the webhook
  • Always pass userId and relevant metadata when creating the Checkout Session
  • Expand line_items when retrieving the session in the webhook handler
  • Implement a polling mechanism on the success page in case the webhook is delayed
  • Store the Stripe event ID alongside the order and log it for debugging
  • Run a daily job to find Stripe sessions with no matching order in your database

Resolution Summary

Move all order creation logic to the checkout.session.completed webhook handler. Add a unique database constraint on stripeSessionId. Check payment_status before creating. Implement idempotency checks. Update the success page to poll for the order rather than create it. Recover missed orders by listing completed Stripe sessions and filling gaps.

Lessons Learned

  • The success URL redirect is best-effort the webhook is the only reliable order creation trigger
  • A unique constraint on stripeSessionId is the simplest idempotency mechanism available
  • Checking payment_status before processing prevents orders from being created for free/gift card sessions
  • Metadata passed to checkout.session.create is available in the webhook use it to avoid extra database queries
  • Success pages should query for existing orders, not create them, to prevent race conditions

Conclusion

Stripe Checkout is powerful precisely because it handles the entire payment UI, but it places the responsibility of reliable order creation on your webhook handler. With idempotency, unique constraints, payment_status validation, and proper metadata, the checkout-to-order pipeline becomes reliable and recoverable even during webhook delivery failures.

Nurture Technologies

NEED HELP WITH YOUR STACK?

Nurture Technologies builds and maintains production-quality software for startups and businesses. If engineering problems are slowing you down, our team can help.

Talk to our team →
FAQ

FREQUENTLY ASKED QUESTIONS

Why should I never create orders in the success URL handler?+

The success URL is loaded by the customer's browser after Stripe redirects them. If the browser is closed, the network drops, or Stripe's redirect fails for any reason, your success handler never runs. The checkout.session.completed webhook is sent by Stripe's servers directly to yours and includes automatic retries.

How do I get the customer's cart items from the checkout.session.completed event?+

The basic event payload does not include line items. You must retrieve the full session with expand: ['line_items'] using stripe.checkout.sessions.retrieve(session.id, { expand: ['line_items'] }) inside your webhook handler.

What is payment_status in a Checkout Session and when is it not 'paid'?+

payment_status can be 'paid', 'unpaid', or 'no_payment_required'. Checkout sessions for free products or fully discounted orders will have 'no_payment_required'. The session.completed event fires in all these cases. Always check payment_status before creating an order that assumes payment was collected.

How do I pass my user's ID through the Checkout Session?+

Add metadata: { userId: 'your-user-id' } when creating the Checkout Session. This metadata is echoed back in the checkout.session.completed webhook event payload, letting you associate the session with your user without additional database lookups.

What if the webhook fires before my success page loads?+

This is the ideal scenario the order is created by the webhook before the user even sees the success page. Your success page should poll your database for the order using the session_id from the URL query parameter.

How do I prevent duplicate orders from Stripe's webhook retries?+

Implement idempotency by checking if an order already exists with the same stripeSessionId before creating a new one. Additionally, add a unique database constraint on stripeSessionId so that even if two concurrent webhook deliveries both pass the check simultaneously, only one insert succeeds.

Can I use the Stripe Checkout Session to store the order total?+

Yes. session.amount_total is the total amount collected in the lowest denomination of the currency (e.g., cents for USD). session.currency is the currency code. These are available directly in the checkout.session.completed event.

How long does Stripe keep Checkout Sessions available via API?+

Stripe retains Checkout Sessions and their associated events for at least 70 days. You can retrieve any session using stripe.checkout.sessions.retrieve(sessionId) during this window, which is useful for order recovery jobs.

What is the correct way to handle a Checkout Session for subscriptions?+

For subscription Checkout Sessions, use mode: 'subscription' and listen to customer.subscription.created in addition to checkout.session.completed. The session object will contain a subscription field with the Stripe subscription ID, which you should store and use for future subscription management.

How do I recover orders that were missed before I fixed the webhook handler?+

List all completed Checkout Sessions for the affected time period using stripe.checkout.sessions.list({ status: 'complete', created: { gte: startTimestamp } }). For each session, check if an order exists in your database. If not, create it from the session data.