Implementar assinaturas Stripe com Claude Code
Implemente assinaturas Stripe com Claude Code: Checkout, Portal, webhooks, permissões e monetização SaaS.
Assinatura é operação de receita, não só botão de pagamento
Criar uma Stripe Checkout Session em modo de assinatura é simples. O problema real aparece depois: falha de pagamento, mudança de plano, cancelamento ao fim do período, fatura, portal do cliente, repetição de Webhook, retirada de acesso e logs para suporte. Em um SaaS, uma biblioteca de templates, um curso pago ou uma oferta de consultoria recorrente, esses detalhes definem se a receita recorrente fica confiável.
Use o Claude Code como uma estrutura de implementação, não apenas como gerador de código. Em vez de pedir “integre Stripe”, entregue a ele documentação oficial, modelo de banco, regras de estado, comandos de teste e critérios de revisão. Assim o agente produz algo mais próximo de produção.
A separação principal é: Stripe é a fonte de verdade de cobrança; o banco da aplicação decide o acesso. Stripe sabe se a assinatura está active, trialing, past_due, unpaid ou canceled. A aplicação decide se o usuário pode baixar templates, ler conteúdo premium, usar assentos de equipe ou solicitar revisão.
flowchart LR
A["Página de preços"] --> B["Checkout Session<br/>mode=subscription"]
B --> C["Assinatura Stripe"]
C --> D["Webhook assinado"]
D --> E["billing_subscriptions"]
D --> F["entitlements"]
F --> G["Controle de acesso"]
G --> H["SaaS, curso ou biblioteca"]
H --> I["Customer Portal"]
I --> C
Defina preço e permissões antes do código
Comece por uma tabela de planos. Ela impede que a lógica comercial fique espalhada entre componentes, rotas e e-mails de suporte.
| Plano | Público | Mensal exemplo | Anual exemplo | Permissões |
|---|---|---|---|---|
| Free | visitantes testando conteúdo | R$ 0 | R$ 0 | prévias e downloads de exemplo |
| Pro | criadores solo e compradores de templates | R$ 49 | R$ 490 | artigos completos, templates, biblioteca de aulas |
| Studio | equipes e clientes recorrentes | R$ 249 | R$ 2.490 | Pro, assentos de equipe, pedidos de revisão, materiais de treinamento |
Pense em pelo menos três usos concretos. Primeiro, um micro-SaaS que libera exportações, dashboards e automações no plano pago. Segundo, um funil de infoproduto que leva do artigo gratuito ao template pago e depois à assinatura mensal. Terceiro, treinamento ou consultoria com assentos de equipe, sessões recorrentes e faturamento previsível.
Entitlement significa permissão de uso, como templates:download. Dunning é o fluxo após falha de pagamento: aviso, nova tentativa e link para atualizar o cartão. Observabilidade é conseguir descobrir qual evento da Stripe alterou plano, estado e permissões de um usuário.
Prompt com documentação oficial
Stripe muda com frequência, então direcione o Claude Code para fontes oficiais.
Implemente assinaturas Stripe Billing em uma aplicação Next.js App Router.
Use estes documentos oficiais como referência:
- Checkout Sessions API: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhooks and local testing: https://docs.stripe.com/webhooks
- Subscription object and statuses: https://docs.stripe.com/api/subscriptions/object
Requisitos:
- TypeScript, Next.js App Router e Postgres
- Checkout com mode="subscription"
- Customer Portal para meio de pagamento, faturas, mudança de plano e cancelamento
- Webhook com verificação de assinatura e deduplicação por event_id
- Tabela local entitlements decide acesso da aplicação
- active/trialing liberam, past_due tem 3 dias de graça, unpaid/canceled/paused bloqueiam
- Inclua código copiável e comandos stripe listen
Schema Postgres
billing_subscriptions acompanha o estado Stripe. entitlements é a tabela consultada antes de liberar funcionalidades. webhook_events evita processamento duplicado.
create table if not exists app_users (
id text primary key,
email text not null unique,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create table if not exists billing_subscriptions (
user_id text primary key references app_users(id) on delete cascade,
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
plan_key text not null,
status text not null check (
status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
),
access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
grace_until timestamptz,
updated_at timestamptz not null default now()
);
create table if not exists entitlements (
user_id text not null references app_users(id) on delete cascade,
feature_key text not null,
active boolean not null default true,
expires_at timestamptz,
updated_at timestamptz not null default now(),
primary key (user_id, feature_key)
);
create table if not exists webhook_events (
event_id text primary key,
event_type text not null,
status text not null default 'processing',
attempts integer not null default 1,
processed_at timestamptz,
last_error text,
created_at timestamptz not null default now()
);
Checkout e Customer Portal
npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false
// src/lib/db.ts
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL!, {
max: 5,
idle_timeout: 20,
});
// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
export const PLANS = {
free: { priceId: null, features: ["article:preview"] },
pro: {
priceId: process.env.STRIPE_PRO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library"],
},
studio: {
priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
},
} as const;
export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;
export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
return value === "pro" || value === "studio";
}
export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
const customerId = await findOrCreateCustomer(userId);
const plan = PLANS[planKey];
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: plan.priceId, quantity: 1 }],
client_reference_id: userId,
allow_promotion_codes: true,
automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${APP_URL}/pricing`,
subscription_data: { metadata: { userId, planKey } },
metadata: { userId, planKey },
});
if (!session.url) throw new Error("Stripe did not return a Checkout URL");
return session.url;
}
export async function createPortalSession(userId: string) {
const customerId = await getCustomerId(userId);
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${APP_URL}/settings/billing`,
});
return session.url;
}
async function findOrCreateCustomer(userId: string) {
const users = await sql`select id, email, stripe_customer_id from app_users where id = ${userId}`;
const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
if (!user) throw new Error("User not found");
if (user.stripe_customer_id) return user.stripe_customer_id;
const customer = await stripe.customers.create({ email: user.email, metadata: { userId } });
await sql`update app_users set stripe_customer_id = ${customer.id} where id = ${userId}`;
return customer.id;
}
async function getCustomerId(userId: string) {
const rows = await sql`select stripe_customer_id from app_users where id = ${userId}`;
const customerId = rows[0]?.stripe_customer_id as string | undefined;
if (!customerId) throw new Error("Stripe customer is not linked");
return customerId;
}
// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const { planKey } = await request.json();
if (!isPaidPlanKey(planKey)) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
const url = await createCheckoutSession(userId, planKey);
return NextResponse.json({ url });
}
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createPortalSession } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const url = await createPortalSession(userId);
return NextResponse.json({ url });
}
Webhook e permissões
A URL de sucesso não prova pagamento. Ela apenas devolve o usuário para a aplicação. O acesso deve vir de Webhooks assinados ou do estado mais recente consultado na API da Stripe. Como eventos podem chegar fora de ordem, busque a assinatura antes de atualizar o banco.
// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";
type AccessState = "pending" | "granted" | "grace" | "revoked";
export async function syncSubscriptionFromStripe(subscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["items.data.price"] });
const item = subscription.items.data[0];
const planKey = planKeyFromPrice(item?.price.id);
const userId = subscription.metadata.userId;
if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);
const accessState = accessStateFor(subscription.status);
const currentPeriodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
const graceUntil = accessState === "grace" ? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) : null;
await sql.begin(async (tx) => {
await tx`
insert into billing_subscriptions (
user_id, stripe_customer_id, stripe_subscription_id, plan_key,
status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
)
values (
${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
${subscription.status}, ${accessState}, ${currentPeriodEnd},
${subscription.cancel_at_period_end}, ${graceUntil}, now()
)
on conflict (user_id) do update set
stripe_customer_id = excluded.stripe_customer_id,
stripe_subscription_id = excluded.stripe_subscription_id,
plan_key = excluded.plan_key,
status = excluded.status,
access_state = excluded.access_state,
current_period_end = excluded.current_period_end,
cancel_at_period_end = excluded.cancel_at_period_end,
grace_until = excluded.grace_until,
updated_at = now()
`;
const features = accessState === "granted" || accessState === "grace" ? PLANS[planKey].features : PLANS.free.features;
await tx`delete from entitlements where user_id = ${userId}`;
for (const feature of features) {
await tx`insert into entitlements (user_id, feature_key, active, expires_at, updated_at) values (${userId}, ${feature}, true, ${graceUntil}, now())`;
}
});
console.info("stripe.subscription.synced", { userId, subscriptionId: subscription.id, status: subscription.status, accessState, planKey });
}
function planKeyFromPrice(priceId: string | undefined): PlanKey {
const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
return entry ? (entry[0] as PlanKey) : "free";
}
function accessStateFor(status: Stripe.Subscription.Status): AccessState {
if (status === "active" || status === "trialing") return "granted";
if (status === "past_due") return "grace";
if (status === "incomplete") return "pending";
return "revoked";
}
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";
export async function POST(request: NextRequest) {
const payload = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing 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 });
}
const rows = await sql`
insert into webhook_events (event_id, event_type, status)
values (${event.id}, ${event.type}, 'processing')
on conflict (event_id) do nothing
returning event_id
`;
if (rows.length === 0) return NextResponse.json({ received: true, duplicate: true });
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionFromStripe(subscription.id);
}
if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
const value = invoice.parent?.subscription_details?.subscription as string | { id: string } | null | undefined;
const subscriptionId = typeof value === "string" ? value : value?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
await sql`update webhook_events set status = 'processed', processed_at = now() where event_id = ${event.id}`;
return NextResponse.json({ received: true });
}
Teste local
Copie o whsec_... impresso pelo stripe listen para STRIPE_WEBHOOK_SECRET.
stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
Os gatilhos da CLI servem para smoke test. Para validar de verdade, crie Product e Price em modo de teste, passe pelo Checkout no navegador e depois teste Customer Portal, troca de plano e cancelamento.
Armadilhas comuns
Não libere recursos pagos apenas pela URL de sucesso. O usuário pode não voltar, o pagamento pode exigir autenticação adicional e o Webhook pode chegar depois.
Não coloque userId apenas no metadata da Checkout Session. Para sincronizar pela Subscription, inclua também subscription_data.metadata.
Não trate past_due, unpaid e canceled como iguais. past_due pode ter período de graça; unpaid e canceled normalmente removem permissões pagas.
Não esqueça a configuração do Customer Portal no Dashboard da Stripe. A API cria a sessão, mas produtos disponíveis, mudança de plano e cancelamento são configurados lá.
Não deixe imposto, moeda, reembolso e desconto anual para a semana do lançamento. Stripe ajuda na automação, mas a política comercial ainda é sua.
Monetização e próximos passos
Nos testes da ClaudeCodeLab com conteúdo e templates pagos, o maior ganho veio de fixar a tabela de estados antes do código. Quando active, past_due, unpaid e canceled têm significado claro, prompts do Claude Code, testes, eventos de analytics e suporte ficam alinhados.
Leia também pagamentos Stripe com Claude Code, autenticação com Claude Code e analytics com Claude Code. Para adaptar Stripe Billing, permissões e revisão com Claude Code ao seu SaaS, curso ou funil de produto, veja treinamento e consultoria Claude Code ou a biblioteca de produtos e templates.
Antes de publicar, confira as fontes oficiais: Checkout Sessions API, Customer Portal, subscription webhooks, webhooks, subscription statuses e Claude Code setup.
Artigos relacionados
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.
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.
Criar um boilerplate SaaS com Claude Code: Next.js, auth, billing, tenants e testes
Use Claude Code para criar um starter SaaS pago com Next.js, auth, billing, tenants, auditoria, testes e checklist.
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.