The Stripe Checkout pattern that skips PCI compliance
The single most expensive mistake I've seen MVP founders make is building their own credit card form.
It looks innocent. You add a few <input> fields, ask for the card number, expiry, CVC. You POST to your backend. You think you'll figure out the security stuff later.
You won't, because the security stuff is a regulatory framework called PCI-DSS (Payment Card Industry Data Security Standard), and the version that applies the moment a credit card number touches your server is the most demanding one — SAQ D for merchants. It requires quarterly vulnerability scans, an annual on-site audit by a Qualified Security Assessor, network segmentation, formal incident response plans, encryption of cardholder data at rest, and about 200 other controls. The annual cost runs $50,000 to $200,000+ for small businesses.
Stripe Checkout makes all of that go away.
What Stripe Checkout actually is
Stripe Checkout is a hosted payment page. Your code generates a session, redirects the user to a checkout.stripe.com URL, the user enters their card details on Stripe's domain, Stripe processes the payment, and Stripe redirects the user back to your /success URL.
Because the card data never touches your server, you qualify for SAQ A — the simplest PCI-DSS attestation, requiring nothing more than a self-completed annual questionnaire. No audit. No scans. No incident response plan.
This is the difference between an unfundable compliance burden and a 15-minute integration.
The integration
1. Get Stripe credentials. Sign up at dashboard.stripe.com. Developers → API keys. Copy the publishable key (pk_test_...) and secret key (sk_test_...). Test mode is fine for development.
2. Install:
npm install stripe @stripe/stripe-js
3. Centralized client at lib/stripe.ts:
import 'server-only';
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
4. Create products and prices in the Stripe dashboard. Each Price gets an ID like price_1Qabc123.... Save these as env vars or a config map keyed by SKU.
5. Checkout endpoint at app/api/checkout/route.ts:
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { auth } from '@/auth';
export async function POST(req: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'auth required' }, { status: 401 });
}
const { priceId } = await req.json();
const checkout = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${req.headers.get('origin')}/account?success=1`,
cancel_url: `${req.headers.get('origin')}/pricing`,
customer_email: session.user.email!,
});
return NextResponse.redirect(checkout.url!, { status: 303 });
}
The user clicks "Buy" → redirected to Stripe → enters card → redirected back to /account?success=1.
6. The webhook is non-optional. The redirect to your success page is not authoritative. Anyone can hit that URL by guessing it. Real fulfillment happens via Stripe's webhook.
In Stripe dashboard → Developers → Webhooks → Add endpoint:
- URL:
https://your-domain.com/api/stripe/webhook - Events:
checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed - Copy the signing secret (
whsec_...) and add to.env.local.
Webhook handler:
import { stripe } from '@/lib/stripe';
export const runtime = 'nodejs';
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!;
const body = await req.text();
let event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// grant access in your DB
break;
}
case 'customer.subscription.deleted': {
// revoke access
break;
}
}
return new Response('ok');
}
The signature verification is the load-bearing line. Without it, anyone can POST fake events to your webhook URL and grant themselves access. With it, Stripe cryptographically proves the event came from them.
Local webhook testing
The Stripe CLI forwards production webhooks to your local dev:
stripe listen --forward-to localhost:3000/api/stripe/webhook
This prints a webhook secret you can drop into .env.local for dev. Then stripe trigger checkout.session.completed fires a test event.
The full test card list you need
Stripe gives you test cards that simulate every payment outcome:
4242 4242 4242 4242— succeeds4000 0000 0000 9995— declined (insufficient funds)4000 0000 0000 0341— succeeds, but requires 3D Secure authentication
Any future expiry, any 3-digit CVC. Walk through each before launch.
Anti-patterns
Granting access on the success page. The success_url redirect is just a UX hint. Anyone can curl that URL. Always wait for the webhook before granting access.
Storing card numbers. No. Never. Use Stripe's tokens.
Hardcoding price IDs. Test mode and live mode have different price IDs. Keep them in env vars so swapping environments is one config change.
Skipping the cancel_url. Without it, users who back out of checkout get stranded. Point it at your pricing page.
What you give up
Direct payment integration with cards on your own form is faster to look at. The user feels like they're "still on your site." With Checkout, they get redirected to checkout.stripe.com for 30 seconds.
The conversion-rate impact of this is real but small. The compliance-cost difference is enormous. For an MVP, Checkout is the right answer every time.
If your conversion data later shows a measurable drop from the redirect, you can migrate to Stripe Elements (an embedded card form that still keeps card data off your server, but renders inside your page). That's a v2 problem. Ship v1 with Checkout.
The whole integration takes about 20 minutes. Real payments, real refunds, real subscriptions, no PCI audit. This is the win.