Claude Code से Stripe Payment Integration: Checkout, Webhooks, Refunds और Tax Handoff
Claude Code से Stripe payments लागू करें: pricing, Checkout, Webhooks, idempotency, fulfillment, refunds और tax handoff.
किसी छोटे SaaS, paid content product या training offer में payment जोड़ना सिर्फ “Buy” button लगाने का काम नहीं है। आपको product/price table, Checkout Session API, Webhook signature verification, idempotent fulfillment, refund handling और tax/accounting handoff चाहिए। वरना Stripe में payment सफल दिखेगा, लेकिन app user को access नहीं देगा; या refund के बाद भी paid feature खुला रह जाएगा।
Claude Code इस काम को तेज कर सकता है, लेकिन केवल तब जब आप उसे साफ boundaries दें। “Stripe payments add karo” जैसा broad prompt अक्सर success page पर ही access देने वाला fragile code बना देता है। Revenue path के लिए Claude Code को छोटे tasks दें: server-side product table, Checkout route, raw body से Webhook signature verification, duplicate event handling, fulfillment, refunds और privacy-safe logs.
इस guide में Next.js App Router, Node.js, TypeScript और Stripe Checkout का use है। Webhook का अर्थ है provider से आपके server को आने वाली payment event notification। Fulfillment का अर्थ है payment के बाद खरीदी गई चीज देना: download, SaaS plan, seat, booking या training access। Idempotency का अर्थ है same operation दो बार चले, फिर भी result एक बार जैसा रहे।
Official docs पहले देखें
Provider-specific details के लिए official docs पर भरोसा करें। Checkout Session बनाने के लिए Checkout Session create API, payment के बाद delivery के लिए Checkout fulfillment, Webhook और signature के लिए Receive Stripe events, और retry safety के लिए Idempotent requests देखें।
Refunds के लिए Create a refund, tax collection के लिए Stripe Tax for Checkout, local testing के लिए Stripe CLI काम आता है। Next.js side पर Route Handlers देखें। Claude Code के लिए Claude Code overview official starting point है।
यदि आप बाद में Paddle, Lemon Squeezy, PayPal या किसी local provider पर जाएं, architecture फिर भी ऐसी ही रहेगी: price server तय करेगा, payment Webhook से confirm होगा, fulfillment idempotent होगा, और refund के बाद app access भी update होगा।
Monetization use cases
| Use case | Billing model | Fulfillment | Common failure |
|---|---|---|---|
| Prompt pack, PDF course, template bundle | One-time payment | Download access देना और buyer email भेजना | Success page ही delivery path हो तो browser बंद करने वाले user को access नहीं मिलता |
| छोटा SaaS Pro plan | Monthly subscription | plan:pro entitlement देना और later billing events से status update करना | Cancellation या failed invoice ignore करने से paid features खुले रह जाते हैं |
| Claude Code workshop deposit | One-time payment या invoice flow | Slot reserve करना और ops को notify करना | Refund के बाद slot release करना है या नहीं, rule unclear रहता है |
Masa ने एक content product prototype में पहली गलती Stripe API में नहीं, success page design में की थी। Test card से flow pass हो गया, लेकिन Webhook retry, browser close, delayed payment और refund जोड़ते ही design कमजोर निकला। Better Claude Code instruction था: “success page सिर्फ status दिखाए; access केवल Webhook-backed fulfillment function से grant हो।“
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"]
Success page authority नहीं है। वह केवल status दिखाती है। Webhook Stripe signature verify करता है, event ID store करता है और paid Checkout Session को एक shared fulfillment function में भेजता है। वही function duplicate processing से बचाता है।
Claude Code को ऐसा prompt दें:
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 और environment variables
Browser सिर्फ productKey भेजेगा। Price ID, mode और entitlement server-side table से आएंगे।
// 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;
}
Local में test mode values रखें। Secret keys को prompt में paste न करें।
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
Idempotency के लिए database model
Production payment state database में होना चाहिए। StripeEvent.id duplicate Webhook processing रोकता है। Entitlement वह access record है जिसे app paid feature दिखाते समय check करेगी।
// 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())
}
npx prisma db push
Checkout API
Stripe SDK और Webhook verification को Node.js runtime पर रखें। Route का काम है user verify करना, internal order बनाना, Checkout Session बनाना, Session ID save करना और URL return करना।
// 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 और tax_id_collection tax data collect करने में मदद करते हैं, लेकिन accounting review का replacement नहीं हैं। Tax registration, invoice wording और refund accounting business और jurisdiction पर निर्भर करते हैं।
Pricing table component
UI amount या Price ID नहीं भेजती। Display price marketing copy है; real price Stripe Price से आता है।
// src/components/PricingTable.tsx
"use client";
import { useState } from "react";
const cards = [
{
productKey: "promptPack",
name: "Prompt Pack",
price: "₹3,299",
description: "Templates और learning material के लिए one-time payment",
},
{
productKey: "proMonthly",
name: "Pro",
price: "₹2,499/month",
description: "छोटे SaaS के लिए monthly plan",
},
{
productKey: "workshopDeposit",
name: "Workshop Deposit",
price: "₹8,000",
description: "Training या consulting booking deposit",
},
];
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>
);
}
Button disable करना UX safety है। Payment safety server-side product table, Stripe idempotency key, unique Webhook event ID और database constraints से आती है।
Webhook verification और fulfillment
Verification से पहले await req.json() मत चलाएं। Stripe को unmodified raw 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 };
});
}
सबसे जरूरी बात है कि paid access grant करने की जगह एक ही रहे। Webhook, repair job या status page उसी function को call कर सकते हैं।
Refunds और access reversal
Refund सिर्फ Stripe API call नहीं है। Product को तय करना होगा कि access revoke होगा, partial refund में access रहेगा, या booking slot release होगा।
// 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 });
}
Refund policy unclear हो तो Claude Code को decision लेने न दें। “Partial refund access रखेगा” या “full refund access हटाएगा” product और operations decision है।
Local testing
Stripe CLI से local route पर events forward करें।
npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
CLI में दिखा whsec_... STRIPE_WEBHOOK_SECRET में डालें, Next.js restart करें और test mode Checkout complete करें। Stripe test card 4242 4242 4242 4242, future expiry, कोई भी CVC और कोई भी postal code basic success path के लिए ठीक है। Declines और authentication flows के लिए Testing देखें।
चार चीजें verify करें: Checkout route internal order बनाता है; Webhook को checkout.session.completed मिलता है; order FULFILLED होता है और Entitlement सिर्फ एक बनता है; same event replay होने पर दूसरा access नहीं बनता।
Claude Code review prompt
Implementation के बाद Claude Code से critical review कराएं।
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 code लिख सकता है, लेकिन billing behavior का final owner नहीं है। API keys, Dashboard settings, tax registration, terms, refund policy और private logs human review में ही तय होंगे।
Summary और CTA
Payment integration button से नहीं, revenue lifecycle से design करें। Pricing table productKey भेजे, server Checkout Session बनाए, Webhook signature verify करे, और एक idempotent fulfillment function access दे। Refund और tax handoff पहले design में शामिल करें।
Checkout पर focused guide के लिए Claude Code Stripe Checkout पढ़ें। Secrets के लिए environment management और events के लिए Webhook implementation देखें।
ClaudeCodeLab payment implementation review, Claude Code team training और SaaS/content monetization design में मदद करता है। Hindi dedicated page न होने पर भी आप English consulting page से शुरुआत कर सकते हैं, और templates/checklists product library में मिलेंगे।
मैंने इस flow को local Next.js project में Stripe test mode, Webhook forwarding, duplicate event और full refund के साथ verify किया। सबसे बड़ा सुधार UI से पहले Order और Entitlement model बनाना था। State model साफ होने पर Claude Code का diff और human review दोनों ज्यादा भरोसेमंद हो जाते हैं।
मुफ़्त PDF: Claude Code cheatsheet
Email डालें और commands, review habits तथा safe workflow वाली एक-page PDF पाएँ.
हम आपका data सुरक्षित रखते हैं और spam नहीं भेजते.
लेखक के बारे में
Masa
Claude Code workflow और team adoption पर काम करने वाला engineer.
संबंधित लेख
Claude Code Obsidian to CLAUDE.md workflow: context बार-बार न समझाएं
Obsidian notes को CLAUDE.md operating notes में बदलकर Claude Code sessions को resume करना आसान बनाएं.
Claude Code Revenue CTA Routing: article से PDF, Gumroad और consultation तक
Reader intent के आधार पर free PDF, Gumroad products और consultation तक CTA route करने वाला workflow.
Claude Code टीम हैंडऑफ नियम: review proof, permissions, rollback और revenue path
Claude Code टीम काम के लिए evidence, permission rules, rollback, free PDF, Gumroad और consultation path वाला handoff.