Stripe is the payments layer for the majority of new web apps in 2026. It handles PCI compliance, card vaulting, fraud detection, subscriptions, invoicing, and tax — none of which you want to build yourself. But integrating it correctly, especially the webhook flow, is where teams consistently make expensive mistakes. This guide covers it end to end.
What changed in 2026
- Stripe Payment Elements v2 unified the UI for cards, Apple Pay, Google Pay, SEPA, and 30+ local payment methods in one drop-in component.
- Stripe Tax became viable for global SaaS — it calculates and remits VAT/GST automatically in 40+ countries.
- Adaptive pricing (automatic currency conversion) is enabled by default on new accounts.
- Stripe Billing v3 improved proration UX and added more granular webhook events for subscription lifecycle management.
- The Stripe CLI improved to the point that local webhook testing is essentially the same experience as production.
Two integration paths
Stripe Checkout (hosted): Stripe hosts the payment page. You redirect to it, Stripe handles everything, you get a webhook. Best for most apps — ~30 min to implement.
Stripe Payment Elements (embedded): You embed the UI in your own page. More control over branding, slightly more complex. Best when you want the payment form inline with your checkout flow.
For an MVP or any app where you do not need deep UI control, use Checkout.
Stripe Checkout: one-time payment
// server: create a checkout session
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, userId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL}/pricing`,
metadata: { userId },
});
return Response.json({ url: session.url });
}
Redirect the user to session.url. Stripe handles the card form.
Stripe Checkout: subscription
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer_email: user.email,
line_items: [{ price: process.env.STRIPE_MONTHLY_PRICE_ID, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${process.env.BASE_URL}/dashboard`,
cancel_url: `${process.env.BASE_URL}/pricing`,
metadata: { userId: user.id },
});
Webhook handling: the critical path
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
export async function POST(req: Request) {
const body = await req.text(); // raw body required for signature verification
const sig = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await db.user.update({
where: { id: session.metadata!.userId },
data: { plan: "pro" },
});
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: sub.customer as string },
data: { plan: "free" },
});
break;
}
}
return new Response(null, { status: 200 });
}
Always return 200 quickly. Stripe retries events that do not receive a 200 within 30 seconds.
Idempotency keys
const paymentIntent = await stripe.paymentIntents.create(
{ amount: 2000, currency: "usd", customer: customerId },
{ idempotencyKey: `pi-${orderId}` } // same key = same object, no duplicate charge
);
Use a stable identifier (order ID, UUID) as the idempotency key on all create operations. If your server retries due to a timeout, Stripe returns the existing object instead of creating a duplicate charge.
Testing with the Stripe CLI
# Install
brew install stripe/stripe-cli/stripe
# Forward webhooks to local dev
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
Production checklist
| Step |
Done? |
| Switch to live API keys |
|
| Register webhook endpoint in Stripe dashboard |
|
Set STRIPE_WEBHOOK_SECRET from dashboard |
|
| Enable only the events your handler uses |
|
Test with real card (4242 4242 4242 4242) |
|
Test decline card (4000 0000 0000 0002) |
|
| Enable Stripe Radar for fraud rules |
|
| Set up email receipts in Stripe settings |
|
How to pick the right integration
- Simple one-time purchase, MVP, or quick launch → Stripe Checkout (hosted). Done in 30 min.
- Subscription SaaS → Checkout with
mode: "subscription" + Billing portal for self-service.
- Custom checkout UI in your own page → Payment Elements. More work but full brand control.
- Marketplace with multiple sellers → Stripe Connect. Significantly more complex — plan 2–3 weeks.
Common mistakes
Trusting the success redirect URL. Users close tabs, networks fail. The redirect may not fire. Always confirm payment via the checkout.session.completed webhook.
Not verifying the webhook signature. Anyone can POST to your endpoint. The stripe.webhooks.constructEvent call with your webhook secret is mandatory.
Using the raw body parsed by your framework. Webhook signature verification requires the exact raw bytes Stripe sent. Always read req.text() before any JSON parsing.
Missing the customer.subscription.updated event. This event fires on plan changes, payment failures, and renewals. Without it, your plan state diverges from Stripe's.
Charging in the frontend. Never call Stripe from the browser with your secret key. The secret key belongs server-side only. The publishable key is safe for the browser.
What to skip
- Building your own invoice PDF generator. Stripe generates and hosts invoices automatically. Link to
invoice.hosted_invoice_url.
- Manual dunning logic. Stripe's Smart Retries and dunning emails are enabled by default. Do not build a retry scheduler.
- Custom subscription upgrade/downgrade UI. Stripe's Billing Portal handles this. Redirect to
stripe.billingPortal.sessions.create(...) for self-service plan management.
FAQ
How do I handle failed payments?
Listen to invoice.payment_failed. Stripe retries automatically with Smart Retries (up to 4 attempts over ~3 weeks). After final failure, customer.subscription.deleted fires — downgrade the user then.
Should I use Stripe Elements or Checkout?
Checkout for speed and simplicity. Elements for embedded UI control. If you are not sure, start with Checkout — you can migrate to Elements later.
How do I support multiple currencies?
Enable Stripe's automatic currency conversion (Adaptive Pricing) in the dashboard. Customers see their local currency; you receive your settlement currency. No code changes required.
What is the Stripe test card?
4242 4242 4242 4242, any future expiry, any CVC. For declined: 4000 0000 0000 0002. For 3DS: 4000 0025 0000 3155.
Where to go next