Back to Blog
Jun 23, 20264 min readOnuzulike Anthony

Stripe Checkout and Webhooks in Next.js

Creating checkout sessions, handling webhook events, idempotency, and syncing subscription state to your database.

Full-StackStripePaymentsNext.js

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

bash
npm install stripe
ts
// 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

ts
// 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:

tsx
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.

ts
// 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:

ts
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

bash
# 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.completed

The CLI outputs the STRIPE_WEBHOOK_SECRET to use for local development.

Checking License Status

ts
// 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

ts
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.