Use Cases (Updated: 6/2/2026)

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.

Claude Code Payment Integration: Stripe Checkout, Webhooks, 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 caseBilling modelFulfillmentFailure mode
Prompt pack, PDF course, template bundleOne-time paymentGrant download access and send a buyer emailIf the success page is the only delivery path, customers who close the browser can lose access
Small SaaS Pro planMonthly subscriptionGrant plan:pro and update billing status on later eventsIgnoring cancellation or failed invoice events can leave paid features open
Claude Code workshop depositOne-time payment or invoice workflowReserve a slot and notify operationsRefunds 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.

#Claude Code #Stripe #payments #webhooks #TypeScript
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.