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 é:

  1. O usuário dispara uma ação (upgrade, cancelamento, atualização de cartão)
  2. O seu sistema chama o Stripe e recebe uma resposta de confirmação da intenção
  3. O Stripe processa e emite um evento (webhook)
  4. 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.

Conversar com a Logik Digital

Tags: billing recorrente saas stripe integração, Billing e Pagamentos, TOFU