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.
/api/payments/webhook route that listens for checkout.session.completedpayments table in Drizzle ORM that records every completed purchaserequirePayment() service helper that gates any feature behind a paid checkAdd 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.
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.
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.
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.
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.
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.
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.