By Day 7, you've understood Stripe's authentication model and begun thinking about payment flows. Now it's time to build one. Stripe Checkout Sessions is the fastest way to collect payments for most products and recurring charges—a hosted payment page that handles the heavy lifting so you don't build a custom form. In this guide, we'll walk through creating a session, redirecting customers, and fulfilling orders on the right webhook event.
Create a session — Line items, mode and redirect URLs
A Checkout Session is a Stripe object that bundles your customer, their cart, and your business logic into a single request. You create it server-side and get back a URL to send the customer to.
There are three critical fields to set up:
- Line items: What the customer is buying—price ID, quantity, optional custom data.
- Mode: Either
paymentfor one-time charges orsubscriptionfor recurring billing. - Redirect URLs: Where Stripe sends the customer after they complete or cancel payment.
Let's build a concrete example. Say you're selling SaaS seats. A customer clicks "Upgrade to Pro" on your dashboard. Your backend receives a request, looks up their current plan, and creates a session to collect the difference.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function createCheckoutSession(customerId: string, priceId: string) {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: 'https://app.example.com/billing/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://app.example.com/billing/cancel',
});
return session.url;
}
A few details matter here:
- Customer ID: Linking the session to an existing Stripe customer ensures your webhook handler knows who to bill. If you're just collecting payment (no user account), omit this and use
customer_emailinstead. - Success and cancel URLs: These are user-facing redirects after the payment attempt. They're not where you fulfill the order—more on that in a moment.
- Line item structure: Use
price(recommended) rather thanamountif you've already set up prices in Stripe Dashboard or via API. Prices bundle amount, currency, tax behavior, and billing intervals in one reusable object.
For one-time purchases—say a digital download or one-off consulting fee—use mode: 'payment' and a single amount or price:
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Premium Report',
},
unit_amount: 9900, // $99.00 in cents
},
quantity: 1,
},
],
success_url: 'https://app.example.com/reports/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://app.example.com/reports/cancel',
});
Notice we can pass price_data inline instead of a price ID. This is convenient for one-off charges but less maintainable at scale; for subscriptions, always create prices ahead of time.
Redirect the customer — Stripe hosts the payment page
Once you have the session URL, send your user there. In a web app, this is typically a redirect from your backend or a direct link from your frontend:
// Frontend example (Next.js)
async function handleUpgradeClick() {
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId: 'price_pro_monthly' }),
});
const { sessionUrl } = await response.json();
window.location.href = sessionUrl;
}
Stripe now owns the payment form. The customer enters their card, billing address, and other required fields on a fully-hosted page. You don't touch PCI compliance for the card data—Stripe handles it all. The page is mobile-responsive and supports all major payment methods: card, wallet providers like Apple Pay and Google Pay, and regional methods like iDEAL or SEPA.
During this flow, Stripe creates a Payment Intent behind the scenes. If the payment succeeds, the customer is redirected to your success_url. If they close the tab or hit cancel, they go to cancel_url. If the card is declined, they see Stripe's error message and can retry.
Critically: do not rely on the success redirect to confirm payment in your system. A customer can be redirected to your success page, then close the browser before data syncs, or a network issue could prevent the redirect from firing. The webhook is your source of truth.
Fulfill on webhook — checkout.session.completed — not the success URL
When a Checkout Session transitions to a completed state, Stripe sends a webhook event: checkout.session.completed. This is where you grant access, send confirmation emails, update subscription records, and log revenue.
Set up an endpoint to receive and verify webhooks first. In your Stripe Dashboard under Developers > Webhooks, add an endpoint (e.g., https://api.example.com/webhooks/stripe) and subscribe to at least checkout.session.completed.
import Stripe from 'stripe';
import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
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, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send('Webhook Error: ' + err.message);
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
// Fulfill the order here
await fulfillOrder(session.customer, session.metadata);
// Send confirmation email
await sendConfirmationEmail(session.customer_email, session.id);
}
res.status(200).json({ received: true });
});
async function fulfillOrder(customerId: string, metadata: Record) {
// Look up your customer
const customer = await db.customers.findById(customerId);
// Grant access, update subscription tier, increment usage, etc.
if (metadata.tier === 'pro') {
await db.subscriptions.update({ customerId, tier: 'pro', active: true });
}
console.log(`Order fulfilled for customer ${customerId}`);
}
A few best practices:
- Verify the webhook signature: The
stripe.webhooks.constructEventcall ensures the webhook came from Stripe, not an attacker. Never skip this. - Use metadata: Store small contextual values (like
tier,seats_count) in the session'smetadatafield when you create it. Retrieve them in the webhook to know what to activate. - Idempotency: Stripe may retry a webhook if your endpoint doesn't return a
2xxstatus. Design your fulfillment logic to be idempotent—if you process the samecheckout.session.completed` event twice, the outcome should be the same. A simple approach: check if the subscription or order already exists before inserting. - Log everything: Record the session ID, timestamp, and result. Debugging payment issues is much easier with a clear audit trail.
For subscriptions, note that checkout.session.completed fires once, when the first payment succeeds. Recurring charges trigger invoice.payment_succeeded instead. We'll cover that on a later day.
When to use — Fastest path for most products and subscriptions
Checkout Sessions is the right choice for:
- SaaS subscriptions: Collecting recurring billing with standard plans (Starter, Pro, Enterprise).
- One-time purchases: Digital goods, courses, reports, or physical products with simple checkout.
- Multi-currency sales: Checkout automatically handles currency conversion and local payment methods.
- Complex line items: Need to show discounts, taxes, or a detailed invoice? Checkout calculates it all.
- Speed to market: Launch in hours, not weeks. No custom form, no PCI headaches.
It's not ideal if you need:
- Fully custom checkout UI: Stripe Elements or the Payment Element library gives you more control.
- Immediate order confirmation: Checkout redirects after payment; if you need real-time in-app feedback, a modal payment flow is better.
- Complex business logic: E.g., dynamic pricing based on customer tier, or conditional discount logic. You can use metadata, but there are limits.
For most SaaS and e-commerce teams, Checkout Sessions handles 80% of use cases elegantly. It's battle-tested, PCI-compliant, and requires minimal code.
Common mistakes
- Relying on the success redirect: Treat it as a hint, not confirmation. Always wait for the webhook.
- Forgetting to verify webhook signatures: Skipping
stripe.webhooks.constructEventopens you to forged events. Never do this in production. - Not handling idempotency: Processing the same webhook twice without checking for duplicates can double-charge or double-activate. Check the session ID before fulfilling.
- Using the wrong redirect URL placeholder: Stripe provides
{CHECKOUT_SESSION_ID}as a template variable. If you hardcode a URL without it, you lose the session context in the success page. - Storing card details in metadata: Never. Metadata is not encrypted and is visible to your team and API logs. Stripe handles all sensitive card data.
- Ignoring tax settings: Checkout can calculate tax automatically if you configure it. Skipping this step means manual tax calculations or unpaid compliance obligations.
Key takeaways
- A Checkout Session bundles customer, line items, and mode into a single Stripe object; create it server-side and redirect the customer to the hosted payment page.
- Always fulfill orders on the
checkout.session.completedwebhook event, not the success redirect—webhooks are reliable and tamper-proof. - Verify webhook signatures with
stripe.webhooks.constructEventand design fulfillment logic to be idempotent. - Store contextual data in session metadata to retrieve it during webhook processing.
- Checkout Sessions is the fastest, safest path for most products and subscriptions; use it until your requirements demand a custom flow.






Comments
Loading comments…