Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Stripe one-time payments in Next.js -- checkout, webhooks, and order tracking with Drizzle ORM

June 27, 2026
stripenextjsdrizzle-ormsaaspayments

If you are selling a digital product, a lifetime deal, or a one-time add-on, Stripe subscriptions are the wrong tool. You need a single checkout session, a webhook that fires once, and a row in your database that says "this user paid." Claude Code Boilerplate ships with Stripe already wired in -- here is how to add one-time payments in an afternoon.

What you are building

  • A Stripe Checkout session for a fixed-price product
  • A /api/payments/webhook route that listens for checkout.session.completed
  • A payments table in Drizzle ORM that records every completed purchase
  • A requirePayment() service helper that gates any feature behind a paid check

1. Schema

Add a payments table to track purchases:

// modules/payment/payment.schema.ts
import { pgTable, text, integer, timestamp, uuid } from 'drizzle-orm/pg-core';
import { userTable } from '../user/user.schema';
 
export const paymentTable = pgTable('payments', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id')
    .references(() => userTable.id, { onDelete: 'cascade' })
    .notNull(),
  stripeSessionId: text('stripe_session_id').notNull().unique(),
  amountCents: integer('amount_cents').notNull(),
  currency: text('currency').notNull().default('usd'),
  status: text('status').notNull().default('pending'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

Run npm run db:generate && npm run db:migrate to apply it. The stripeSessionId unique constraint prevents double-recording if Stripe delivers the webhook more than once.

2. Checkout route

A thin POST route creates the Stripe session and returns the redirect URL:

// app/api/payments/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getUserFromRequest } from '@/lib/auth';
import { handleError } from '@/lib/errors';
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function POST(req: NextRequest) {
  try {
    const user = await getUserFromRequest(req);
    const session = await stripe.checkout.sessions.create({
      mode: 'payment',
      customer_email: user.email,
      client_reference_id: user.id,
      line_items: [{ price: process.env.NEXT_PUBLIC_STRIPE_PRICE_ONE_TIME!, quantity: 1 }],
      success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
    });
    return NextResponse.json({ url: session.url });
  } catch (error: unknown) {
    return handleError(error);
  }
}

The client_reference_id is how you link the Stripe session back to your user when the webhook fires. Without it you have no way to know who paid.

3. Webhook handler

This is where the purchase actually gets recorded. Stripe calls this route after the card is charged:

// app/api/payments/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { paymentService } from '@/modules/payment';
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature')!;
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }
 
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    await paymentService.recordPayment({
      userId: session.client_reference_id!,
      stripeSessionId: session.id,
      amountCents: session.amount_total ?? 0,
      currency: session.currency ?? 'usd',
    });
  }
 
  return NextResponse.json({ received: true });
}

Always verify the signature before touching your database. A raw POST body without signature verification lets anyone fake a successful payment.

4. Service layer

The service records the purchase and exposes a requirePayment() guard:

// modules/payment/payment.service.ts
import { paymentRepo } from './payment.repo';
import { HttpError } from '@/lib/errors';
 
export const paymentService = {
  async recordPayment(data: {
    userId: string;
    stripeSessionId: string;
    amountCents: number;
    currency: string;
  }) {
    return paymentRepo.create({ ...data, status: 'completed' });
  },
 
  async hasUserPaid(userId: string): Promise<boolean> {
    const row = await paymentRepo.findCompletedByUserId(userId);
    return row !== null;
  },
 
  async requirePayment(userId: string): Promise<void> {
    const paid = await this.hasUserPaid(userId);
    if (!paid) throw new HttpError(402, 'Payment required');
  },
};

Any service method locked behind a purchase calls await paymentService.requirePayment(userId) as its first line. The route catches the HttpError(402) via handleError and returns the right status to the client automatically.

5. Buy button

On the client, a button hits the checkout route and redirects to Stripe:

async function handleBuy() {
  const res = await fetch('/api/payments/checkout', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
  });
  const { url } = await res.json();
  window.location.href = url;
}

No local state to manage -- the session lives on Stripe's side until the user completes payment.

Testing locally

Use the Stripe CLI to forward webhooks to your dev server:

stripe listen --forward-to localhost:3000/api/payments/webhook

It prints a whsec_... secret -- set that as STRIPE_WEBHOOK_SECRET in .env while developing. The production secret comes from the Stripe dashboard under Webhooks.

What this unlocks

With hasUserPaid() in your service layer you can gate a download, offer a lifetime deal alongside a monthly plan, or record purchase history for support -- all using the same patterns already in the codebase.

The pattern is the same whether you are selling a $9 template pack or a $499 lifetime license. Clone Claude Code Boilerplate, wire in your Stripe price ID, and have a working one-time payment flow running before lunch.