Use Cases (Atualizado: 01/06/2026)

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.

Implementar Stripe Checkout com Claude Code: Sessions, webhooks e idempotência

Stripe Checkout elimina a necessidade de criar um formulário próprio de cartão, mas não elimina a responsabilidade sobre pedido, acesso e reconciliação. Uma integração pronta para produção precisa de Checkout Sessions API, hosted checkout, webhook fulfillment, metadata.order_id, idempotência, modo de teste e checagens de reembolso e cancelamento.

Claude Code acelera esse trabalho quando recebe tarefas pequenas. Em vez de pedir “adicione Stripe”, peça primeiro o schema de pedidos, depois validação de variáveis de ambiente, depois a rota de Checkout, depois o webhook com assinatura, e por fim uma revisão crítica. Assim o código fica auditável e as chaves secretas não entram no prompt.

Este guia usa Next.js App Router e Node TypeScript. O fluxo cria o pedido interno antes da Checkout Session, redireciona para a página hospedada pela Stripe e entrega o produto por uma função de servidor idempotente.

O fluxo oficial

A Checkout Sessions API é a base de hosted Checkout, embedded Checkout e fluxos customizados. Para a maioria dos produtos digitais, SaaS e treinamentos, hosted checkout é o começo mais seguro porque a Stripe cuida do formulário, autenticação, validações e erros de pagamento.

Em How Checkout works, a sequência é: a aplicação cria uma Checkout Session, o cliente paga na página hospedada pela Stripe, e um evento como checkout.session.completed aciona o fulfillment. Fulfillment é a entrega após o pagamento: liberar download, ativar plano, reservar vaga, enviar pedido ou confirmar agenda.

A documentação Fulfill orders destaca que webhooks são obrigatórios e que a função de fulfillment precisa suportar múltiplas chamadas para a mesma Session. A referência da API também mostra que uma Session representa uma tentativa de pagamento e depois aponta para Customer, PaymentIntent ou Subscription.

Para o desenho completo, leia também integração de pagamentos com Claude Code, boas práticas de segurança e gerenciamento de variáveis de ambiente.

Arquitetura recomendada

flowchart LR
  Buyer["Comprador"] --> App["App Next.js"]
  App --> CheckoutRoute["/api/checkout"]
  CheckoutRoute --> Orders["Tabela Order"]
  CheckoutRoute --> Stripe["Stripe Checkout Session"]
  Stripe --> Hosted["Hosted Checkout"]
  Hosted --> Webhook["/api/stripe/webhook"]
  Webhook --> Fulfill["fulfillCheckout"]
  Hosted --> Success["/checkout/success"]
  Success --> Fulfill
  Fulfill --> Orders

A rota de Checkout cria ou reutiliza um pedido interno e devolve session.url. O webhook verifica a assinatura com o raw body e chama fulfillment. A página de sucesso também pode chamar a mesma função para reduzir atraso percebido, mas nunca substitui o webhook.

Ambiente e schema

npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init

Use somente chaves de teste no desenvolvimento. Não cole valores reais sk_test_, sk_live_ ou whsec_ em prompts do Claude Code.

APP_URL=http://localhost:3000
DATABASE_URL=postgresql://user:password@localhost:5432/checkout_dev
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_EBOOK=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO=price_xxxxxxxxxxxxxxxxxxxxx
// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Order {
  id                      String      @id @default(cuid())
  cartId                  String      @unique
  userId                  String
  email                   String?
  productKey              String
  quantity                Int         @default(1)
  status                  OrderStatus @default(PENDING)
  stripeCheckoutSessionId String?     @unique
  stripePaymentIntentId   String?     @unique
  stripeSubscriptionId    String?     @unique
  amountTotal             Int?
  currency                String?
  fulfilledAt             DateTime?
  createdAt               DateTime    @default(now())
  updatedAt               DateTime    @updatedAt
  logs                    FulfillmentLog[]
}

model FulfillmentLog {
  id                      String   @id @default(cuid())
  orderId                 String
  stripeCheckoutSessionId String
  event                   String
  createdAt               DateTime @default(now())
  order                   Order    @relation(fields: [orderId], references: [id])

  @@unique([orderId, event])
  @@index([stripeCheckoutSessionId])
}

