Use Cases (Atualizado: 02/06/2026)

Implementar assinaturas Stripe com Claude Code

Implemente assinaturas Stripe com Claude Code: Checkout, Portal, webhooks, permissões e monetização SaaS.

Implementar assinaturas Stripe com Claude Code

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.

PlanoPúblicoMensal exemploAnual exemploPermissões
Freevisitantes testando conteúdoR$ 0R$ 0prévias e downloads de exemplo
Procriadores solo e compradores de templatesR$ 49R$ 490artigos completos, templates, biblioteca de aulas
Studioequipes e clientes recorrentesR$ 249R$ 2.490Pro, 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.

#Claude Code #Stripe #assinaturas #SaaS #TypeScript
Grátis

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.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.