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 5Cross-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
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
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.
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 completeProduction 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.