Claude Code Payment Integration: Stripe Checkout, Webhooks, Refunds, and Tax Handoff
Build Stripe payments with Claude Code: pricing, Checkout, webhooks, idempotency, fulfillment, refunds, and tax handoff.
For a small SaaS or paid content product, payment integration is not just a button on a pricing page. You need a product table, a Checkout Session API, webhook verification, idempotent fulfillment, refunds, and a clean handoff to tax and accounting. Without that flow, Stripe can show revenue while your app still thinks the customer has no access, or a refund can go through while the paid entitlement remains active.
Claude Code can speed this up, but only when you give it narrow boundaries. A prompt like “add Stripe payments” often produces a nice-looking checkout button and a fragile success-page handler. For revenue code, ask Claude Code to implement small slices and then review the diff critically: server-side price lookup, raw-body webhook signature verification, duplicate event handling, fulfillment, refunds, and privacy-safe logs.
This guide uses Next.js App Router, Node.js, TypeScript, and Stripe Checkout. Webhook means a server-to-server event Stripe sends to your app. Fulfillment means giving the customer what they paid for: a download, a SaaS plan, a seat, a booking, or training access. Idempotency means the same operation can run twice without granting access twice.
Official Docs to Keep Open
Use official docs for provider-specific details. For Stripe, check the Checkout Session create API, Checkout fulfillment, Receive Stripe events, and Idempotent requests. Refund behavior belongs in the Create a refund API, tax collection belongs in Stripe Tax for Checkout, and local webhook testing belongs in the Stripe CLI docs.
For the framework side, use the current Next.js Route Handlers reference. For the tool itself, Anthropic’s Claude Code overview explains how Claude Code works in the terminal, IDE, desktop app, and web.
The same architecture also applies if you later move to Paddle, Lemon Squeezy, PayPal, or a local payment provider. The provider changes, but the guardrails stay the same: never trust client-side prices, confirm payment through webhooks, make fulfillment idempotent, and keep refund state aligned with product access.
Monetization Use Cases
| Use case | Billing model | Fulfillment | Failure mode |
|---|---|---|---|
| Prompt pack, PDF course, template bundle | One-time payment | Grant download access and send a buyer email | If the success page is the only delivery path, customers who close the browser can lose access |
| Small SaaS Pro plan | Monthly subscription | Grant plan:pro and update billing status on later events | Ignoring cancellation or failed invoice events can leave paid features open |
| Claude Code workshop deposit | One-time payment or invoice workflow | Reserve a slot and notify operations | Refunds need a clear rule for whether the reservation is released |
In one small content-product prototype, Masa’s first mistake was treating the success page as payment truth. It worked with a happy-path test card, but it failed the real review: webhook retries, browser exits, delayed payment methods, and refunds all needed one server-side fulfillment path. The safer instruction to Claude Code was: “the success page may display status, but only webhook-backed fulfillment can grant access.”
Architecture
flowchart LR
Buyer["Buyer"] --> Pricing["Pricing table"]
Pricing --> CheckoutApi["/api/checkout"]
CheckoutApi --> Order["Order table"]
CheckoutApi --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Stripe hosted checkout"]
Hosted --> Webhook["/api/webhooks/stripe"]
Webhook --> Fulfill["fulfillCheckoutSession"]
Fulfill --> Entitlement["Entitlement table"]
Hosted --> Success["Success page"]
Success --> ReadOnly["Read order status"]
The success page is a confirmation screen, not the authority that grants access. The webhook verifies the provider signature, records the event ID, and calls a single fulfillment function. That fulfillment function can safely run more than once for the same Checkout Session.
Use a Claude Code prompt with the boundary stated up front:
Implement Stripe Checkout in a Next.js App Router app.
Do not grant access from the success page alone.
Verify the Stripe webhook with the raw request body.
Make fulfillment safe if the same Checkout Session is processed twice.
Do not print secrets or read .env values into chat.
Before editing, explain the target files and design.
Product Table and Environment
The browser should send only a productKey. Price IDs, modes, and entitlement keys stay on the server.
// src/lib/billing/catalog.ts
export const BILLING_PRODUCTS = {
promptPack: {
label: "Claude Code Prompt Pack",
mode: "payment",
entitlement: "download:prompt-pack",
priceEnv: "STRIPE_PRICE_PROMPT_PACK",
},
proMonthly: {
label: "SaaS Pro Monthly",
mode: "subscription",
entitlement: "plan:pro",
priceEnv: "STRIPE_PRICE_PRO_MONTHLY",
},
workshopDeposit: {
label: "Claude Code Workshop Deposit",
mode: "payment",
entitlement: "booking:workshop-deposit",
priceEnv: "STRIPE_PRICE_WORKSHOP_DEPOSIT",
},
} as const;
export type ProductKey = keyof typeof BILLING_PRODUCTS;
export function getBillingProduct(productKey: string) {
if (productKey in BILLING_PRODUCTS) {
return {
key: productKey as ProductKey,
...BILLING_PRODUCTS[productKey as ProductKey],
};
}
throw new Error(`Unknown productKey: ${productKey}`);
}
export function getPriceId(priceEnv: string) {
const priceId = process.env[priceEnv];
if (!priceId) throw new Error(`Missing environment variable: ${priceEnv}`);
return priceId;
}
Start in test mode. Do not paste secret keys into Claude Code prompts.
APP_URL=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PROMPT_PACK=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO_MONTHLY=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_WORKSHOP_DEPOSIT=price_xxxxxxxxxxxxxxxxx
Database Shape for Idempotency
Production payment state belongs in a database. StripeEvent.id prevents duplicate webhook processing. Entitlement is the access record your app checks when showing paid features.
// prisma/schema.prisma
model Order {
id String @id @default(cuid())
userId String
productKey String
entitlement String
status String @default("PENDING")
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String?
stripeSubscriptionId String?
stripeRefundId String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Entitlement {
id String @id @default(cuid())
userId String
key String
active Boolean @default(true)
source String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, key])
}
model StripeEvent {
id String @id
type String
createdAt DateTime @default(now())
}
Apply it locally:
npx prisma db push
Stripe Client and Checkout API
Stripe SDK calls and webhook verification should run in the Node.js runtime. Keep the API route focused: authenticate, create an internal order, create a Checkout Session, save the Session ID, and return the URL.
// src/lib/billing/stripe.ts
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is required");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function appUrl(path: string) {
const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
return `${baseUrl.replace(/\/$/, "")}${path}`;
}
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getBillingProduct, getPriceId } from "@/lib/billing/catalog";
import { appUrl, stripe } from "@/lib/billing/stripe";
import { requireUser } from "@/lib/auth";
export const runtime = "nodejs";
const checkoutSchema = z.object({
productKey: z.string().min(1),
});
export async function POST(req: NextRequest) {
const user = await requireUser();
const { productKey } = checkoutSchema.parse(await req.json());
const product = getBillingProduct(productKey);
const order = await prisma.order.create({
data: {
userId: user.id,
productKey: product.key,
entitlement: product.entitlement,
},
});
const session = await stripe.checkout.sessions.create(
{
mode: product.mode,
customer_email: user.email,
line_items: [{ price: getPriceId(product.priceEnv), quantity: 1 }],
client_reference_id: order.id,
metadata: {
order_id: order.id,
product_key: product.key,
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
success_url: appUrl("/checkout/success?session_id={CHECKOUT_SESSION_ID}"),
cancel_url: appUrl("/pricing"),
},
{
idempotencyKey: `checkout:${order.id}`,
}
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
if (!session.url) {
return NextResponse.json({ error: "Checkout URL was not created" }, { status: 500 });
}
return NextResponse.json({ url: session.url });
}
automatic_tax and tax_id_collection help collect the data needed for tax handling, but they are not a substitute for accounting review. Registration obligations, invoice wording, tax rates, and refund accounting depend on your business and jurisdictions.
Pricing Table Component
The UI sends a productKey; it does not send an amount or a Stripe Price ID. Display prices are marketing copy. The server still decides the real Stripe Price.
// src/components/PricingTable.tsx
"use client";
import { useState } from "react";
const cards = [
{
productKey: "promptPack",
name: "Prompt Pack",
price: "$39",
description: "One-time payment for templates and learning material",
},
{
productKey: "proMonthly",
name: "Pro",
price: "$29/month",
description: "Monthly plan for a small SaaS product",
},
{
productKey: "workshopDeposit",
name: "Workshop Deposit",
price: "$100",
description: "Deposit for training or consulting",
},
];
export function PricingTable() {
const [loadingKey, setLoadingKey] = useState<string | null>(null);
async function startCheckout(productKey: string) {
setLoadingKey(productKey);
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productKey }),
});
const data = await res.json();
if (!res.ok) {
setLoadingKey(null);
alert(data.error ?? "Checkout failed");
return;
}
window.location.assign(data.url);
}
return (
<div className="grid gap-4 md:grid-cols-3">
{cards.map((card) => (
<section key={card.productKey} className="rounded-lg border p-5">
<h3 className="text-lg font-semibold">{card.name}</h3>
<p className="mt-2 text-2xl font-bold">{card.price}</p>
<p className="mt-2 text-sm text-gray-600">{card.description}</p>
<button
className="mt-4 rounded bg-black px-4 py-2 text-white disabled:opacity-60"
disabled={loadingKey === card.productKey}
onClick={() => startCheckout(card.productKey)}
>
{loadingKey === card.productKey ? "Redirecting..." : "Continue to Checkout"}
</button>
</section>
))}
</div>
);
}
Disabling the button prevents noisy double-clicks, but it is not your main safety layer. The real protection is the server-side product table, Stripe idempotency key, unique webhook event IDs, and database constraints.
Webhook Verification and Fulfillment
Do not call await req.json() before verifying the webhook. Stripe signature verification needs the unmodified raw request body.
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { fulfillCheckoutSession } from "@/lib/billing/fulfillment";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const payload = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
return NextResponse.json({ error: "Missing webhook signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
try {
await prisma.stripeEvent.create({ data: { id: event.id, type: event.type } });
} catch {
return NextResponse.json({ received: true, duplicate: true });
}
if (
event.type === "checkout.session.completed" ||
event.type === "checkout.session.async_payment_succeeded"
) {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillCheckoutSession(session.id);
}
if (event.type === "checkout.session.async_payment_failed") {
const session = event.data.object as Stripe.Checkout.Session;
await prisma.order.updateMany({
where: { stripeCheckoutSessionId: session.id, status: "PENDING" },
data: { status: "CANCELED" },
});
}
return NextResponse.json({ received: true });
}
// src/lib/billing/fulfillment.ts
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
function stripeObjectId(value: string | { id: string } | null | undefined) {
if (!value) return undefined;
return typeof value === "string" ? value : value.id;
}
export async function fulfillCheckoutSession(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["payment_intent", "subscription"],
});
if (session.payment_status !== "paid") {
return { status: "waiting_for_payment" as const };
}
const orderId = session.metadata?.order_id;
if (!orderId) throw new Error(`Missing order_id metadata for ${session.id}`);
return prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error(`Order not found: ${orderId}`);
if (order.status === "FULFILLED") {
return { status: "already_fulfilled" as const, order };
}
const updatedOrder = await tx.order.update({
where: { id: order.id },
data: {
status: "FULFILLED",
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: stripeObjectId(session.payment_intent),
stripeSubscriptionId: stripeObjectId(session.subscription),
fulfilledAt: new Date(),
},
});
await tx.entitlement.upsert({
where: {
userId_key: {
userId: order.userId,
key: order.entitlement,
},
},
update: { active: true, source: `stripe:${session.id}` },
create: {
userId: order.userId,
key: order.entitlement,
active: true,
source: `stripe:${session.id}`,
},
});
return { status: "fulfilled" as const, order: updatedOrder };
});
}
The important design point is that there is only one place where paid access is granted. You can call it from a webhook, a reconciliation job, or a status repair tool, and the result stays stable.
Refunds and Access Reversal
A refund is not just a Stripe API call. Your app must decide whether to revoke access, keep access for partial refunds, release a booking slot, or notify an operator. The route below assumes an admin-only full or partial refund and deactivates the entitlement after the refund request is created.
// src/app/api/admin/refunds/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { requireAdmin } from "@/lib/auth";
export const runtime = "nodejs";
const refundSchema = z.object({
orderId: z.string().min(1),
amount: z.number().int().positive().optional(),
});
export async function POST(req: NextRequest) {
await requireAdmin();
const { orderId, amount } = refundSchema.parse(await req.json());
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order?.stripePaymentIntentId) {
return NextResponse.json({ error: "Refundable payment not found" }, { status: 404 });
}
const refund = await stripe.refunds.create(
{
payment_intent: order.stripePaymentIntentId,
amount,
metadata: { order_id: order.id },
},
{
idempotencyKey: `refund:${order.id}:${amount ?? "full"}`,
}
);
await prisma.$transaction([
prisma.order.update({
where: { id: order.id },
data: { status: "REFUNDED", stripeRefundId: refund.id },
}),
prisma.entitlement.updateMany({
where: { userId: order.userId, key: order.entitlement },
data: { active: false },
}),
]);
return NextResponse.json({ refundId: refund.id, status: refund.status });
}
Ask Claude Code to stop and ask questions before it invents refund policy. “Partial refund keeps access” and “full refund revokes access” are product decisions, not SDK details.
Local Testing
Use the Stripe CLI to forward events to your local route.
npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Copy the displayed whsec_... into STRIPE_WEBHOOK_SECRET, restart Next.js, and complete Checkout in test mode. Stripe’s test card 4242 4242 4242 4242 with a future expiry, any CVC, and any postal code covers the basic success path. For declines, authentication flows, and more payment methods, use Stripe’s Testing page.
Verify four things: the Checkout route creates an internal order, the webhook receives checkout.session.completed, the order becomes FULFILLED, and replaying the same event does not create a second entitlement.
Claude Code Review Prompt
After implementation, switch Claude Code into review mode:
Review this payment diff critically.
Check these risks:
- Access is not granted from the success page alone.
- The webhook verifies the raw body before JSON parsing.
- Stripe Event ID and Checkout Session ID prevent duplicate processing.
- The browser cannot choose price IDs or amounts.
- Metadata does not contain email, address, card data, or other sensitive data.
- Refunds keep Order and Entitlement state consistent.
- Tax, receipts, and cancellation policy are handed off to operations.
Claude Code can draft the implementation, but it is not the final owner of billing behavior. Humans still own API keys, Dashboard configuration, tax registrations, terms, refund policy, and privacy review.
Summary and CTA
Build payment integration from the revenue lifecycle, not from the button. The pricing table sends a product key, the server creates the Checkout Session, the webhook verifies Stripe’s signature, and one idempotent fulfillment function grants access. Refunds and tax handoff belong in the first design, not in a later cleanup.
For a narrower Checkout walkthrough, read Implement Stripe Checkout with Claude Code. For secrets, pair it with Claude Code environment management, and for event handling, see Claude Code webhook implementation.
ClaudeCodeLab helps teams review payment implementations, train developers on Claude Code workflows, and design monetization paths for SaaS and content products. Start with the consulting page for implementation help or the product library for templates and checklists.
I tested this flow in a local Next.js project with Stripe test mode, webhook forwarding, duplicate event handling, and a full refund. The biggest improvement came from modeling Order and Entitlement before polishing the UI. Once the state model was clear, both Claude Code’s diff and the human review became much easier to trust.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.