Claude Code से Stripe Checkout लागू करें: Sessions, Webhooks और Idempotency
Claude Code से Stripe Checkout सुरक्षित लागू करें: hosted checkout, webhooks, metadata, idempotency, test mode और recovery checks.
Stripe Checkout का मतलब यह नहीं है कि payment integration पूरी तरह खत्म हो गई। यह card form, validation और hosted payment page को Stripe पर ले जाता है, लेकिन order state, access grant, webhook fulfillment, refund और cancellation की जिम्मेदारी अभी भी आपकी application पर रहती है।
Claude Code इस काम में तभी उपयोगी है जब आप उसे छोटे, reviewable tasks दें। “Stripe payment बना दो” कहने के बजाय पहले order schema, फिर environment variable validation, फिर Checkout Route Handler, फिर webhook signature verification, फिर idempotent fulfillment और अंत में security review कराएं।
यह लेख Next.js App Router और Node TypeScript पर आधारित है। code secret key को source में hard-code नहीं करता, पहले internal order बनाता है, फिर Stripe Checkout Session बनाता है, और payment complete होने पर एक idempotent server function से product या subscription access देता है।
Stripe का official flow
Stripe की Checkout Sessions API hosted Checkout, embedded Checkout और custom flows की backend API है। ज्यादातर SaaS, digital products और training payments के लिए hosted checkout सबसे सुरक्षित starting point है क्योंकि payment form, authentication, error handling और payment method UI Stripe संभालता है।
How Checkout works में flow यह है: application Checkout Session बनाती है, user Stripe hosted page पर payment करता है, और checkout.session.completed जैसा webhook event fulfillment trigger करता है। fulfillment का मतलब है payment के बाद खरीदी गई चीज देना, जैसे download access, SaaS plan, workshop seat, shipping task या booking confirmation।
Stripe की Fulfill orders guide साफ कहती है कि webhook जरूरी है और fulfillment function एक ही Checkout Session के लिए कई बार call हो सकता है। Checkout Sessions API reference भी बताती है कि Session एक payment attempt को represent करती है और success के बाद Customer, PaymentIntent या Subscription से जुड़ती है।
पूरी payment architecture के लिए Claude Code payment integration, Claude Code security best practices और Claude Code env management भी देखें।
Architecture
flowchart LR
Buyer["Buyer"] --> App["Next.js app"]
App --> CheckoutRoute["/api/checkout"]
CheckoutRoute --> Orders["Order table"]
CheckoutRoute --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Hosted Checkout"]
Hosted --> Webhook["/api/stripe/webhook"]
Webhook --> Fulfill["fulfillCheckout"]
Hosted --> Success["/checkout/success"]
Success --> Fulfill
Fulfill --> Orders
Checkout route internal order create या reuse करती है और session.url return करती है। Webhook route raw body से Stripe signature verify करती है और paid events को fulfillment में भेजती है। Success page भी वही function call कर सकता है, लेकिन webhook का replacement नहीं है।
Environment और database
npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init
Local development में सिर्फ test mode values रखें। असली sk_test_, sk_live_ या whsec_ values Claude Code prompt में paste न करें।
APP_URL=http://localhost:3000
DATABASE_URL=postgresql://user:password@localhost:5432/checkout_dev
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_EBOOK=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO=price_xxxxxxxxxxxxxxxxxxxxx
Checkout Session से पहले internal order बनाएं। इससे आप client_reference_id और metadata.order_id में stable order ID रख सकते हैं।
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Order {
id String @id @default(cuid())
cartId String @unique
userId String
email String?
productKey String
quantity Int @default(1)
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
stripeSubscriptionId String? @unique
amountTotal Int?
currency String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs FulfillmentLog[]
}
model FulfillmentLog {
id String @id @default(cuid())
orderId String
stripeCheckoutSessionId String
event String
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id])
@@unique([orderId, event])
@@index([stripeCheckoutSessionId])
}
enum OrderStatus {
PENDING
PAID
FULFILLED
CANCELED
REFUNDED
FAILED
}
Server utilities
// src/lib/env.ts
import { z } from "zod";
const envSchema = z.object({
APP_URL: z.string().url(),
DATABASE_URL: z.string().min(1),
STRIPE_SECRET_KEY: z.string().regex(/^sk_(test|live)_/),
STRIPE_WEBHOOK_SECRET: z.string().regex(/^whsec_/),
STRIPE_PRICE_EBOOK: z.string().startsWith("price_"),
STRIPE_PRICE_PRO: z.string().startsWith("price_"),
});
export const env = envSchema.parse(process.env);
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// src/lib/stripe.ts
import Stripe from "stripe";
import { env } from "@/lib/env";
export const stripe = new Stripe(env.STRIPE_SECRET_KEY);
Checkout Session बनाना
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
const checkoutRequestSchema = z.object({
cartId: z.string().min(8),
userId: z.string().min(1),
email: z.string().email().optional(),
productKey: z.enum(["ebook", "pro"]),
});
const priceByProduct = {
ebook: env.STRIPE_PRICE_EBOOK,
pro: env.STRIPE_PRICE_PRO,
} as const;
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = checkoutRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid checkout request" }, { status: 400 });
}
const { cartId, userId, email, productKey } = parsed.data;
const mode: Stripe.Checkout.SessionCreateParams.Mode =
productKey === "pro" ? "subscription" : "payment";
const order = await prisma.order.upsert({
where: { cartId },
update: {},
create: { cartId, userId, email, productKey, quantity: 1, status: "PENDING" },
});
if (order.stripeCheckoutSessionId) {
const existing = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (existing.status === "open" && existing.url) {
return NextResponse.json({ url: existing.url, orderId: order.id });
}
return NextResponse.json(
{ error: "Checkout session is not open. Create a new cart." },
{ status: 409 },
);
}
const metadata = {
order_id: order.id,
cart_id: cartId,
product_key: productKey,
user_id: userId,
};
const session = await stripe.checkout.sessions.create(
{
mode,
line_items: [{ price: priceByProduct[productKey], quantity: 1 }],
customer_email: email,
success_url: `${env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/pricing?checkout=cancelled&order_id=${order.id}`,
client_reference_id: order.id,
metadata,
allow_promotion_codes: true,
payment_intent_data: mode === "payment" ? { metadata } : undefined,
subscription_data: mode === "subscription" ? { metadata } : undefined,
},
{ idempotencyKey: `checkout:${order.id}:v1` },
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
return NextResponse.json({ url: session.url, orderId: order.id });
}
Metadata में सिर्फ internal IDs रखें। email, address या card details न रखें। idempotencyKey Session creation retry के लिए है, webhook fulfillment की duplicate execution रोकने के लिए database guard जरूरी है।
Idempotent fulfillment और webhook
// src/lib/checkout-fulfillment.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
function stripeObjectId(
value: string | Stripe.PaymentIntent | Stripe.Subscription | null,
) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent", "subscription"],
});
if (session.payment_status === "unpaid") {
return { status: "waiting_for_payment" as const, sessionId };
}
const orderId = session.metadata?.order_id ?? session.client_reference_id;
if (!orderId) throw new Error(`Checkout Session ${session.id} has no order_id`);
return prisma.$transaction(async (tx) => {
const updated = await tx.order.updateMany({
where: { id: orderId, fulfilledAt: null },
data: {
status: "FULFILLED",
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: stripeObjectId(session.payment_intent),
stripeSubscriptionId: stripeObjectId(session.subscription),
amountTotal: session.amount_total,
currency: session.currency,
fulfilledAt: new Date(),
},
});
if (updated.count === 0) return { status: "already_fulfilled" as const, orderId };
await tx.fulfillmentLog.create({
data: { orderId, stripeCheckoutSessionId: session.id, event: "checkout.fulfilled" },
});
return { status: "fulfilled" as const, orderId };
});
}
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
import { fulfillCheckout } from "@/lib/checkout-fulfillment";
export const runtime = "nodejs";
function idFromStripeObject(value: string | { id: string } | null | undefined) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
async function markSession(sessionId: string, status: "CANCELED" | "FAILED") {
await prisma.order.updateMany({
where: { stripeCheckoutSessionId: sessionId, fulfilledAt: null },
data: { status },
});
}
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ error: `Webhook signature verification failed: ${message}` },
{ status: 400 },
);
}
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
await fulfillCheckout((event.data.object as Stripe.Checkout.Session).id);
break;
case "checkout.session.async_payment_failed":
await markSession((event.data.object as Stripe.Checkout.Session).id, "FAILED");
break;
case "checkout.session.expired":
await markSession((event.data.object as Stripe.Checkout.Session).id, "CANCELED");
break;
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
const paymentIntentId = idFromStripeObject(charge.payment_intent);
if (paymentIntentId) {
await prisma.order.updateMany({
where: { stripePaymentIntentId: paymentIntentId },
data: { status: "REFUNDED" },
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await prisma.order.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: { status: "CANCELED" },
});
break;
}
}
return NextResponse.json({ received: true });
}
Webhook में raw body जरूरी है। अगर Claude Code इसे signature verification से पहले request.json() में बदल दे, तो इसे security bug मानें।
Test mode, use cases और pitfalls
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
CLI से मिला whsec_ STRIPE_WEBHOOK_SECRET में डालें, Next.js restart करें और test card 4242 4242 4242 4242 से payment करें। Stripe Dashboard के साथ database भी देखें: Order.status FULFILLED होना चाहिए, Stripe IDs save होने चाहिए, और success page refresh करने पर दूसरा fulfillment log नहीं बनना चाहिए।
| Use case | Checkout mode | Fulfillment | Risk |
|---|---|---|---|
| PDF या template | payment | Download access देना | Success page पर ही file न दें |
| SaaS Pro plan | subscription | Plan और seats activate करना | Subscription deletion handle करें |
| Training या consultation deposit | payment | Slot reserve करना | Refund से slot release होगा या नहीं तय करें |
Common failures: सिर्फ success_url पर access देना, metadata.order_id भूलना, Stripe idempotencyKey को webhook idempotency समझ लेना, test और live objects mix करना, और refund के बाद app permissions update न करना।
Claude Code review prompt छोटा रखें।
Review only the Stripe Checkout implementation.
Check env-only secrets, metadata order_id without PII, raw-body webhook signature verification,
idempotent fulfillment, test-mode setup, refund and cancellation handling.
Return concrete file and line references.
असल में try करते समय confirmation points
Publish से पहले test-mode payment करें, webhook log देखें, order table inspect करें, success page reload करें, Session expire करें, refund करें और test subscription cancel करें। अगर वही Checkout Session कई बार process होने पर भी access सिर्फ एक बार मिलता है, तो production review के लिए आधार तैयार है।
ClaudeCodeLab Stripe Checkout implementation review, Claude Code payment workflow training, और आपके order, refund और subscription rules के अनुसार implementation support दे सकता है।
मुफ़्त 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.