Billing e Pagamentos
11 de fevereiro de 2026 · 13 min de leitura
Billing recorrente parece simples quando você olha a documentação do Stripe pela primeira vez. Você cria um Customer, uma Subscription, e o dinheiro começa a entrar todo mês. Na prática, o que acontece entre 'criar a assinatura' e 'receita chegando de forma confiável' envolve uma série de decisões que a maioria dos times só descobre quando algo dá errado em produção — uma cobrança duplicada, um acesso que deveria ter sido suspenso mas não foi, um webhook que falhou silenciosamente às 3h da manhã. Este post cobre o que você precisa entender sobre eventos de assinatura, processamento de webhooks e consistência financeira antes que esses problemas apareçam.
O modelo mental certo: seu sistema reage a eventos, não controla o billing
O erro conceitual mais comum ao integrar Stripe é tratar o billing como uma operação que o seu sistema executa. Na realidade, o Stripe é quem gerencia o ciclo de vida da assinatura — o seu sistema reage ao que o Stripe informa via webhooks.
Isso muda completamente a forma de pensar sobre consistência. Se você atualiza o status da assinatura no seu banco só quando o usuário clica em 'fazer upgrade', está apostando que a transação no Stripe vai funcionar e que o usuário não vai fechar a aba antes do redirect. São duas apostas arriscadas.
O fluxo correto é:
- O usuário dispara uma ação (upgrade, cancelamento, atualização de cartão)
- O seu sistema chama o Stripe e recebe uma resposta de confirmação da intenção
- O Stripe processa e emite um evento (webhook)
- O seu sistema processa o webhook e atualiza o estado local
O estado da assinatura no seu banco deve ser sempre um reflexo do que o Stripe informou via webhook — não uma suposição baseada no que o usuário clicou. Essa distinção é o que separa um sistema de billing que funciona em produção de um que funciona na demo.
Os eventos que realmente importam no ciclo de vida de uma assinatura
O Stripe emite dezenas de tipos de eventos. Para um SaaS B2B que está começando, a maioria pode ser ignorada. Os que você precisa tratar:
customer.subscription.created
Emitido quando uma nova assinatura é criada. Use para provisionar o tenant — criar o registro no banco, definir os limites do plano, enviar o e-mail de boas-vindas.
customer.subscription.updated
Emitido quando qualquer coisa na assinatura muda: troca de plano, alteração de quantidade de seats, mudança de data de cobrança. Você precisa verificar o que mudou no objeto subscription e atualizar o plano local do tenant de acordo.
customer.subscription.deleted
Emitido quando a assinatura é cancelada — seja pelo usuário, pelo sistema (inadimplência), ou por você via API. Use para suspender o acesso, enviar o e-mail de cancelamento e iniciar o processo de retenção de dados conforme o que foi acordado no contrato.
invoice.payment_succeeded
Confirmação de que uma cobrança recorrente foi processada com sucesso. Use para atualizar a data de próxima renovação no seu banco, emitir a NFS-e correspondente e registrar no histórico financeiro do tenant.
invoice.payment_failed
A cobrança falhou — cartão expirado, saldo insuficiente, limite atingido. O Stripe vai tentar novamente automaticamente (conforme a política de retry que você configura), mas você precisa notificar o cliente e, dependendo da política, restringir o acesso após um número de tentativas.
payment_method.updated
O cliente atualizou o cartão. Relevante para limpar flags de inadimplência e registrar no histórico.
Tratar bem esses seis eventos cobre 95% dos casos que vão aparecer em produção. O restante pode ser adicionado conforme necessidade real — não antecipação.
Processando webhooks no .NET Core: o que não pode falhar
Um webhook do Stripe é uma requisição HTTP POST que o Stripe envia para um endpoint seu. Se o endpoint retornar qualquer status que não seja 2xx em menos de 30 segundos, o Stripe considera falha e tenta novamente — até 5 vezes, em intervalos crescentes.
Isso cria dois requisitos que precisam estar funcionando desde o primeiro deploy:
Validação de assinatura
Antes de processar qualquer coisa, valide que o webhook veio de fato do Stripe — não de alguém que descobriu o seu endpoint e está enviando eventos forjados:
[ApiController]
[Route("api/webhooks")]
public class StripeWebhookController : ControllerBase
{
private readonly IStripeWebhookService _webhookService;
private readonly IConfiguration _configuration;
public StripeWebhookController(
IStripeWebhookService webhookService,
IConfiguration configuration)
{
_webhookService = webhookService;
_configuration = configuration;
}
[HttpPost("stripe")]
public async Task<IActionResult> HandleStripeWebhook()
{
var payload = await new StreamReader(Request.Body).ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"];
var secret = _configuration["Stripe:WebhookSecret"]!;
try
{
var stripeEvent = EventUtility.ConstructEvent(
payload, signature, secret,
throwOnApiVersionMismatch: false
);
// Retorna 200 imediatamente — processamento vai para a fila
await _webhookService.EnqueueAsync(stripeEvent);
return Ok();
}
catch (StripeException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}
Retornar 200 rápido, processar depois
O controller faz duas coisas: valida a assinatura e coloca o evento em uma fila. Só isso. O processamento real — atualizar banco de dados, emitir NFS-e, enviar e-mail — acontece num Worker Service, fora do ciclo HTTP.
Por quê? Porque o Stripe aguarda no máximo 30 segundos. Se o processamento demorar mais — emissão de NFS-e travou, banco lento, serviço de e-mail fora do ar — o Stripe vai considerar falha e tentar novamente. Com o padrão acima, o endpoint responde em milissegundos independente do que o processamento vai demorar.
Idempotência: o problema que você não vê até ser tarde
O Stripe pode entregar o mesmo evento mais de uma vez. Isso não é bug — é comportamento documentado. Se o seu endpoint retornar erro ou demorar demais, o Stripe reenvia. Se houver instabilidade de rede, o evento pode chegar duplicado.
Sem idempotência, um evento invoice.payment_succeeded duplicado pode emitir duas NFS-e para o mesmo pagamento, debitar duas vezes o crédito do tenant, ou enviar dois e-mails de confirmação para o cliente.
A solução é simples: registrar o ID do evento antes de processar e rejeitar duplicatas:
public class StripeWebhookWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Consome eventos da fila
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var handler = scope.ServiceProvider.GetRequiredService<IStripeEventHandler>();
var evento = await DequeueAsync(stoppingToken);
// Verificar se já foi processado
var jaProcessado = await db.StripeEventosProcessados
.AnyAsync(e => e.EventoId == evento.Id, stoppingToken);
if (jaProcessado)
{
_logger.LogInformation(
"Evento duplicado ignorado. EventoId={EventoId}", evento.Id);
continue;
}
await using var transaction = await db.Database.BeginTransactionAsync(stoppingToken);
try
{
await handler.HandleAsync(evento, stoppingToken);
db.StripeEventosProcessados.Add(new StripeEventoProcessado
{
EventoId = evento.Id,
Tipo = evento.Type,
ProcessadoEm = DateTime.UtcNow
});
await db.SaveChangesAsync(stoppingToken);
await transaction.CommitAsync(stoppingToken);
}
catch (Exception ex)
{
await transaction.RollbackAsync(stoppingToken);
_logger.LogError(ex, "Falha ao processar evento. EventoId={EventoId}", evento.Id);
// Volta para a fila ou vai para DLQ
}
}
}
}
O registro do evento processado e a lógica de negócio ficam na mesma transação. Se a lógica de negócio falhar, o registro não é salvo — e o evento será processado novamente na próxima tentativa. Se a lógica de negócio passar, o registro confirma que não deve ser processado de novo.
Reconciliação: o que fazer quando o webhook não chegou
Mesmo com retry automático, há cenários onde o webhook não chega — falha de infraestrutura prolongada, timeout do lado do Stripe, configuração incorreta do endpoint. Confiar exclusivamente nos webhooks é construir sobre uma suposição que vai falhar em algum momento.
A solução é um job periódico de reconciliação que compara o estado das assinaturas no Stripe com o estado no seu banco:
public class ReconciliacaoAssinaturasJob
{
private readonly SubscriptionService _stripeSubscriptions;
private readonly IAssinaturaRepository _assinaturaRepository;
private readonly ILogger<ReconciliacaoAssinaturasJob> _logger;
public async Task ExecutarAsync(CancellationToken cancellationToken)
{
var assinaturas = await _assinaturaRepository.GetAllActivasAsync(cancellationToken);
foreach (var assinatura in assinaturas)
{
var stripeSubscription = await _stripeSubscriptions.GetAsync(
assinatura.StripeSubscriptionId, cancellationToken: cancellationToken);
if (stripeSubscription.Status != assinatura.Status)
{
_logger.LogWarning(
"Divergência detectada. TenantId={TenantId} StatusLocal={Local} StatusStripe={Stripe}",
assinatura.TenantId, assinatura.Status, stripeSubscription.Status);
await _assinaturaRepository.AtualizarStatusAsync(
assinatura.TenantId,
stripeSubscription.Status,
cancellationToken);
}
}
}
}
Rodar esse job uma vez por dia é suficiente para a maioria dos SaaS early-stage. Não resolve inconsistências em tempo real, mas garante que erros de sincronização não persistam por semanas sem que ninguém perceba.
Um log de divergência com LogWarning é importante: se esse alerta disparar com frequência, você tem um problema de entrega de webhooks que precisa ser investigado — não mascarado pelo job de reconciliação.
Modelo de dados: o que persistir sobre billing
Você precisa de uma tabela de assinaturas que seja a fonte de verdade local sobre o estado de billing de cada tenant. Ela não substitui o Stripe — complementa:
public class Assinatura
{
public Guid Id { get; private set; }
public Guid TenantId { get; private set; }
// Referências no Stripe
public string StripeCustomerId { get; private set; } = string.Empty;
public string StripeSubscriptionId { get; private set; } = string.Empty;
public string StripePriceId { get; private set; } = string.Empty;
// Estado atual
public string Plano { get; private set; } = string.Empty;
public string Status { get; private set; } = string.Empty; // active, past_due, canceled
public int QuantidadeSeats { get; private set; }
// Datas
public DateTime PeriodoAtualInicio { get; private set; }
public DateTime PeriodoAtualFim { get; private set; }
public DateTime? CanceladoEm { get; private set; }
public DateTime? TrialFim { get; private set; }
// Auditoria
public DateTime AtualizadoEm { get; private set; }
public string UltimoEventoStripe { get; private set; } = string.Empty;
}
Alguns campos merecem atenção:
UltimoEventoStripe — guarda o ID do último evento que atualizou esse registro. Isso permite rastrear de qual webhook veio cada mudança de estado, o que é inestimável para depurar inconsistências.
Status como string, não enum — os status do Stripe (active, past_due, canceled, unpaid, trialing) podem evoluir. Guardar como string evita que um status novo quebre a aplicação com uma exceção de enum.
PeriodoAtualFim — a data em que o período atual da assinatura termina. Usada para exibir 'seu plano renova em X' na UI e para calcular o acesso de tenants que cancelaram mas ainda têm crédito do período pago.
O que testar antes de ir para produção
O Stripe tem um modo de teste com eventos simulados via CLI — use antes de colocar qualquer cliente real:
# Instalar Stripe CLI
brew install stripe/stripe-cli/stripe
# Autenticar
stripe login
# Escutar webhooks localmente e encaminhar para a API
stripe listen --forward-to localhost:5000/api/webhooks/stripe
# Em outro terminal, disparar eventos de teste
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
O fluxo mínimo que precisa funcionar antes do primeiro cliente pagante:
- Criar assinatura → webhook
subscription.created→ tenant provisionado com plano correto - Pagamento bem-sucedido → webhook
invoice.payment_succeeded→ data de renovação atualizada - Pagamento falho → webhook
invoice.payment_failed→ e-mail enviado, acesso restringido após política de retry - Cancelamento → webhook
subscription.deleted→ acesso suspenso, dados retidos pelo período contratual - Evento duplicado → segundo processamento ignorado sem erro e sem efeito colateral
- Job de reconciliação → executa sem erro com assinaturas ativas
Testar esses cenários manualmente antes de ir para produção economiza pelo menos uma crise de madrugada.
Se você está integrando billing no seu SaaS e quer uma arquitetura que não gere inconsistências financeiras quando o volume aumentar — fala com a Logik Digital.