Webhooks de subscrição Stripe que sobrevivem aos casos extremos
Os webhooks de subscrição Stripe só se mantêm corretos se tratar a Stripe como fonte de verdade, verificar o cabeçalho Stripe-Signature, deduplicar pelo event id e voltar a buscar os objetos em vez de confiar na ordem dos eventos. Aqui fica o mapa do ciclo de vida e os modos de falha que nos morderam.
Os webhooks de subscrição Stripe só se mantêm corretos se tratar a Stripe como fonte de verdade, verificar o cabeçalho Stripe-Signature em cada pedido, deduplicar pelo event id porque a Stripe faz retentativas, e voltar a buscar o objeto em direto em vez de confiar na ordem em que os eventos chegam. Tudo o resto — a linha na base de dados, a flag "active", o controlo de acesso — é uma projeção dessa verdade, não a verdade em si. Foi a lição que pagámos a sério ao construir a suite de faturação para a Delicious Diamonds, uma chocolataria de luxo em Leiria, e é o princípio que mantém o sistema honesto meses depois.
Como lidar com webhooks de subscrição Stripe sem corromper o estado de faturação
O modelo mental ingénuo é: um cliente subscreve, a Stripe envia um webhook, ativa-se um booleano, pronto. Esse modelo sobrevive à demo e morre em produção. A faturação de subscrições real é uma máquina de estados de longa duração que a Stripe corre no seu próprio relógio — renovações no fim do mês, retentativas em cartões falhados, proporção em mudanças de nível, dunning, churn involuntário. O seu servidor sabe de tudo isto através de uma mangueira de eventos que chegam atrasados, duplicados e fora de ordem. O trabalho do handler de webhooks não é "processar pagamentos". É ser um projetor fiel de uma máquina de estados que vive inteiramente dentro da Stripe.
Na Amplified Creations construímos PHP 8.3 renderizado no servidor em alojamento partilhado OVH atrás da Cloudflare, com o Cockpit CMS para conteúdos e sem passo de build. Para a Delicious Diamonds entregámos uma suite Stripe completa: encomendas avulsas, comissões personalizadas, edições limitadas numeradas à mão, e subscrições mensais de chocolate com pausa, cancelamento e mudança de nível — tudo movido por webhooks de ciclo de vida. A regra de design a que chegámos depois da primeira ronda de bugs foi direta: guardar o mínimo de estado local necessário para renderizar uma página depressa, e voltar a derivar tudo o que importa a partir da Stripe sempre que um webhook nos diz que algo se mexeu.
A Stripe é a fonte de verdade, não a sua base de dados
A sua base de dados é uma cache. Diga isto em voz alta antes de escrever um único handler, porque todos os bugs dolorosos de subscrição remontam a esquecê-lo. Se a sua coluna status local e a Stripe discordarem, a Stripe está certa e você está desatualizado. Impomos isto nunca calculando o estado da subscrição nós próprios — nunca decidimos "este pagamento falhou, logo definir status para past_due". Em vez disso esperamos que a Stripe nos diga que a subscrição está agora past_due e copiamos isso para baixo. A única lógica autorada do handler é mapear a realidade da Stripe nas nossas regras de acesso (pode este cliente entrar e ver o seu preço de subscritor?), nunca inventar realidade de faturação própria.
O ganho prático: quando algo parece errado, a pergunta de depuração é sempre "o que pensa a Stripe?", respondida numa consulta ao dashboard ou numa chamada à API. Não há uma segunda opinião para reconciliar.
Que eventos de ciclo de vida realmente importam
A Stripe emite dezenas de tipos de evento. Para subscrições pode ignorar a maioria. Estes são os que efetivamente ligamos, e o que cada um significa.
checkout.session.completed
Dispara quando o cliente termina o Stripe Checkout. É o seu sinal de que uma intenção de subscrição teve sucesso e pode ligar os ids customer e subscription da Stripe ao seu utilizador local. Não o trate como "pagamento confirmado para sempre" — é o início da relação, não uma garantia de cada renovação futura. Lemos aqui o client_reference_id para casar a sessão com o utilizador que a iniciou.
customer.subscription.created / customer.subscription.updated / customer.subscription.deleted
São a espinha da máquina de estados. created diz-lhe que a subscrição existe; updated dispara em quase todas as mudanças relevantes — transições de estado (active, past_due, canceled, paused), trocas de nível, cancelamentos agendados, pausa/retoma. deleted significa que acabou e o acesso deve terminar. Tratamos o customer.subscription.updated como o evento mais importante: sempre que chega copiamos o status atual da subscrição, os items (o nível) e o current_period_end para a nossa linha. Noventa por cento de "manter a vista local correta" vive neste handler.
invoice.paid
Dispara quando uma fatura — incluindo cada fatura de renovação mensal — é paga com sucesso. É o evento que diz "pagaram o período seguinte". Usamo-lo para estender o acesso e registar o pagamento no histórico de encomendas do cliente. Note que é distinto de checkout.session.completed: o Checkout cobre o primeiro pagamento, o invoice.paid cobre cada renovação depois.
invoice.payment_failed
Dispara quando uma cobrança de renovação falha — cartão expirado, fundos insuficientes, um banco a recusar uma compra de luxo transfronteiriça (acontece mais do que se pensa). É a porta de entrada para o dunning. Por si só não significa que a subscrição foi cancelada; a Stripe vai retentar no calendário que configurou. Usamo-lo para sinalizar a conta e, quando apropriado, incentivar o cliente a atualizar o cartão.
invoice.payment_action_required
Dispara quando o pagamento precisa de Autenticação Forte do Cliente — o cliente tem de completar um desafio 3D Secure antes da cobrança passar. Sob as regras europeias de SCA isto é comum, não exótico. A resposta certa é notificar o cliente de que é preciso ação; o dinheiro não é cobrado até ele autenticar.
O cabeçalho Stripe-Signature e o webhook signing secret
Antes de confiar num único byte de um webhook, verifique que veio da Stripe. O endpoint é um URL público; qualquer um pode fazer POST. A Stripe assina cada entrega com o cabeçalho Stripe-Signature, e o seu handler verifica essa assinatura contra um webhook signing secret por endpoint (o valor whsec_... do dashboard, mantido fora do repositório). Em PHP:
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sig, $webhookSecret
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit;
}
Duas armadilhas morderam-nos. Primeira: tem de verificar contra o corpo do pedido cru. Se algum middleware recodificar ou formatar o JSON antes de o assinar, a verificação falha para eventos legítimos — leia php://input diretamente, não recorra a um array já parseado. Segunda: o signing secret do stripe listen em testes locais é diferente do secret do seu endpoint de produção. Trocá-los produz um fluxo de 400s que parecem um ataque e são na verdade um erro de configuração.
Idempotência: deduplicar pelo event id porque a Stripe retenta
A Stripe garante entrega pelo-menos-uma-vez, não exatamente-uma-vez. Se o seu endpoint for lento, der timeout ou devolver um não-2xx, a Stripe retenta — e pode também legitimamente entregar o mesmo evento duas vezes. Se o seu handler não for idempotente, um invoice.paid duplicado credita uma encomenda duas vezes, e um evento retentado reenvia um email de "a sua subscrição está ativa" que o cliente já recebeu. A solução é simples e não-negociável: cada evento tem um id estável (o valor evt_...). Registe os ids processados e salte tudo o que já viu.
// Inserir o event id primeiro; a restrição UNIQUE é o lock.
$ok = $db->insertEventIfNew($event->id); // false se já existir
if (!$ok) {
http_response_code(200); // já tratado, confirmar e seguir
exit;
}
// ... fazer o trabalho real ...
Deixamos a restrição UNIQUE da base de dados no event id fazer a deduplicação — inserir primeiro, e se colidir já tratámos este evento e devolvemos 200 imediatamente. Devolver 200 num duplicado importa: se devolver um erro, a Stripe continua a retentar para sempre. Confirme depressa, processe de forma idempotente.
Eventos fora de ordem: use o objeto no evento, depois volte a buscar
Este foi o que mais nos surpreendeu. Os webhooks não têm garantia de chegar na ordem em que as mudanças subjacentes aconteceram. Pode receber um customer.subscription.updated de um cancelamento antes do invoice.paid da renovação que o precedeu, porque as duas entregas HTTP correram uma corrida. Se o seu handler aplicar cegamente o que recebeu por último, um evento desatualizado a chegar tarde atropela o estado fresco e um cliente pagante perde o acesso.
Duas defesas, usadas em conjunto. Primeira, nunca confie na ordem dos webhooks para nada que importe — quando um evento lhe diz que uma subscrição mudou, volte a buscar o objeto em direto da API da Stripe e projete esse, não o snapshot possivelmente desatualizado embutido no evento. O objeto embutido serve para encaminhamento e logging; a busca em direto é o que escreve. Segunda, quando usar o objeto embutido, apoie-se nos campos monotónicos da Stripe — o current_period_end e o status da subscrição numa busca fresca refletem o verdadeiro estado atual independentemente de qual evento o acordou. Aprendemos isto quando um cliente que subiu de nível apareceu brevemente no nível antigo porque dois eventos updated chegaram trocados; voltar a buscar em cada handler fez o sintoma desaparecer.
Dunning e pagamentos falhados: past_due e canceled
Quando uma renovação falha, a Stripe não cancela imediatamente — corre o dunning: um calendário de retentativas configurável (Smart Retries ou fixo) com emails de lembrete opcionais. Durante esta janela a subscrição fica em past_due. O seu trabalho é decidir o que past_due significa para o acesso. Mantemos o preço de subscritor e a conta ativos durante a janela de tolerância — um cartão recusado numa subscrição de chocolate é normalmente um problema de cartão, não um sinal de churn — e só revogamos quando a Stripe finalmente move a subscrição para canceled depois de esgotar as retentativas. Os estados a tratar: active (tudo bem), past_due (pagou antes, a falhar agora, em tolerância), canceled (acabou, revogar acesso), e paused se usar pause collection. Crucialmente, não autoramos nenhuma destas transições — a Stripe é dona da política de retentativas, nós só lemos o status resultante.
Implementar pausa, cancelamento e mudança de nível
Os três são escritas na API da Stripe; o webhook customer.subscription.updated resultante é o que atualiza o nosso lado. Nunca mutamos o estado local no momento do clique na esperança de que a Stripe concorde — fazemos a chamada à API, e o webhook fecha o ciclo.
- Cancelar: definir
cancel_at_period_end = truepara um fim gracioso (o cliente mantém o acesso até esgotar o período que pagou), ou cancelar imediatamente se exigir. O caminho gracioso é quase sempre o default certo e evita discussões de reembolso. - Pausa: usar o
pause_collectionda Stripe na subscrição para que as renovações parem sem perder o registo da subscrição e o seu histórico. Retomar limpa-o. É bem mais limpo que cancelar-e-recriar, que perde o histórico de direitos da edição numerada que nos importa. - Mudança de nível: atualizar o item da subscrição para o novo preço. A Stripe trata da proporção; o webhook
updatedtraz o novo nível até nós. Deixamos a Stripe calcular o dinheiro e nunca tentamos pré-calcular a proporção.
Testar com a Stripe CLI: stripe listen e stripe trigger
Não pode testar isto contra pagamentos de produção, e não o deve testar clicando pelo Checkout cinquenta vezes. A Stripe CLI é a ferramenta. stripe listen --forward-to localhost:8000/webhook abre um túnel que reencaminha eventos reais de modo-de-teste para o seu handler local e imprime o signing secret a usar. stripe trigger invoice.payment_failed (e os outros nomes de evento acima) dispara um evento sintético-mas-realista para exercitar o caminho do dunning sem ter um cartão que recusa a pedido. Escrevemos um script de sequência — criar, renovar, falhar, retentar, cancelar — e verificamos o nosso estado projetado após cada passo, que é a única forma de apanhar os bugs de fora-de-ordem antes dos clientes.
A versão curta
- A Stripe é a fonte de verdade; a sua base de dados é uma cache que re-deriva dos webhooks.
- Verifique o cabeçalho
Stripe-Signaturecontra o secretwhsec_por endpoint, usando o corpo cru do pedido. - Deduplique pelo
iddo evento — a Stripe entrega pelo-menos-uma-vez e retenta em qualquer não-2xx. - Não confie na ordem dos eventos: volte a buscar a subscrição/fatura em direto e projete isso.
- Ligue só os eventos que importam:
checkout.session.completed,customer.subscription.created/updated/deleted,invoice.paid,invoice.payment_failed,invoice.payment_action_required. - Deixe a Stripe ser dona do dunning e da transição
past_due→canceled; você só lê. - Implemente pausa/cancelamento/mudança-de-nível como escritas na API da Stripe e deixe o webhook
updatedreconciliar o estado local. - Teste com
stripe listenestripe triggerantes de publicar.
Perguntas frequentes
Preciso de tratar todos os eventos de webhook da Stripe?
Não. Para faturação de subscrições pode ignorar a maioria dos tipos de evento e ligar um pequeno conjunto: checkout.session.completed, os três eventos customer.subscription.*, invoice.paid, invoice.payment_failed e invoice.payment_action_required. Subscreva só o que ação requer; o resto é ruído.
Como torno idempotente um handler de webhook da Stripe?
Deduplique pelo id do evento. Cada evento Stripe tem um id estável evt_...; registe os ids processados (uma restrição UNIQUE funciona bem) e salte qualquer id já tratado, devolvendo 200. É necessário porque a Stripe entrega pelo-menos-uma-vez e retenta entregas falhadas, logo o mesmo evento pode chegar duas vezes.
Porque é que os meus eventos de webhook da Stripe chegam fora de ordem?
Porque a Stripe não garante a ordem de entrega — entregas HTTP separadas podem correr uma corrida, logo um evento mais novo pode chegar antes de um mais antigo. Não aplique eventos cegamente. Quando um evento sinaliza uma mudança, volte a buscar o objeto em direto da API da Stripe e projete esse estado atual, em vez de confiar no snapshot embutido no evento.
Qual é a diferença entre past_due e canceled na Stripe?
past_due significa que um pagamento de renovação falhou e a Stripe está a retentá-lo no seu calendário de dunning — a subscrição ainda está viva e normalmente vale a pena manter o acesso aberto durante a janela de tolerância. canceled significa que a Stripe desistiu após esgotar as retentativas (ou você cancelou); é aí que revoga o acesso. Deixe a Stripe conduzir a transição em vez de a calcular você.
Como testo webhooks de subscrição da Stripe localmente?
Use a Stripe CLI. Corra stripe listen --forward-to localhost:8000/webhook para tunelar eventos reais de modo-de-teste para o seu endpoint local (imprime o signing secret a usar), e stripe trigger invoice.payment_failed ou qualquer outro nome de evento para disparar um evento sintético realista. Escreva um script do ciclo de vida completo — criar, renovar, falhar, cancelar — e verifique o seu estado projetado após cada passo.