Use Cases (Atualizado: 02/06/2026)

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.

Pagamentos com Claude Code: Stripe Checkout, Webhooks, 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

CasoModeloFulfillmentFalha comum
Pacote de prompts, PDF, templatesPagamento únicoLiberar download e enviar email ao compradorSe a página de sucesso for o único canal, quem fecha o navegador pode perder o acesso
Plano Pro de um SaaS pequenoAssinatura mensalAtivar plan:pro e atualizar status com eventos posterioresIgnorar cancelamento ou cobrança falha mantém recursos pagos abertos
Sinal para workshop Claude CodePagamento único ou faturaReservar uma vaga e avisar a operaçãoO 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.

#Claude Code #Stripe #pagamentos #Webhook #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.