enum OrderStatus {
  PENDING
  PAID
  FULFILLED
  CANCELED
  REFUNDED
  FAILED
}

Utilitários de servidor

// src/lib/env.ts
import { z } from "zod";

const envSchema = z.object({
  APP_URL: z.string().url(),
  DATABASE_URL: z.string().min(1),
  STRIPE_SECRET_KEY: z.string().regex(/^sk_(test|live)_/),
  STRIPE_WEBHOOK_SECRET: z.string().regex(/^whsec_/),
  STRIPE_PRICE_EBOOK: z.string().startsWith("price_"),
  STRIPE_PRICE_PRO: z.string().startsWith("price_"),
});

export const env = envSchema.parse(process.env);
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}
// src/lib/stripe.ts
import Stripe from "stripe";
import { env } from "@/lib/env";

export const stripe = new Stripe(env.STRIPE_SECRET_KEY);

Criar a Checkout Session

// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";

const checkoutRequestSchema = z.object({
  cartId: z.string().min(8),
  userId: z.string().min(1),
  email: z.string().email().optional(),
  productKey: z.enum(["ebook", "pro"]),
});

const priceByProduct = {
  ebook: env.STRIPE_PRICE_EBOOK,
  pro: env.STRIPE_PRICE_PRO,
} as const;

export async function POST(request: Request) {
  const body = await request.json().catch(() => null);
  const parsed = checkoutRequestSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid checkout request" }, { status: 400 });
  }

  const { cartId, userId, email, productKey } = parsed.data;
  const mode: Stripe.Checkout.SessionCreateParams.Mode =
    productKey === "pro" ? "subscription" : "payment";

  const order = await prisma.order.upsert({
    where: { cartId },
    update: {},
    create: { cartId, userId, email, productKey, quantity: 1, status: "PENDING" },
  });

  if (order.stripeCheckoutSessionId) {
    const existing = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
    if (existing.status === "open" && existing.url) {
      return NextResponse.json({ url: existing.url, orderId: order.id });
    }

    return NextResponse.json(
      { error: "Checkout session is not open. Create a new cart." },
      { status: 409 },
    );
  }

  const metadata = {
    order_id: order.id,
    cart_id: cartId,
    product_key: productKey,
    user_id: userId,
  };

  const session = await stripe.checkout.sessions.create(
    {
      mode,
      line_items: [{ price: priceByProduct[productKey], quantity: 1 }],
      customer_email: email,
      success_url: `${env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${env.APP_URL}/pricing?checkout=cancelled&order_id=${order.id}`,
      client_reference_id: order.id,
      metadata,
      allow_promotion_codes: true,
      payment_intent_data: mode === "payment" ? { metadata } : undefined,
      subscription_data: mode === "subscription" ? { metadata } : undefined,
    },
    { idempotencyKey: `checkout:${order.id}:v1` },
  );

  await prisma.order.update({
    where: { id: order.id },
    data: { stripeCheckoutSessionId: session.id },
  });

  return NextResponse.json({ url: session.url, orderId: order.id });
}

Metadata deve conter apenas IDs internos. Email, endereço e dados de cartão não entram ali. idempotencyKey ajuda na criação da Session, mas o webhook ainda precisa de proteção idempotente no banco.

Fulfillment idempotente e webhook

// src/lib/checkout-fulfillment.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";

function stripeObjectId(
  value: string | Stripe.PaymentIntent | Stripe.Subscription | null,
) {
  if (!value) return null;
  return typeof value === "string" ? value : value.id;
}

