Stripe Checkout and Webhooks in Next.js
Creating checkout sessions, handling webhook events, idempotency, and syncing subscription state to your database.
Stripe is the standard for web payments, but the webhook handling and state sync are where most bugs live. Here's the NodWatch implementation — download license purchases — with all the edge cases handled.
Setup
npm install stripe// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20",
});Creating a Checkout Session
// app/api/checkout/route.ts
import { stripe } from "@/lib/stripe";
import { getSession } from "@/lib/session";
export async function POST(req: Request) {
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
const { tmdbId, mediaType, title } = await req.json();
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
customer_email: session.user.email,
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: `Download: ${title}`,
description: `Permanent offline download for ${title}`,
},
unit_amount: 299, // $2.99
},
quantity: 1,
},
],
metadata: {
userId: session.user.id,
tmdbId: String(tmdbId),
mediaType,
},
success_url: `${process.env.NEXT_PUBLIC_URL}/downloads?success=1`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/movie/${tmdbId}?canceled=1`,
});
return Response.json({ url: checkoutSession.url });
}On the client:
async function handlePurchase() {
const res = await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ tmdbId, mediaType, title }),
});
const { url } = await res.json();
window.location.href = url; // Redirect to Stripe-hosted checkout
}Webhook Handler
Stripe sends events to your webhook endpoint after payment. Verify the signature — never trust unverified webhook payloads.
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { downloadLicenses } from "@/db/schema";
import Stripe from "stripe";
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, WEBHOOK_SECRET);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Only handle the events you care about
if (event.type === "checkout.session.completed") {
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
}
return new Response("OK");
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.payment_status !== "paid") return;
const { userId, tmdbId, mediaType } = session.metadata ?? {};
if (!userId || !tmdbId || !mediaType) return;
// Idempotent insert — use the Stripe session ID as the license ID
await db
.insert(downloadLicenses)
.values({
id: session.id,
userId,
tmdbId: parseInt(tmdbId),
mediaType,
stripeSessionId: session.id,
purchasedAt: new Date(),
expiresAt: null, // permanent license
})
.onConflictDoNothing(); // safe to replay
}Idempotency
Stripe can deliver webhooks multiple times. Your handler must be idempotent — processing the same event twice must produce the same result. The .onConflictDoNothing() on the insert ensures duplicate events don't create duplicate licenses.
For more complex operations, use the Stripe event ID as an idempotency key:
const processed = await db.query.stripeEvents.findFirst({
where: eq(stripeEvents.id, event.id),
});
if (processed) return; // already handled
await db.transaction(async (tx) => {
await tx.insert(stripeEvents).values({ id: event.id });
// ... do the actual work
});Local Testing with Stripe CLI
# Install Stripe CLI and authenticate
stripe login
# Forward webhook events to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, trigger a test event
stripe trigger checkout.session.completedThe CLI outputs the STRIPE_WEBHOOK_SECRET to use for local development.
Checking License Status
// lib/licenses.ts
export async function hasDownloadLicense(userId: string, tmdbId: number, mediaType: string) {
const license = await db.query.downloadLicenses.findFirst({
where: and(
eq(downloadLicenses.userId, userId),
eq(downloadLicenses.tmdbId, tmdbId),
eq(downloadLicenses.mediaType, mediaType),
),
});
if (!license) return false;
if (license.expiresAt && license.expiresAt < new Date()) return false;
return true;
}Call this before serving download URLs — never rely solely on client-side state for access control.
Refunds
export async function refundPurchase(stripeSessionId: string) {
const session = await stripe.checkout.sessions.retrieve(stripeSessionId);
if (!session.payment_intent) throw new Error("No payment intent");
await stripe.refunds.create({ payment_intent: String(session.payment_intent) });
// Revoke the license
await db.delete(downloadLicenses).where(eq(downloadLicenses.stripeSessionId, stripeSessionId));
}Handle charge.refunded webhooks from Stripe for the same idempotent cleanup.