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.
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.