export async function fulfillCheckout(sessionId: string) {
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ["line_items", "payment_intent", "subscription"],
  });

  if (session.payment_status === "unpaid") {
    return { status: "waiting_for_payment" as const, sessionId };
  }

  const orderId = session.metadata?.order_id ?? session.client_reference_id;
  if (!orderId) throw new Error(`Checkout Session ${session.id} has no order_id`);

  return prisma.$transaction(async (tx) => {
    const updated = await tx.order.updateMany({
      where: { id: orderId, fulfilledAt: null },
      data: {
        status: "FULFILLED",
        stripeCheckoutSessionId: session.id,
        stripePaymentIntentId: stripeObjectId(session.payment_intent),
        stripeSubscriptionId: stripeObjectId(session.subscription),
        amountTotal: session.amount_total,
        currency: session.currency,
        fulfilledAt: new Date(),
      },
    });

    if (updated.count === 0) return { status: "already_fulfilled" as const, orderId };

    await tx.fulfillmentLog.create({
      data: { orderId, stripeCheckoutSessionId: session.id, event: "checkout.fulfilled" },
    });

    return { status: "fulfilled" as const, orderId };
  });
}
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
import { fulfillCheckout } from "@/lib/checkout-fulfillment";

export const runtime = "nodejs";

function idFromStripeObject(value: string | { id: string } | null | undefined) {
  if (!value) return null;
  return typeof value === "string" ? value : value.id;
}

async function markSession(sessionId: string, status: "CANCELED" | "FAILED") {
  await prisma.order.updateMany({
    where: { stripeCheckoutSessionId: sessionId, fulfilledAt: null },
    data: { status },
  });
}

export async function POST(request: Request) {
  const signature = request.headers.get("stripe-signature");
  if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });

  const rawBody = await request.text();
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error";
    return NextResponse.json(
      { error: `Webhook signature verification failed: ${message}` },
      { status: 400 },
    );
  }

  switch (event.type) {
    case "checkout.session.completed":
    case "checkout.session.async_payment_succeeded":
      await fulfillCheckout((event.data.object as Stripe.Checkout.Session).id);
      break;
    case "checkout.session.async_payment_failed":
      await markSession((event.data.object as Stripe.Checkout.Session).id, "FAILED");
      break;
    case "checkout.session.expired":
      await markSession((event.data.object as Stripe.Checkout.Session).id, "CANCELED");
      break;
    case "charge.refunded": {
      const charge = event.data.object as Stripe.Charge;
      const paymentIntentId = idFromStripeObject(charge.payment_intent);
      if (paymentIntentId) {
        await prisma.order.updateMany({
          where: { stripePaymentIntentId: paymentIntentId },
          data: { status: "REFUNDED" },
        });
      }
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await prisma.order.updateMany({
        where: { stripeSubscriptionId: subscription.id },
        data: { status: "CANCELED" },
      });
      break;
    }
  }

  return NextResponse.json({ received: true });
}

O raw body é indispensável para validar a assinatura. Se Claude Code trocar isso por request.json() antes da verificação, trate como bug de segurança.

Testes e falhas comuns

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

Use o whsec_ exibido pela CLI, reinicie o Next.js e pague com o cartão de teste 4242 4242 4242 4242. Verifique o banco: Order.status deve ficar FULFILLED, os IDs da Stripe devem estar salvos e recarregar a página de sucesso não pode criar um segundo log.

Caso de usoCheckout modeFulfillmentRisco
PDF ou templatepaymentLiberar downloadNão entregar só pela página de sucesso
Plano SaaS ProsubscriptionAtivar limites e assentosTratar cancelamento de assinatura
Sinal para consultoriapaymentReservar agendaDefinir efeito do reembolso

Erros comuns: liberar acesso apenas no success_url, esquecer metadata.order_id, confiar só no idempotencyKey, misturar objetos de teste e produção, e registrar reembolso na Stripe sem revogar ou ajustar permissões na aplicação.

Peça uma revisão estreita ao Claude Code.

Review only the Stripe Checkout implementation.
Check env-only secrets, metadata order_id without PII, raw-body webhook signature verification,
idempotent fulfillment, test-mode setup, refund and cancellation handling.
Return concrete file and line references.

Pontos de confirmação ao testar

Antes de publicar, faça um pagamento em modo de teste, observe o webhook, confira a tabela de pedidos, recarregue a página de sucesso, expire uma Session, faça um reembolso e cancele uma assinatura de teste. Se a mesma Checkout Session puder ser processada várias vezes sem duplicar acesso, você tem uma boa base para revisão de produção.

ClaudeCodeLab pode revisar sua integração Stripe Checkout, treinar sua equipe em workflows de pagamento com Claude Code ou implementar a primeira versão com suas regras de pedidos, reembolsos e assinaturas.

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