Payment Integration: Stripe Checkout for Developers
How to integrate Stripe Checkout into a web app — server-side session creation with the Stripe SDK, redirecting customers, verifying webhooks for payment success, and running subscriptions.
Published on • August 14, 2026
AI Assistant

You’ve built the storefront, the cart works, and now it’s time to accept money. The temptation is to store card numbers yourself — don’t. PCI compliance, fraud liability, and a dozen payment methods make that a losing trade. The standard answer for most products is Stripe Checkout: Stripe hosts the payment page (or gives you an embeddable form), handles cards, wallets, and local methods, and your backend stays tiny — create a session, redirect, and react to webhooks.
In this tutorial, you will learn how to integrate Stripe Checkout end-to-end with Node.js: creating a Checkout Session on the server with the Stripe SDK, redirecting customers to the hosted payment page, verifying and handling webhooks for payment success, and configuring subscriptions. You’ll also learn the production rules that separate a demo from a real integration: signature verification, idempotency, test mode, and event retries.
Prerequisites
- A Stripe account (free; you stay in test mode until you go live).
- Node.js 16+ and
npm. @stripe/stripe-jsfor client-side redirects (optional — a plain form works too).- The Stripe CLI for local webhook testing:
stripe listen.
All API keys live in test mode: sk_test_... for the secret key and pk_test_... for the publishable key. Never hardcode or commit keys — read them from environment variables.
How Checkout works
There are three hosted UIs built on the Checkout Sessions API, and they share one backend pattern:
| UI | Hosting | Complexity |
|---|---|---|
| Full page | Stripe-hosted, redirect | Low (recommended) |
| Embedded form | Embedded on your site | Some |
| Elements | Fully custom, embedded | Most |
The flow is the same in every case: your server calls stripe.checkout.sessions.create() with the line items, mode, and return URLs; Stripe responds with a session object containing a hosted url; you redirect the customer there. After payment, Stripe redirects the customer back to your success_url and delivers a webhook event (checkout.session.completed) to your backend — the webhook is the source of truth for fulfilling the order.
Install the Stripe SDK
npm install --save stripe
The Node library initializes with your secret key and includes full TypeScript types:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
Keep product pricing on the server — never trust client-supplied amounts. Stripe always uses the amount bound to the price ID you pass in the session.
Create a Checkout Session
Define a route that creates a session for a one-time payment. This example references a Price object created in the Dashboard (price_...), which keeps pricing server-side:
app.post('/create-checkout-session', async (req, res) => {
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: 'price_1234', // the exact Price ID from your Dashboard
quantity: 1,
},
],
mode: 'payment', // 'payment' | 'subscription' | 'setup'
success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/cancel`,
// optional polish
customer_email: 'customer@example.com',
billing_address_collection: 'auto',
automatic_tax: { enabled: true },
});
res.redirect(303, session.url);
});
Key details:
line_items— each item is apriceID plus aquantity. You can also build a price on the fly withprice_data, but predefined Prices are simpler and reusable.mode—paymentfor one-time charges,subscriptionfor recurring billing,setupfor saving a payment method without charging.success_url/cancel_url— must be publicly accessible. Use the literal{CHECKOUT_SESSION_ID}template in the success URL to retrieve the session on the success page.- The redirect is HTTP 303 See Other — use it as shown so GET/refresh semantics stay correct.
Prefer predefined Price IDs over inline amounts: sensitive information like price and availability must live on the server to prevent customer manipulation from the client.
Redirect the customer client-side
A plain HTML form that POSTs to your server route works with no JavaScript at all:
<form action="/create-checkout-session" method="POST">
<button type="submit">Checkout</button>
</form>
If you prefer a client-driven redirect (e.g. after choosing options in a SPA), call your backend and follow the returned url:
import { loadStripe } from '@stripe/stripe-js';
const stripe = await loadStripe('pk_test_...');
const res = await fetch('/create-checkout-session', { method: 'POST' });
const { url } = await res.json();
window.location.href = url;
The customer pays on the Stripe-hosted page, then is redirected back to your success_url.
Confirm payment with webhooks
The success page is for confirmation UX; the webhook is where you fulfill. Stripe pushes a JSON Event object to your endpoint whenever something happens — a completed session, a successful payment intent, a paid invoice. Event delivery is asynchronous and retried for up to three days with exponential backoff in live mode, so your handler must be idempotent.
First, register the endpoint. In production you add the endpoint in the Dashboard (Workbench → Webhooks → Create event destination) with your public HTTPS URL and the event types you actually need, e.g. checkout.session.completed and invoice.paid. The Dashboard gives you a signing secret (whsec_...). For local development, forward Stripe events to your machine with the CLI:
stripe listen --forward-to localhost:4242/webhook
The CLI prints a signing secret — note it; your handler uses it to verify signatures. You can trigger test events with stripe trigger checkout.session.completed.
Now the handler. Two things matter: read the raw body (never JSON-parsed first, or signature verification breaks) and verify the Stripe-Signature header:
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Express's raw body is required for signature verification.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// Fulfill the order — grant access, send email, update inventory.
// session.payment_status is 'paid' here for one-time payments.
console.log('Fulfilling order for session', session.id);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
// Renew access for subscription billing cycles.
console.log('Subscription invoice paid:', invoice.subscription);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
});
What constructEvent does under the hood: it parses the Stripe-Signature header (a t= timestamp plus one or more v1= HMAC-SHA256 signatures), recomputes the signature over the raw body, and rejects anything that isn’t genuinely from Stripe. The official libraries default to a 5-minute timestamp tolerance, which also mitigates replay attacks.
Two rules keep this robust in production:
- Return
2xxfast. Stripe recommends acknowledging delivery before doing complex work — return200, then enqueue fulfillment. If you crash mid-fulfillment, Stripe retries. - Handle duplicates. Stripe may deliver the same event more than once (and retries create fresh signatures). Log processed event IDs and skip repeats, keyed on
event.idortype + data.object.id.
Payment Links vs. Checkout Sessions
Before building, decide which product fits. Payment Links are created entirely in the Dashboard — Stripe hosts the whole flow and you never write checkout code; great for simple products, invoices, and one-off links. Checkout Sessions are created from your server with the API and support dynamic pricing, subscriptions, custom success URLs, and webhook-driven fulfillment. If you need any business logic, you need Sessions.
Subscriptions and metered billing
Switching to recurring revenue is a one-line change to the session — set mode to subscription and point line_items at a recurring Price (created with recurring: { interval: 'month' }):
app.post('/create-subscription', async (req, res) => {
const session = await stripe.checkout.sessions.create({
line_items: [{ price: 'price_recurring_monthly', quantity: 1 }],
mode: 'subscription',
success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/cancel`,
});
res.redirect(303, session.url);
});
Stripe now owns the entire lifecycle: the initial charge, renewal invoices, failed-payment retries, dunning emails, and cancellation. Your job is to react to invoice.paid (grant/keep access) and customer.subscription.deleted (revoke). For usage-based (metered) pricing, Stripe supports billing meters and per-unit pricing; the customer-facing flow is identical, but your server reports usage via stripe.billing.meterEvents.create() or the raw usage-records API, and Stripe invoices based on the meter at the end of each billing cycle.
Test mode, test cards, and production checklist
Test mode uses the same code with sk_test_... keys — no real money moves. Stripe provides test cards for the important scenarios:
| Scenario | Card number |
|---|---|
| Payment succeeds | 4242 4242 4242 4242 |
| Requires 3DS authentication | 4000 0025 0000 3155 |
| Payment is declined | 4000 0000 0000 9995 |
Add customer_creation: 'always' and pass customer or customer_email to attach a known customer; Checkout otherwise uses guest customers for one-time payments.
When you’re ready for production:
- Set the webhook endpoint’s signing secret as
STRIPE_WEBHOOK_SECRET(live-mode keys have their own secret — different from test). - Configure the endpoint to receive only the event types your integration needs.
- Roll the signing secret periodically (Dashboard → Webhooks → Roll secret).
- Optionally IP-allowlist Stripe’s published webhook IP ranges as a second layer.
- Idempotency keys: Stripe API requests accept an
Idempotency-Keyheader (the Node SDK exposes it on request options). If a request times out and you retry with the same key, Stripe returns the original result instead of creating a duplicate session or charge — crucial for order creation under flaky networks.
Putting It All Together
A complete, minimal integration — server, client button, and webhook — is about forty lines of backend code plus one page.
// server.js
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const DOMAIN = process.env.DOMAIN || 'http://localhost:4242';
app.use(express.static('public'));
app.post('/create-checkout-session', express.json(), async (req, res) => {
const session = await stripe.checkout.sessions.create({
line_items: [{ price: 'price_1234', quantity: 1 }],
mode: 'payment',
success_url: `${DOMAIN}/success.html`,
cancel_url: `${DOMAIN}/cancel.html`,
});
res.redirect(303, session.url);
});
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error('Signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'checkout.session.completed':
// Fulfill the order. In production, push to a queue and return fast.
console.log('Fulfill order:', event.data.object.id, event.data.object.payment_status);
break;
default:
console.log('Unhandled event:', event.type);
}
res.json({ received: true });
});
app.listen(4242, () => console.log('Server running on http://localhost:4242'));
<!-- public/index.html -->
<form action="/create-checkout-session" method="POST">
<button type="submit">Checkout</button>
</form>
Expected output — run stripe listen --forward-to localhost:4242/webhook in one terminal and the server in another. Open http://localhost:4242, click Checkout, and pay with 4242 4242 4242 4242. Stripe redirects you to success.html and your server logs a verified event:
Server running on http://localhost:4242
Fulfill order: cs_test_a1B2c3D4e5F6g7H8i9J0 payment_status: paid
Pay with 4000 0000 0000 9995 and the session completes but with payment_status: unpaid — which is why you gate fulfillment on the webhook’s actual payment status, not on the redirect.
Conclusion & Next Steps
You now know the shape of a production Stripe integration: server-side Checkout Session creation with mode, line_items, and success/cancel URLs; client redirect via form or @stripe/stripe-js; signature-verified webhooks as the source of truth for fulfillment; and the switch to mode: 'subscription' plus invoice.paid handling for recurring revenue. Next steps: add automatic_tax, adopt idempotency keys on session creation, set up your real webhook endpoint with a rolled signing secret, and read the fulfillment guide before going live.
References / Sources
- Stripe — Checkout documentation and API reference. https://stripe.com/docs/payments/checkout
- Stripe — Checkout quickstart (hosted page, session creation, test cards). https://docs.stripe.com/checkout/quickstart
- Stripe — Webhooks documentation (endpoints, signatures, retries, best practices). https://stripe.com/docs/webhooks
- Stripe — Subscriptions with Checkout Sessions (recurring pricing, metered billing). https://stripe.com/docs/payments/subscriptions
- Stripe — Fulfilling orders after Checkout. https://docs.stripe.com/checkout/fulfillment