Implementar Stripe Checkout com Claude Code: Sessions, webhooks e idempotência
Implemente Stripe Checkout com Claude Code: hosted checkout, webhooks, metadata, idempotência, modo de teste e recuperação.
Stripe Checkout elimina a necessidade de criar um formulário próprio de cartão, mas não elimina a responsabilidade sobre pedido, acesso e reconciliação. Uma integração pronta para produção precisa de Checkout Sessions API, hosted checkout, webhook fulfillment, metadata.order_id, idempotência, modo de teste e checagens de reembolso e cancelamento.
Claude Code acelera esse trabalho quando recebe tarefas pequenas. Em vez de pedir “adicione Stripe”, peça primeiro o schema de pedidos, depois validação de variáveis de ambiente, depois a rota de Checkout, depois o webhook com assinatura, e por fim uma revisão crítica. Assim o código fica auditável e as chaves secretas não entram no prompt.
Este guia usa Next.js App Router e Node TypeScript. O fluxo cria o pedido interno antes da Checkout Session, redireciona para a página hospedada pela Stripe e entrega o produto por uma função de servidor idempotente.
O fluxo oficial
A Checkout Sessions API é a base de hosted Checkout, embedded Checkout e fluxos customizados. Para a maioria dos produtos digitais, SaaS e treinamentos, hosted checkout é o começo mais seguro porque a Stripe cuida do formulário, autenticação, validações e erros de pagamento.
Em How Checkout works, a sequência é: a aplicação cria uma Checkout Session, o cliente paga na página hospedada pela Stripe, e um evento como checkout.session.completed aciona o fulfillment. Fulfillment é a entrega após o pagamento: liberar download, ativar plano, reservar vaga, enviar pedido ou confirmar agenda.
A documentação Fulfill orders destaca que webhooks são obrigatórios e que a função de fulfillment precisa suportar múltiplas chamadas para a mesma Session. A referência da API também mostra que uma Session representa uma tentativa de pagamento e depois aponta para Customer, PaymentIntent ou Subscription.
Para o desenho completo, leia também integração de pagamentos com Claude Code, boas práticas de segurança e gerenciamento de variáveis de ambiente.
Arquitetura recomendada
flowchart LR
Buyer["Comprador"] --> App["App Next.js"]
App --> CheckoutRoute["/api/checkout"]
CheckoutRoute --> Orders["Tabela Order"]
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
A rota de Checkout cria ou reutiliza um pedido interno e devolve session.url. O webhook verifica a assinatura com o raw body e chama fulfillment. A página de sucesso também pode chamar a mesma função para reduzir atraso percebido, mas nunca substitui o webhook.
Ambiente e schema
npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init
Use somente chaves de teste no desenvolvimento. Não cole valores reais sk_test_, sk_live_ ou whsec_ em prompts do Claude Code.
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
// 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
}
Utilitários de servidor
// 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);
Criar a 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 deve conter apenas IDs internos. Email, endereço e dados de cartão não entram ali. idempotencyKey ajuda na criação da Session, mas o webhook ainda precisa de proteção idempotente no banco.
Fulfillment idempotente e 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 });
}
O raw body é indispensável para validar a assinatura. Se Claude Code trocar isso por request.json() antes da verificação, trate como bug de segurança.
Testes e falhas comuns
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
Use o whsec_ exibido pela CLI, reinicie o Next.js e pague com o cartão de teste 4242 4242 4242 4242. Verifique o banco: Order.status deve ficar FULFILLED, os IDs da Stripe devem estar salvos e recarregar a página de sucesso não pode criar um segundo log.
| Caso de uso | Checkout mode | Fulfillment | Risco |
|---|---|---|---|
| PDF ou template | payment | Liberar download | Não entregar só pela página de sucesso |
| Plano SaaS Pro | subscription | Ativar limites e assentos | Tratar cancelamento de assinatura |
| Sinal para consultoria | payment | Reservar agenda | Definir efeito do reembolso |
Erros comuns: liberar acesso apenas no success_url, esquecer metadata.order_id, confiar só no idempotencyKey, misturar objetos de teste e produção, e registrar reembolso na Stripe sem revogar ou ajustar permissões na aplicação.
Peça uma revisão estreita ao Claude Code.
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.
Pontos de confirmação ao testar
Antes de publicar, faça um pagamento em modo de teste, observe o webhook, confira a tabela de pedidos, recarregue a página de sucesso, expire uma Session, faça um reembolso e cancele uma assinatura de teste. Se a mesma Checkout Session puder ser processada várias vezes sem duplicar acesso, você tem uma boa base para revisão de produção.
ClaudeCodeLab pode revisar sua integração Stripe Checkout, treinar sua equipe em workflows de pagamento com Claude Code ou implementar a primeira versão com suas regras de pedidos, reembolsos e assinaturas.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.