This chapter follows Products and prices in Stripe.
Stripe Checkout is the default path for many web apps: Stripe renders the payment form, handles wallets and local methods you enable in the Dashboard, and returns the customer to your site. You create a Checkout Session on the server and redirect to session.url.
Prerequisites
You need:
- A Price id for a one-time product (from Products and prices)
STRIPE_SECRET_KEYand the shared stripe client- Optional: a Stripe Customer id if the buyer is logged in
Do not pass payment_method_types on the Session unless you are building Terminal card_present flows. Omitting it enables dynamic payment methods from Dashboard settings.
Create a Checkout Session on the server
import { stripe } from '../stripe-client.js';import { usersById } from '../store.js';
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4242';
export async function createCheckoutSession(req, res) { const { userId, priceId } = req.body; const user = usersById.get(userId);
const session = await stripe.checkout.sessions.create({ mode: 'payment', line_items: [{ price: priceId, quantity: 1 }], success_url: `${BASE_URL}/success.html?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${BASE_URL}/cancel.html`, customer: user?.stripeCustomerId, client_reference_id: userId, metadata: { appUserId: userId ?? 'guest' }, });
res.json({ url: session.url });}client_reference_id and metadata help your webhook handler tie payment back to your user without trusting query parameters alone.
Redirect the browser to Checkout
<button id="buy">Buy Pro (lifetime)</button><script> document.getElementById('buy').onclick = async () => { const res = await fetch('/create-checkout-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: 'user_1', priceId: 'price_REPLACE_ME', }), }); const { url } = await res.json(); window.location = url; };</script>Wire Express:
import express from 'express';import { createCheckoutSession } from './routes/checkout.js';
const app = express();app.use(express.static('public'));app.use(express.json());app.post('/create-checkout-session', createCheckoutSession);app.listen(4242);In production, protect the POST route with authentication and validate priceId against your allowed catalog—never accept arbitrary price ids from anonymous clients.
Success and cancel pages
Cancel — Show a friendly message; no payment occurred.
Success — You may retrieve the Session for display:
import { stripe } from '../stripe-client.js';
export async function getSessionStatus(req, res) { const session = await stripe.checkout.sessions.retrieve(req.query.session_id); res.json({ status: session.status, paymentStatus: session.payment_status, customerEmail: session.customer_details?.email, });}session.status === 'complete' and payment_status === 'paid' indicate success for most card payments. Do not grant entitlements here alone—users can refresh or share the URL. Grant access when your webhook processes checkout.session.completed (and checkout.session.async_payment_succeeded when payment_status is unpaid until async methods settle).
Why fulfillment belongs in webhooks
Redirects are unreliable: closed tabs, back buttons, and mobile handoffs happen daily. Webhooks retry from Stripe’s infrastructure and carry signed proof of the event.
| Step | Safe for fulfillment? |
|---|---|
Webhook checkout.session.completed |
Yes (with idempotent handler) |
Success page retrieve |
Display only |
Client-side session_id in URL |
Untrusted hint |
The next payment chapter covers embedded UI; Webhooks in Stripe implements the handler.
Test the flow locally
Use test card 4242 4242 4242 4242, any future expiry, any CVC. Install the Stripe CLI and forward events while developing:
stripe listen --forward-to localhost:4242/webhookComplete a Checkout payment and confirm events arrive before you rely on success-page logic.
Check your understanding
Question 1 of 6
0 of 6 checked