A maioria das integrações do Stripe falha no mesmo lugar: eles tratam o redirecionamento do navegador como prova de pagamento. Não é. A única fonte confiável da verdade é o webhook que o Stripe envia ao seu servidor. Este guia constrói uma integração correta em Next.js com o App Router — Checkout, webhooks com verificação de assinatura, atendimento idempotente e assinaturas.
📋 Table of Contents
- Arquitetura em primeiro lugar
- Etapa 1: Instalar e configurar
- Etapa 2: Crie a sessão de checkout
- Etapa 3: Redirecionar do cliente
- Etapa 4: O Webhook — Onde mora a correção
- Etapa 5: testar webhooks localmente
- Etapa 6: A página de sucesso confirma, mas não concede
- Etapa 7: Permita que os clientes gerenciem suas próprias assinaturas
- Lista de verificação de ativação
- Erros Comuns
- Conclusão
Arquitetura em primeiro lugar
Compreender o fluxo evita os dois bugs que quebram a maioria das integrações.
- Seu servidor cria uma sessão de checkout e retorna sua URL.
- O navegador redireciona para Stripe. O usuário paga na página do Stripe – os detalhes do cartão nunca tocam no seu servidor.
- Stripe redireciona o navegador de volta para o seu URL de sucesso.
- Separadamente, o Stripe envia um webhook ao seu servidor confirmando o pagamento.
- Seu manipulador de webhook concede acesso. Não é a página de sucesso.
A etapa 5 é o jogo inteiro. O usuário pode fechar a aba antes do redirecionamento, perder a conectividade ou simplesmente editar a URL de sucesso e visitá-la diretamente. Conceda acesso apenas no webhook.
Etapa 1: Instalar e configurar
npm install stripe @stripe/stripe-js
Armazene chaves em variáveis de ambiente. A chave secreta nunca deve chegar ao cliente, portanto não recebeNEXT_PUBLIC_ prefixo.
# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_BASE_URL=http://localhost:3000
Crie um único cliente Stripe compartilhado para não construir um por solicitação.
// lib/stripe.ts
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2026-06-30',
typescript: true,
});
FixandoapiVersion assuntos. Sem ele, você herda qualquer versão padrão da sua conta, e isso pode mudar sob você.
Etapa 2: Crie a sessão de checkout
Nunca aceite um preço do cliente. Envie um identificador de produto e consulte o preço no servidor, ou o usuário simplesmente edita a solicitação e paga um centavo.
// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getCurrentUser } from '@/lib/auth';
const PRICES: Record<string, string> = {
pro_monthly: 'price_1AbCdEfGhIjKlMnO',
pro_yearly: 'price_1XyZaBcDeFgHiJkL',
};
export async function POST(req: Request) {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { plan } = await req.json();
const priceId = PRICES[plan];
if (!priceId) {
return NextResponse.json({ error: 'Unknown plan' }, { status: 400 });
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
customer_email: user.email,
// client_reference_id survives the round trip and arrives in the webhook.
client_reference_id: user.id,
metadata: { userId: user.id, plan },
});
return NextResponse.json({ url: session.url });
}
client_reference_id emetadata são como você sabe qual usuário pagou quando o webhook chega. Omita-os e você estará combinando os pagamentos às contas por e-mail, o que é interrompido no momento em que alguém paga com um endereço diferente.
Etapa 3: Redirecionar do cliente
'use client';
export function UpgradeButton({ plan }: { plan: string }) {
async function handleClick() {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan }),
});
if (!res.ok) {
alert('Could not start checkout. Please try again.');
return;
}
const { url } = await res.json();
window.location.href = url;
}
return <button onClick={handleClick}>Upgrade</button>;
}
Etapa 4: O Webhook — Onde mora a correção
Duas coisas quebram os webhooks no Next.js. Primeiro, você deve verificar a assinatura em relação aocru corpo da solicitação; se alguma coisa analisar primeiro em JSON, a verificação falhará. Em segundo lugar, você deve lidar com entregas duplicadas, porque o Stripe tentará novamente e entregará o mesmo evento mais de uma vez.
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { grantAccess, revokeAccess, hasProcessed, markProcessed } from '@/lib/billing';
export async function POST(req: Request) {
// req.text() gives the raw body, which signature verification requires.
const body = await req.text();
const signature = (await headers()).get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Stripe retries on any non-2xx, so the same event can arrive repeatedly.
if (await hasProcessed(event.id)) {
return NextResponse.json({ received: true });
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const userId = session.metadata?.userId ?? session.client_reference_id;
if (userId) {
await grantAccess(userId, {
customerId: session.customer as string,
subscriptionId: session.subscription as string,
});
}
break;
}
case 'customer.subscription.deleted': {
await revokeAccess(event.data.object.customer as string);
break;
}
case 'invoice.payment_failed': {
// Notify the user; do not revoke immediately — cards fail temporarily.
break;
}
}
await markProcessed(event.id);
return NextResponse.json({ received: true });
} catch (err) {
console.error('Webhook handler failed:', err);
// Return 500 so Stripe retries rather than dropping the event.
return NextResponse.json({ error: 'Handler failed' }, { status: 500 });
}
}
hasProcessed emarkProcessed deve gravar em uma tabela digitada no ID do evento Stripe com uma restrição exclusiva. Essa única restrição é o que torna o cumprimento idempotente – sem ela, uma nova tentativa credita duas vezes a conta.
Etapa 5: testar webhooks localmente
Stripe não pode alcançarlocalhost, então encaminhe eventos com a CLI.
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, fire a test event:
stripe trigger checkout.session.completed
stripe listen imprime um segredo de assinatura do webhook. Use esse valor paraSTRIPE_WEBHOOK_SECRET em desenvolvimento — é diferente daquele no Dashboard.
Etapa 6: A página de sucesso confirma, mas não concede
A página de sucesso deve indicar o estado que seu webhook já escreveu. Se o webhook ainda não tiver chegado, mostre um estado pendente em vez de conceder qualquer coisa.
// app/welcome/page.tsx
import { getCurrentUser } from '@/lib/auth';
import { getSubscription } from '@/lib/billing';
export default async function WelcomePage() {
const user = await getCurrentUser();
const subscription = await getSubscription(user.id);
if (!subscription?.active) {
return (
<p>Payment received. Your account is being activated — this usually takes a few seconds.</p>
);
}
return <h1>Welcome to Pro</h1>;
}
Etapa 7: Permita que os clientes gerenciem suas próprias assinaturas
O Portal de Cobrança trata de alterações de planos, cancelamentos, faturas e atualizações de cartões. Construir você mesmo esses fluxos são semanas de trabalho que você não precisa fazer.
// app/api/portal/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getCurrentUser } from '@/lib/auth';
import { getCustomerId } from '@/lib/billing';
export async function POST() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const customerId = await getCustomerId(user.id);
if (!customerId) return NextResponse.json({ error: 'No subscription' }, { status: 400 });
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/account`,
});
return NextResponse.json({ url: session.url });
}
Lista de verificação de ativação
- Troque chaves de teste por chaves ativas e crie umseparar endpoint webhook em modo ao vivo com seu próprio segredo de assinatura
- Confirme se o URL do seu webhook está acessível publicamente e retorna 2xx rapidamente – faça um trabalho lento de forma assíncrona
- Ative o Stripe Tax se você vende em várias jurisdições
- Ative as regras do Radar para proteção contra fraudes
- Teste o fluxo completo com um cartão real e depois faça o reembolso
- Verifique se sua tabela de idempotência possui um índice exclusivo no ID do evento
Erros Comuns
Concedendo acesso na página de sucesso. Os usuários que nunca o acessam ainda pagam, e os usuários que o visitam diretamente nunca o fazem.
Analisando o corpo antes da verificação da assinatura. A verificação precisa dos bytes brutos. Usarreq.text() e nada mais primeiro.
Confiar em um preço do cliente. Procure preços no servidor por identificador, sempre.
Retornando 200 em caso de falha do manipulador. Stripe trata 2xx como sucesso e para de tentar novamente, então o evento é perdido permanentemente. Retorne 500 e deixe tentar novamente.
Revogando o acesso no primeiro pagamento com falha. Os cartões falham por motivos temporários. Em vez disso, siga o processo de cobrança do Stripe.
Conclusão
Uma integração correta do Stripe depende de algumas regras:crie sessões no lado do servidor com preços que você controla, trate o webhook como a única fonte de verdade, verifique assinaturas em relação ao corpo bruto, torne o cumprimento idempotente com uma restrição exclusiva no ID do evento e retorne não-2xx quando o tratamento falhar, então Stripe tenta novamente. Faça o gerenciamento de assinaturas manualmente no Portal de cobrança, em vez de reconstruí-lo. Faça tudo certo e os pagamentos se tornarão a parte menos agitada do seu aplicativo.
🔗 Share this article
✍️ Leave a Comment