Full-Stack Engineering
Stripe Integration in Next.js: Payments, Subscriptions, and Webhooks
Last updated: April 14, 2026
TL;DR
Stripe is the payment infrastructure I reach for on every project that touches money. I've integrated it across multiple Next.js apps — most notably FreshMart, a UK grocery delivery platform where we handle one-time purchases, recurring subscriptions for weekly boxes, and webhook-driven order fulfillment. This guide covers Stripe Checkout for fast integration, Stripe Elements for custom payment forms, subscription billing with trials and upgrades, webhook handling in Next.js Route Handlers, idempotency for safe retries, testing with the Stripe CLI, the Customer Portal for self-service, and handling failed payments gracefully. Every code sample is TypeScript, every pattern is production-tested. If you're adding payments to a Next.js app, this is the guide I wish I'd had when I started.
Setting Up Stripe
Before writing any payment code, you need a solid foundation. I set up every Stripe project the same way — a shared configuration module, environment variables validated at build time, and type-safe access to the Stripe SDK on both server and client.
Install the dependencies:
npm install stripe @stripe/stripe-js @stripe/react-stripe-jsServer-side Stripe client — this is the one that touches your secret key. It never runs in the browser:
// lib/stripe/server.ts
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set in environment variables");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-12-18.acacia",
typescript: true,
appInfo: {
name: "FreshMart",
version: "1.0.0",
url: "https://freshmart.co.uk",
},
});Client-side Stripe (publishable key only):
// lib/stripe/client.ts
import { loadStripe } from "@stripe/stripe-js";
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
if (!publishableKey) {
throw new Error("NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is not set");
}
export const getStripe = () => loadStripe(publishableKey);I always set appInfo on the server client. Stripe uses it to identify your integration in their dashboard and support tools. When something goes wrong at 2am and you're on a call with Stripe support, they can pull up your specific integration instantly. Small thing, big payoff.
Environment variables for your .env.local:
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Stripe Checkout -- The Fast Path
Stripe Checkout is a hosted payment page. You redirect the user to Stripe, they pay, Stripe redirects them back. It handles PCI compliance, 3D Secure, Apple Pay, Google Pay, and dozens of local payment methods out of the box. For FreshMart, this is what we used for one-time grocery orders before we built the custom subscription flow.
Create a Checkout Session from a Route Handler:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/server";
import { auth } from "@/lib/auth";
interface CheckoutItem {
priceId: string;
quantity: number;
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "Sign in to checkout" } },
{ status: 401 }
);
}
const { items } = (await request.json()) as { items: CheckoutItem[] };
const lineItems = items.map((item) => ({
price: item.priceId,
quantity: item.quantity,
}));
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
customer_email: session.user.email ?? undefined,
line_items: lineItems,
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/orders/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart`,
metadata: {
userId: session.user.id,
},
shipping_address_collection: {
allowed_countries: ["GB"],
},
payment_intent_data: {
metadata: {
userId: session.user.id,
},
},
});
return NextResponse.json({ url: checkoutSession.url });
}Client-side redirect:
// components/checkout-button.tsx
"use client";
import { useState } from "react";
interface CheckoutButtonProps {
items: Array<{ priceId: string; quantity: number }>;
}
export function CheckoutButton({ items }: CheckoutButtonProps) {
const [isLoading, setIsLoading] = useState(false);
async function handleCheckout() {
setIsLoading(true);
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
const { url, error } = await response.json();
if (error) {
throw new Error(error.message);
}
window.location.href = url;
} catch (err) {
console.error("Checkout failed:", err);
setIsLoading(false);
}
}
return (
<button
onClick={handleCheckout}
disabled={isLoading}
className="rounded-lg bg-[#635BFF] px-6 py-3 font-semibold text-white
transition-colors hover:bg-[#4B45C6] disabled:opacity-50"
>
{isLoading ? "Redirecting..." : "Checkout"}
</button>
);
}The key decisions here: I pass metadata on both the session and the payment_intent_data. The session metadata is for the checkout.session.completed webhook. The payment intent metadata is for the payment_intent.succeeded webhook. Both fire, and you want your userId available in both handlers. I learned this the hard way on an early client project where I only set session metadata and then couldn't correlate payment intents to users when investigating refund issues.
Stripe Elements -- Custom Forms
Stripe Checkout is fast to ship, but sometimes you need the payment form embedded in your own UI. For FreshMart's subscription signup, we wanted the payment form inside a multi-step onboarding flow — choose your box, pick delivery day, enter payment. That's where Stripe Elements comes in.
Set up the Elements provider:
// components/stripe-provider.tsx
"use client";
import { Elements } from "@stripe/react-stripe-js";
import { getStripe } from "@/lib/stripe/client";
import type { ReactNode } from "react";
interface StripeProviderProps {
clientSecret: string;
children: ReactNode;
}
export function StripeProvider({ clientSecret, children }: StripeProviderProps) {
return (
<Elements
stripe={getStripe()}
options={{
clientSecret,
appearance: {
theme: "night",
variables: {
colorPrimary: "#F7931A",
colorBackground: "#111827",
colorText: "#FFFFFF",
colorDanger: "#FF4560",
fontFamily: "Inter, system-ui, sans-serif",
borderRadius: "8px",
},
},
}}
>
{children}
</Elements>
);
}Create a Payment Intent on the server:
// app/api/payment-intent/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/server";
import { auth } from "@/lib/auth";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "Sign in required" } },
{ status: 401 }
);
}
const { amount, currency = "gbp" } = await request.json();
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency,
metadata: { userId: session.user.id },
automatic_payment_methods: { enabled: true },
});
return NextResponse.json({ clientSecret: paymentIntent.client_secret });
}The payment form component:
// components/payment-form.tsx
"use client";
import { useState, type FormEvent } from "react";
import {
useStripe,
useElements,
PaymentElement,
} from "@stripe/react-stripe-js";
interface PaymentFormProps {
onSuccess: (paymentIntentId: string) => void;
}
export function PaymentForm({ onSuccess }: PaymentFormProps) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!stripe || !elements) return;
setIsProcessing(true);
setError(null);
const { error: submitError, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/orders/success`,
},
redirect: "if_required",
});
if (submitError) {
setError(submitError.message ?? "Payment failed. Please try again.");
setIsProcessing(false);
return;
}
if (paymentIntent?.status === "succeeded") {
onSuccess(paymentIntent.id);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<PaymentElement />
{error && (
<div className="rounded-lg bg-red-500/10 p-3 text-sm text-red-400">
{error}
</div>
)}
<button
type="submit"
disabled={!stripe || isProcessing}
className="w-full rounded-lg bg-[#F7931A] px-6 py-3 font-semibold
text-white transition-colors hover:bg-[#E07B0A]
disabled:opacity-50"
>
{isProcessing ? "Processing..." : "Pay Now"}
</button>
</form>
);
}The redirect: "if_required" option is critical. Without it, every payment redirects — even simple card payments that don't need 3D Secure. With it, only payments requiring additional authentication (like 3D Secure) redirect, and everything else completes inline. Much better UX.
Subscription Billing
Subscriptions are where Stripe really shines — and where the complexity ramps up. For FreshMart, customers subscribe to weekly grocery boxes. They can upgrade, downgrade, pause, and cancel. Here's how I wire it all up.
Create a subscription through Checkout:
// app/api/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "Sign in required" } },
{ status: 401 }
);
}
const { priceId } = await request.json();
let customerId = session.user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email ?? undefined,
metadata: { userId: session.user.id },
});
customerId = customer.id;
await db.user.update({
where: { id: session.user.id },
data: { stripeCustomerId: customerId },
});
}
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?subscribed=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
subscription_data: {
trial_period_days: 7,
metadata: { userId: session.user.id },
},
allow_promotion_codes: true,
});
return NextResponse.json({ url: checkoutSession.url });
}Handling plan changes (upgrades and downgrades):
// app/api/subscription/change-plan/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/server";
import { auth } from "@/lib/auth";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.stripeCustomerId) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "No active subscription" } },
{ status: 401 }
);
}
const { newPriceId } = await request.json();
const subscriptions = await stripe.subscriptions.list({
customer: session.user.stripeCustomerId,
status: "active",
limit: 1,
});
const subscription = subscriptions.data[0];
if (!subscription) {
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: "No active subscription found" } },
{ status: 404 }
);
}
const updatedSubscription = await stripe.subscriptions.update(
subscription.id,
{
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: "create_prorations",
}
);
return NextResponse.json({
subscription: {
id: updatedSubscription.id,
status: updatedSubscription.status,
currentPeriodEnd: updatedSubscription.current_period_end,
},
});
}The proration_behavior: "create_prorations" setting is important. When a customer upgrades mid-cycle, Stripe calculates the difference and charges them only the prorated amount. When they downgrade, a credit is applied to the next invoice. Don't set this to "none" unless you have a very specific reason — customers notice when they're double-charged.
Webhook Handling in Route Handlers
Webhooks are the backbone of any Stripe integration. They're how Stripe tells your app what happened — payment succeeded, subscription cancelled, invoice failed. In Next.js, I handle them in a Route Handler with signature verification.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe/server";
import { db } from "@/lib/db";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error("STRIPE_WEBHOOK_SECRET is not set");
}
export async function POST(request: NextRequest) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: { code: "MISSING_SIGNATURE", message: "No signature header" } },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(`Webhook signature verification failed: ${message}`);
return NextResponse.json(
{ error: { code: "INVALID_SIGNATURE", message } },
{ status: 400 }
);
}
try {
await handleWebhookEvent(event);
} catch (err) {
console.error(`Webhook handler error for ${event.type}:`, err);
return NextResponse.json(
{ error: { code: "HANDLER_ERROR", message: "Webhook processing failed" } },
{ status: 500 }
);
}
return NextResponse.json({ received: true });
}
async function handleWebhookEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutComplete(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdate(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionCancelled(event.data.object);
break;
case "invoice.payment_succeeded":
await handleInvoicePaid(event.data.object);
break;
case "invoice.payment_failed":
await handleInvoiceFailed(event.data.object);
break;
default:
console.log(`Unhandled webhook event: ${event.type}`);
}
}
async function handleCheckoutComplete(
session: Stripe.Checkout.Session
) {
const userId = session.metadata?.userId;
if (!userId) {
console.error("No userId in checkout session metadata");
return;
}
if (session.mode === "subscription") {
await db.user.update({
where: { id: userId },
data: {
subscriptionId: session.subscription as string,
subscriptionStatus: "active",
},
});
}
if (session.mode === "payment") {
await db.order.create({
data: {
userId,
stripeSessionId: session.id,
paymentIntentId: session.payment_intent as string,
status: "CONFIRMED",
total: (session.amount_total ?? 0) / 100,
},
});
}
}
async function handleSubscriptionUpdate(
subscription: Stripe.Subscription
) {
const userId = subscription.metadata?.userId;
if (!userId) return;
await db.user.update({
where: { id: userId },
data: {
subscriptionStatus: subscription.status,
currentPlan: subscription.items.data[0]?.price.id,
},
});
}
async function handleSubscriptionCancelled(
subscription: Stripe.Subscription
) {
const userId = subscription.metadata?.userId;
if (!userId) return;
await db.user.update({
where: { id: userId },
data: {
subscriptionStatus: "canceled",
subscriptionId: null,
},
});
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
console.log(`Invoice paid: ${invoice.id} for ${invoice.customer}`);
}
async function handleInvoiceFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) return;
const userId = customer.metadata?.userId;
if (!userId) return;
await db.notification.create({
data: {
userId,
type: "PAYMENT_FAILED",
title: "Payment Failed",
message:
"We couldn't process your payment. Please update your card to keep your subscription active.",
},
});
}Two things I always get right on webhooks. First, read the body as text(), not json(). Stripe's signature verification needs the raw body string. If you parse it as JSON first, the signature won't match because JSON serialization can reorder keys. Second, always return a 200 quickly. If your webhook handler takes too long, Stripe will retry and you'll process the same event multiple times. For heavy processing, acknowledge the webhook and queue the work.
Idempotency and Retry Logic
Stripe will retry failed webhooks, and network issues can cause duplicate API calls. Without idempotency, you'll create duplicate orders, charge customers twice, or corrupt subscription state. I handle this at two levels.
For outgoing API calls, use Stripe's idempotency keys:
// lib/stripe/idempotent.ts
import { stripe } from "./server";
import { randomUUID } from "crypto";
export async function createPaymentIntentIdempotent(
params: {
amount: number;
currency: string;
customerId: string;
orderId: string;
}
) {
const idempotencyKey = `pi_${params.orderId}_${params.amount}`;
return stripe.paymentIntents.create(
{
amount: params.amount,
currency: params.currency,
customer: params.customerId,
metadata: { orderId: params.orderId },
},
{ idempotencyKey }
);
}For incoming webhooks, track processed event IDs:
// lib/stripe/webhook-dedup.ts
import { db } from "@/lib/db";
export async function isEventProcessed(eventId: string): Promise<boolean> {
const existing = await db.stripeEvent.findUnique({
where: { eventId },
});
return existing !== null;
}
export async function markEventProcessed(eventId: string): Promise<void> {
await db.stripeEvent.create({
data: {
eventId,
processedAt: new Date(),
},
});
}Then wrap your webhook handler:
async function handleWebhookEvent(event: Stripe.Event) {
if (await isEventProcessed(event.id)) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// ... handle the event ...
await markEventProcessed(event.id);
}The idempotency key format matters. I use a deterministic key based on the operation — pi_${orderId}_${amount} — so that retrying the same operation with the same parameters hits the same idempotency key. Using randomUUID() as the key defeats the purpose because every retry generates a new key. Deterministic keys, always.
Testing with Stripe CLI
The Stripe CLI is indispensable for local development. It lets you forward webhooks to your local server and trigger test events without going through the full payment flow.
Set up local webhook forwarding:
# Install and login
stripe login
# Forward webhooks to your local Route Handler
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# The CLI prints a webhook signing secret — use it in .env.local
# STRIPE_WEBHOOK_SECRET=whsec_...Trigger specific events for testing:
# Test a successful payment
stripe trigger payment_intent.succeeded
# Test a subscription lifecycle
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
# Test failed payment
stripe trigger invoice.payment_failedI also write integration tests that use Stripe's test mode:
// __tests__/stripe/checkout.test.ts
import { stripe } from "@/lib/stripe/server";
describe("Checkout Session", () => {
it("creates a valid checkout session", async () => {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "gbp",
product_data: { name: "Test Product" },
unit_amount: 1999,
},
quantity: 1,
},
],
success_url: "https://example.com/success",
cancel_url: "https://example.com/cancel",
});
expect(session.id).toBeDefined();
expect(session.url).toContain("checkout.stripe.com");
expect(session.payment_status).toBe("unpaid");
});
it("creates a subscription checkout with trial", async () => {
const product = await stripe.products.create({
name: "Weekly Grocery Box",
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 2999,
currency: "gbp",
recurring: { interval: "week" },
});
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: price.id, quantity: 1 }],
subscription_data: { trial_period_days: 7 },
success_url: "https://example.com/success",
cancel_url: "https://example.com/cancel",
});
expect(session.mode).toBe("subscription");
});
});Use test card numbers during development: 4242424242424242 for success, 4000000000000002 for decline, 4000002500003155 for 3D Secure required. These are burned into my muscle memory at this point.
Customer Portal
Stripe's Customer Portal lets subscribers manage their own billing — update payment methods, view invoices, change plans, cancel. Instead of building all of that UI yourself, you configure it in the Stripe Dashboard and redirect users to it.
// app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/server";
import { auth } from "@/lib/auth";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.stripeCustomerId) {
return NextResponse.json(
{ error: { code: "UNAUTHORIZED", message: "No billing account found" } },
{ status: 401 }
);
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: session.user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });
}Client-side:
// components/manage-billing-button.tsx
"use client";
export function ManageBillingButton() {
async function handleClick() {
const response = await fetch("/api/billing/portal", { method: "POST" });
const { url } = await response.json();
window.location.href = url;
}
return (
<button
onClick={handleClick}
className="rounded-lg border border-white/10 px-4 py-2 text-sm
text-white/70 transition-colors hover:border-[#F7931A]
hover:text-white"
>
Manage Billing
</button>
);
}Configure what customers can do in the portal through the Stripe Dashboard under Settings > Billing > Customer portal. I typically enable: update payment method, view invoice history, cancel subscription (with a cancellation reason survey), and switch between plans. The cancellation reason data is gold for product decisions — on FreshMart, it directly informed which box options we added.
Handling Failed Payments
Failed payments are inevitable. Cards expire, banks decline, insufficient funds. How you handle failures determines whether you recover that revenue or lose the customer. Stripe's Smart Retries handle the automatic retry schedule, but you need to communicate with the customer.
Build a dunning flow:
// lib/stripe/dunning.ts
import { stripe } from "./server";
import { db } from "@/lib/db";
import { sendEmail } from "@/lib/email";
export async function handleFailedPayment(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) return;
const userId = customer.metadata?.userId;
if (!userId) return;
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return;
const attemptCount = invoice.attempt_count ?? 1;
await db.user.update({
where: { id: userId },
data: { paymentFailedAt: new Date() },
});
if (attemptCount === 1) {
await sendEmail({
to: user.email,
template: "payment-failed-first",
data: {
name: user.name,
amount: ((invoice.amount_due ?? 0) / 100).toFixed(2),
updateUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing/update-card`,
},
});
}
if (attemptCount === 3) {
await sendEmail({
to: user.email,
template: "payment-failed-final",
data: {
name: user.name,
cancelDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0],
},
});
}
}Show a banner in the app when payment is past due:
// components/payment-alert.tsx
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function PaymentAlert() {
const session = await auth();
if (!session?.user) return null;
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { subscriptionStatus: true, paymentFailedAt: true },
});
if (user?.subscriptionStatus !== "past_due") return null;
return (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
<p className="text-sm text-red-400">
Your last payment failed. Please{" "}
<a
href="/billing/update-card"
className="font-semibold text-red-300 underline hover:text-white"
>
update your payment method
</a>{" "}
to keep your subscription active.
</p>
</div>
);
}On FreshMart, this dunning flow recovered about 40% of failed payments without any manual intervention. The first email gets the most recoveries — people update their card within hours. The key is making the update process frictionless: one click to the Stripe Customer Portal, update the card, done.
My Stripe Architecture
After integrating Stripe across FreshMart and several client projects, I've settled on an architecture that handles every scenario cleanly. Here's the full picture.
+------------------+ +-------------------+ +------------------+
| Next.js App | | Stripe API | | Stripe |
| | | | | Dashboard |
| Route Handlers |---->| Checkout | | |
| Server Actions |---->| Payment Intents | | Products |
| Client (Elements|---->| Subscriptions | | Prices |
| | | Customer Portal | | Webhooks Config |
+--------+---------+ +--------+----------+ +------------------+
| |
| Webhooks |
|<------------------------+
|
+--------v---------+
| Webhook Handler |
| |
| Verify Signature |
| Deduplicate |
| Route to Handler |
| Update Database |
| Send Emails |
+-------------------+The principles I follow:
- Never trust the client. The client initiates the flow, but the server creates the Checkout Session or Payment Intent. The client never sends amounts or prices — only price IDs that the server validates.
- Webhooks are the source of truth. Don't update order status based on the redirect. The redirect happens before the webhook, and the payment might still fail (3D Secure, bank processing). Wait for the webhook to confirm.
- Metadata everywhere. Every Stripe object I create gets
userIdin its metadata. When a webhook fires weeks later for a subscription renewal, I need to know which user it belongs to without making extra API calls.
- Idempotency is not optional. Every mutating API call gets an idempotency key. Every webhook handler checks for duplicate events. This isn't defensive programming — it's the minimum bar for handling money.
- Test with the CLI, not the browser. The Stripe CLI lets me trigger any event, inspect payloads, and replay failed webhooks. I test the full webhook flow locally before deploying. The browser checkout flow is for QA, not development.
- Customer Portal over custom UI. Unless you have very specific requirements, use Stripe's hosted Customer Portal. It handles PCI compliance, localization, and edge cases that take months to build yourself. On FreshMart, this saved us at least three weeks of development time.
- Log everything, alert on failures. Every webhook event gets logged with its type and processing result. Failed webhook handlers trigger alerts. You need to know immediately when a
checkout.session.completedhandler fails — that's a customer who paid but didn't get their order.
Key Takeaways
- Start with Stripe Checkout for one-time payments. It's PCI-compliant out of the box, supports dozens of payment methods, and takes an afternoon to integrate. Switch to Elements only when you need custom UI.
- Webhooks are your real API. The client-side redirect is a UX convenience. The webhook is where you actually fulfill orders, activate subscriptions, and update your database. Build your webhook handler first, then the client flow.
- Idempotency keys must be deterministic. Use a combination of the operation and entity ID (
pi_${orderId}_${amount}), not random UUIDs. Retries should produce the same result, not create duplicates.
- Failed payment recovery is revenue recovery. A simple email with a direct link to update the payment method recovers 30-40% of failed payments. Don't let failed subscriptions silently churn.
- Use the Stripe CLI for development. Forward webhooks locally, trigger test events, inspect payloads. It's faster than clicking through the checkout flow every time you change something.
- Metadata is your lifeline. Put
userIdon every Stripe object. When debugging a payment issue six months later, you'll thank yourself.
- Customer Portal saves weeks. Stripe's hosted portal handles billing management, plan changes, invoice history, and cancellation. Use it unless you have a compelling reason to build custom.
If you're building a payment system and want production-grade architecture from the start, check out my services. You can also see the FreshMart platform in action in my portfolio — the subscription billing system handles thousands of weekly deliveries across the UK.
*Uvin Vindula is a full-stack and Web3 engineer based between Sri Lanka and the UK. He builds production systems at iamuvin.com↗ and writes about the patterns that survive real traffic. Stripe integration is one of the most requested services across his client projects -- from e-commerce platforms to SaaS subscription billing. Reach him at contact@uvin.lk or @IAMUVIN↗ on X.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.