Pagamentos com Claude Code: Stripe Checkout, Webhooks, reembolsos e impostos
Implemente Stripe com Claude Code: preços, Checkout, Webhooks, idempotência, fulfillment, reembolsos e impostos.
Em um SaaS pequeno, produto de conteúdo ou venda de templates, integração de pagamento não é só um botão de compra. Você precisa de tabela de produtos, API para Checkout Session, verificação de Webhook, fulfillment idempotente, lógica de reembolso e uma passagem clara para contabilidade e impostos. Sem isso, o Stripe pode registrar receita enquanto o app não libera acesso, ou um reembolso pode acontecer enquanto o plano pago continua ativo.
Claude Code acelera muito esse trabalho, mas apenas quando o escopo é bem definido. Um pedido como “adicione Stripe” costuma gerar uma tela bonita e uma lógica frágil na página de sucesso. Para código ligado a receita, peça entregas pequenas: preços definidos no servidor, rota de Checkout, verificação de assinatura com raw body, deduplicação de eventos, fulfillment, reembolsos e logs sem dados sensíveis.
Este guia usa Next.js App Router, Node.js, TypeScript e Stripe Checkout. Webhook é uma notificação servidor a servidor enviada pelo Stripe. Fulfillment é entregar ao cliente o que ele comprou: download, plano SaaS, vaga, reserva ou acesso a treinamento. Idempotência significa que a mesma operação pode rodar duas vezes sem duplicar o resultado.
Documentação oficial
Para detalhes específicos do provedor, use a documentação oficial. A criação de sessão está em Checkout Session create API, a entrega após pagamento em Checkout fulfillment, Webhooks e assinatura em Receive Stripe events, e retentativas seguras em Idempotent requests.
Reembolsos ficam em Create a refund, impostos em Stripe Tax for Checkout, e testes locais em Stripe CLI. No Next.js, consulte Route Handlers. Para a própria ferramenta, veja Claude Code overview.
Se você trocar para Paddle, Lemon Squeezy, PayPal ou outro provedor local, a arquitetura continua parecida: o preço é decidido no servidor, o pagamento é confirmado por Webhook, o fulfillment é idempotente e o reembolso mantém o acesso do app consistente.
Casos de monetização
| Caso | Modelo | Fulfillment | Falha comum |
|---|---|---|---|
| Pacote de prompts, PDF, templates | Pagamento único | Liberar download e enviar email ao comprador | Se a página de sucesso for o único canal, quem fecha o navegador pode perder o acesso |
| Plano Pro de um SaaS pequeno | Assinatura mensal | Ativar plan:pro e atualizar status com eventos posteriores | Ignorar cancelamento ou cobrança falha mantém recursos pagos abertos |
| Sinal para workshop Claude Code | Pagamento único ou fatura | Reservar uma vaga e avisar a operação | O reembolso não define sozinho se a vaga volta para agenda |
Em um protótipo de produto de conteúdo, Masa começou usando a página de sucesso como prova de pagamento. Funcionava com cartão de teste, mas falhava ao considerar reenvio de Webhook, navegador fechado, pagamento assíncrono e reembolso. A instrução melhor para Claude Code foi: “a página de sucesso só mostra status; acesso só é concedido pelo fulfillment comum chamado pelo Webhook.”
Arquitetura
flowchart LR
Buyer["Comprador"] --> Pricing["Tabela de preços"]
Pricing --> CheckoutApi["/api/checkout"]
CheckoutApi --> Order["Tabela Order"]
CheckoutApi --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Stripe hosted checkout"]
Hosted --> Webhook["/api/webhooks/stripe"]
Webhook --> Fulfill["fulfillCheckoutSession"]
Fulfill --> Entitlement["Tabela Entitlement"]
Hosted --> Success["Página de sucesso"]
Success --> ReadOnly["Ler status do pedido"]
A página de sucesso não concede acesso. Ela apenas mostra o estado. O Webhook verifica a assinatura, registra o ID do evento e chama uma única função de fulfillment que pode processar a mesma sessão mais de uma vez sem duplicar permissões.
Prompt recomendado para Claude Code:
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.
Tabela de produtos e ambiente
O navegador envia apenas productKey. Price IDs, modo de cobrança e chave de acesso ficam no servidor.
// 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;
}
Comece em modo de teste. Não cole sk_test_, sk_live_ ou whsec_ no prompt.
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
Banco de dados para idempotência
O estado de pagamento em produção deve ficar no banco. StripeEvent.id evita processamento duplicado de Webhook. Entitlement é o acesso que o app consulta de verdade.
// 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
API de Checkout
SDK do Stripe e verificação de Webhook devem rodar no runtime Node.js. A rota autentica, cria pedido interno, cria Checkout Session, salva o ID e devolve a 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 e tax_id_collection ajudam a coletar informações fiscais, mas não substituem revisão contábil. Registro, nota, alíquota e tratamento de reembolso dependem do negócio.
Componente de preços
A UI não envia valores nem Price ID. O preço exibido é texto; o valor real vem do Price do Stripe.
// src/components/PricingTable.tsx
"use client";
import { useState } from "react";
const cards = [
{
productKey: "promptPack",
name: "Prompt Pack",
price: "R$ 199",
description: "Pagamento único por templates e material prático",
},
{
productKey: "proMonthly",
name: "Pro",
price: "R$ 149/mês",
description: "Plano mensal para um SaaS pequeno",
},
{
productKey: "workshopDeposit",
name: "Workshop Deposit",
price: "R$ 500",
description: "Sinal para treinamento ou consultoria",
},
];
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 ? "Redirecionando..." : "Ir para Checkout"}
</button>
</section>
))}
</div>
);
}
Desabilitar o botão reduz clique duplo, mas a segurança real vem do servidor: tabela de produtos, idempotency key, ID de evento único e constraints no banco.
Verificação de Webhook e fulfillment
Não chame await req.json() antes da verificação. O Stripe precisa do raw body sem alteração.
// 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 };
});
}
O ponto principal é ter um único lugar onde acesso pago é concedido. Webhook, job de reparo e página de status podem chamar a mesma função.
Reembolsos e acesso
Reembolso não é só uma chamada ao Stripe. O produto precisa decidir se remove acesso, se reembolso parcial mantém acesso ou se uma reserva volta para agenda.
// 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 });
}
Peça para Claude Code perguntar quando a política de reembolso estiver indefinida. “Reembolso parcial mantém acesso” ou “reembolso total remove acesso” é decisão de produto.
Teste local
Use Stripe CLI para encaminhar eventos ao endpoint local.
npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Copie o whsec_... para STRIPE_WEBHOOK_SECRET, reinicie o Next.js e faça Checkout em modo de teste. O cartão 4242 4242 4242 4242, validade futura, qualquer CVC e qualquer CEP cobrem o sucesso básico. Para recusas e autenticação, veja Testing.
Verifique quatro coisas: a rota cria um pedido interno, o Webhook recebe checkout.session.completed, o pedido vira FULFILLED com um único Entitlement, e repetir o evento não cria outro acesso.
Prompt de revisão para Claude Code
Depois da implementação, use Claude Code como revisor.
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 escreve código, mas não assume a responsabilidade final por billing. Chaves, Dashboard, impostos, termos, política de reembolso e privacidade de logs continuam sendo revisão humana.
Resumo e CTA
Desenhe pagamentos a partir do ciclo de receita, não do botão. A tabela envia productKey, o servidor cria Checkout, o Webhook verifica assinatura e uma função idempotente concede acesso. Reembolso e impostos entram no primeiro desenho.
Para um passo a passo mais específico, leia Stripe Checkout com Claude Code. Para segredos, veja gerenciamento de variáveis de ambiente, e para eventos, implementação de Webhooks.
ClaudeCodeLab ajuda com revisão de pagamentos, treinamento em Claude Code e desenho de monetização para SaaS e conteúdo. Comece pela consultoria em inglês ou pela biblioteca de produtos com templates e checklists.
Testei este fluxo em um projeto local Next.js com Stripe em modo de teste, Webhook forwarding, evento duplicado e reembolso completo. O melhor investimento foi modelar Order e Entitlement antes da interface. Com o estado claro, o diff do Claude Code e a revisão humana ficam muito mais confiáveis.
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.