Implementação do Stripe Billing: assinaturas, faturas e webhooks
Stripe é o padrão de fato para infraestrutura de faturamento, mas implementá-lo corretamente – lidar com webhooks de forma idempotente, gerenciar transições de estado de assinatura e recuperar-se de falhas de pagamento – exige muito mais cuidado do que a documentação de introdução sugere. Um sistema de pagamento que funciona 99% do tempo e falha silenciosamente nos outros 1% é pior do que aquele que falha ruidosamente em 95%.
Este guia cobre uma implementação de produção do Stripe com 6 tipos de eventos de webhook, gerenciamento do ciclo de vida da assinatura, provisionamento automático de licenças e tratamento de reembolsos. Os exemplos de código usam NestJS 11, mas os padrões se aplicam a qualquer estrutura de back-end.
Principais conclusões
- Nunca provisione acesso com base no redirecionamento de checkout — sempre espere pelo webhook
checkout.session.completed- Verifique as assinaturas do webhook com
stripe.webhooks.constructEvent()antes de processar qualquer evento- Tornar todos os manipuladores de webhook idempotentes — Stripe tenta novamente webhooks com falha por até 72 horas
- Armazene IDs de clientes, IDs de assinatura e IDs de preços do Stripe em seu banco de dados – não consulte tudo novamente no Stripe
- Lidar com
invoice.payment_failedpara acionar e-mails de cobrança e suspensão de assinaturacharge.refundedecheckout.session.completedjuntos cobrem todo o ciclo de vida da compra- Use o campo
metadatado Stripe para vincular objetos Stripe aos seus registros internos- Nunca registre cargas úteis de webhook do Stripe — elas contêm dados confidenciais do cartão
Configuração do projeto
pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
Armazene estas variáveis de ambiente:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
Criação de sessão de checkout
O fluxo de checkout: seu backend cria uma sessão Stripe Checkout, o frontend redireciona para Stripe, Stripe coleta o pagamento e depois chama seu webhook. Nunca cuide do pagamento sozinho – sempre redirecione para Stripe Checkout.
// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';
@Injectable()
export class BillingService {
async createCheckoutSession(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
// Get or create Stripe customer
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'payment', // or 'subscription' for recurring
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/services`,
metadata: {
userId,
organizationId: orgId,
productId,
},
allow_promotion_codes: true,
invoice_creation: {
enabled: true,
},
});
return { url: session.url };
}
async createSubscriptionCheckout(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
subscription_data: {
metadata: {
userId,
organizationId: orgId,
productId,
},
trial_period_days: 14,
},
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pricing`,
metadata: {
userId,
organizationId: orgId,
productId,
},
});
return { url: session.url };
}
private async getOrCreateCustomer(userId: string, orgId: string) {
// Check if customer already exists in your DB
const existing = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (existing) return existing;
// Fetch user details for Stripe customer creation
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
const stripeCustomer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId,
organizationId: orgId,
},
});
const [customer] = await db
.insert(billingCustomers)
.values({
userId,
organizationId: orgId,
stripeCustomerId: stripeCustomer.id,
})
.returning();
return customer;
}
}
Manipulador de webhook
O manipulador de webhook é a parte mais crítica da integração do Stripe. Todo evento deve ser idempotente:
// billing/webhook.controller.ts
import {
Controller,
Post,
Req,
Headers,
RawBodyRequest,
HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';
@Controller('billing/webhook')
export class WebhookController {
constructor(private webhookService: BillingWebhookService) {}
@Post()
@Public()
@HttpCode(200) // Always return 200 — Stripe retries on non-2xx
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string
) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody!, // Requires raw body middleware
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Invalid signature — do not process
throw new Error(`Webhook signature verification failed: ${err.message}`);
}
// Route to specific handler
switch (event.type) {
case 'checkout.session.completed':
await this.webhookService.handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case 'invoice.paid':
await this.webhookService.handleInvoicePaid(
event.data.object as Stripe.Invoice
);
break;
case 'invoice.payment_failed':
await this.webhookService.handlePaymentFailed(
event.data.object as Stripe.Invoice
);
break;
case 'customer.subscription.deleted':
await this.webhookService.handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case 'customer.subscription.updated':
await this.webhookService.handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case 'charge.refunded':
await this.webhookService.handleChargeRefunded(
event.data.object as Stripe.Charge
);
break;
default:
// Ignore unhandled event types
break;
}
return { received: true };
}
}
Importante: NestJS requer rawBody: true em NestFactory.create() para verificação de assinatura Stripe:
// main.ts
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
Manipulador de check-out concluído
Este é o manipulador de provisionamento principal. Torne-o idempotente:
// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// Check if already processed (idempotency)
const existing = await db.query.orders.findFirst({
where: eq(orders.stripeSessionId, session.id),
});
if (existing) {
return; // Already processed — Stripe is retrying
}
const { userId, organizationId, productId } = session.metadata!;
// Create order in transaction
await db.transaction(async (tx) => {
// Create order
const [order] = await tx
.insert(orders)
.values({
userId,
organizationId,
productId,
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
amount: session.amount_total! / 100, // Stripe uses cents
currency: session.currency!,
status: 'completed',
})
.returning();
// Generate license key
const licenseKey = generateLicenseKey(productId);
// Create license
await tx.insert(licenses).values({
orderId: order.id,
userId,
organizationId,
productId,
licenseKey,
status: 'active',
issuedAt: new Date(),
});
});
// Send confirmation email (non-blocking — don't fail the webhook)
this.emailService
.sendPurchaseConfirmation(userId, productId)
.catch((err) => this.logger.error('Email send failed', err));
}
async handleInvoicePaid(invoice: Stripe.Invoice) {
// For subscriptions — renewal
if (!invoice.subscription) return;
// Find existing subscription/license and extend it
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
await db
.update(licenses)
.set({
status: 'active',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
})
.where(eq(licenses.id, license.id));
// Log renewal in audit table
await db.insert(licenseAuditLogs).values({
licenseId: license.id,
action: 'renewed',
details: { invoiceId: invoice.id },
});
}
async handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
// Grace period before suspension (e.g., 3 days)
const gracePeriod = new Date();
gracePeriod.setDate(gracePeriod.getDate() + 3);
if (new Date() > gracePeriod) {
await db
.update(licenses)
.set({ status: 'suspended' })
.where(eq(licenses.id, license.id));
}
// Send dunning email
this.emailService
.sendPaymentFailedNotification(license.userId, invoice)
.catch(console.error);
}
async handleChargeRefunded(charge: Stripe.Charge) {
const order = await db.query.orders.findFirst({
where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
});
if (!order) return;
await db.transaction(async (tx) => {
// Update order status
await tx
.update(orders)
.set({ status: 'refunded' })
.where(eq(orders.id, order.id));
// Revoke associated licenses
await tx
.update(licenses)
.set({ status: 'revoked', revokedAt: new Date() })
.where(eq(licenses.orderId, order.id));
});
// Log audit trail
await db.insert(licenseAuditLogs).values({
licenseId: (await db.query.licenses.findFirst({
where: eq(licenses.orderId, order.id),
}))!.id,
action: 'revoked',
details: { reason: 'refund', chargeId: charge.id },
});
}
async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db
.update(licenses)
.set({ status: 'cancelled', cancelledAt: new Date() })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const status = subscription.status === 'active' ? 'active' : 'suspended';
await db
.update(licenses)
.set({ status })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
}
Portal do Cliente
Permita que os clientes gerenciem suas próprias assinaturas sem entrar em contato com o suporte:
async createPortalSession(userId: string, orgId: string) {
const customer = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (!customer) {
throw new NotFoundException('No billing account found');
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
});
return { url: portalSession.url };
}
O portal do cliente permite que os usuários cancelem assinaturas, atualizem métodos de pagamento, baixem faturas e visualizem o histórico de faturamento – sem qualquer trabalho de interface personalizada.
Integração de front-end
// web: lib/billing.ts
'use client';
import { apiFetch } from '@/lib/api';
export async function initiateCheckout(priceId: string, productId: string) {
const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
method: 'POST',
body: JSON.stringify({ priceId, productId }),
});
// Redirect to Stripe Checkout
window.location.href = url;
}
export async function openCustomerPortal() {
const { url } = await apiFetch<{ url: string }>('/billing/portal', {
method: 'POST',
});
window.open(url, '_blank');
}
Testando webhooks localmente
O Stripe CLI é essencial para o desenvolvimento de webhook local:
# Install Stripe CLI
npm install -g stripe
# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted
# View event logs
stripe events list
A CLI fornece seu segredo do webhook local — use-o em seu .env.local:
STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output
Armadilhas e soluções comuns
Armada 1: provisionamento de acesso no redirecionamento de checkout em vez de webhook
O redirecionamento de sucesso de checkout pode ser acionado por: retrocesso/avançamento do navegador, guias duplicadas ou nova tentativa de rede. Sempre provisione com base no webhook checkout.session.completed, não no URL de redirecionamento.
Armadilha 2: não lidar com novas tentativas de webhook (idempotência)
Stripe tenta webhooks com falha por 72 horas. Sem verificações de idempotência, as novas tentativas criam pedidos, licenças e e-mails duplicados. Armazene o ID da sessão Stripe em sua tabela de pedidos com uma restrição exclusiva – inserções duplicadas falham normalmente.
Armadilha 3: Usar middleware de corpo bruto incorretamente
A verificação da assinatura Stripe requer o corpo da solicitação bruto e não analisado. Se o analisador de corpo JSON do Express for executado primeiro, o corpo bruto desaparecerá. Use rawBody: true no NestJS e acesse req.rawBody ou configure o middleware JSON para preservar o corpo bruto especificamente para a rota do webhook.
Armada 4: Lógica de repetição ausente para chamadas de API Stripe
As chamadas da API Stripe podem falhar temporariamente. Adicione lógica de nova tentativa para operações idempotentes:
import Stripe from 'stripe';
async function retryStripeOperation<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
Perguntas frequentes
Devo usar o Stripe Checkout ou formulários de pagamento personalizados?
Sempre use o Stripe Checkout, a menos que você tenha requisitos específicos que tornem isso impossível (raro). O Checkout lida automaticamente com conformidade com PCI, autenticação 3D Secure, Apple Pay, Google Pay, SEPA e dezenas de outros métodos de pagamento. Os formulários de pagamento personalizados (usando Stripe Elements) exigem que você mesmo mantenha a conformidade com o PCI e implemente cada método de pagamento manualmente. Para 95% dos produtos SaaS, o Checkout é a escolha certa.
Como lidar com o modo de teste do Stripe versus o modo ao vivo?
Use configurações de ambiente separadas: STRIPE_SECRET_KEY=sk_test_... em desenvolvimento e STRIPE_SECRET_KEY=sk_live_... em produção. O modo de teste Stripe é completamente isolado do modo ao vivo – nenhuma cobrança de teste afeta o dinheiro real e nenhum dado ao vivo aparece no modo de teste. Use stripe.mode em seu código para verificar se você está usando a chave correta e adicione um indicador em destaque na interface do administrador mostrando em qual modo você está.
O que acontece se meu servidor de webhook estiver inativo e o Stripe não conseguir entregar?
O Stripe tenta novamente webhooks com falha em uma programação de espera exponencial por até 72 horas: 5 minutos, 30 minutos, 1 hora, 2 horas e intervalos crescentes. Após 72 horas, o evento é considerado falhado. Use o painel de eventos do Stripe para reenviar manualmente eventos com falha ou consulte stripe.events.list() e reproduza-os. Projete seus manipuladores para serem idempotentes, para que a reentrega seja segura.
Como implementar o faturamento medido/baseado no uso?
O Stripe Billing oferece suporte ao uso medido via stripe.subscriptionItems.createUsageRecord(). Reportar a utilização ao final de cada período de faturamento com a quantidade consumida. Preço dos planos medidos em seu painel do Stripe como preços "medidos". O principal detalhe da implementação: relatar o uso antes do término do período de faturamento, não depois - Stripe finaliza a fatura no final do período.
Como devo armazenar Stripe IDs em meu banco de dados?
Armazene todos os Stripe IDs como colunas text (não numéricas). Crie índices em stripe_customer_id, stripe_subscription_id e stripe_payment_intent_id — você procurará registros por eles com frequência. Adicione restrições exclusivas quando apropriado (um registro de cliente por usuário). Nunca armazene números de cartão de crédito ou dados brutos de pagamento em seu próprio banco de dados.
Próximas etapas
Implementar o faturamento do Stripe corretamente — com idempotência de webhook, gerenciamento do ciclo de vida da assinatura, provisionamento automático e tratamento adequado de reembolso — não é uma tarefa de engenharia trivial. ECOSIRE lida com 6 tipos de eventos de webhook Stripe em produção com provisionamento automático de licença e integração ao portal do cliente.
Se você precisar de integração Stripe, infraestrutura de cobrança de assinatura ou suporte completo ao desenvolvimento de SaaS, explore nossos serviços de desenvolvimento.
Escrito por
ECOSIRE Research and Development Team
Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.
Artigos Relacionados
ERP for Law Firms: Case Management, Billing, and Client Portal
Complete guide to ERP for law firms — case management, legal billing, trust accounting, IOLTA compliance, client portal, and matter management for 2026.
Media Company ERP Implementation: Editorial, Ad Sales, and Subscriptions
Step-by-step guide to implementing ERP in media companies, covering editorial workflow integration, advertising system migration, subscription billing setup, and data migration.
Professional Services ERP Implementation: Time, Billing, and Projects
Step-by-step professional services ERP implementation guide covering time tracking, billing configuration, project accounting, and resource management setup.