Blog

Integrações 10 min de leitura

Stripe webhooks em PHP: um manipulador confiável em que você pode confiar

Um webhook Stripe que funciona em seu laptop ainda não é um webhook em que você possa confiar com dinheiro. Este guia mostra como verificar assinaturas, reconhecer eventos rapidamente, desduplicar e lidar com novas tentativas em PHP e Laravel simples, com código que você pode colar em um projeto.

Imagem de capa: Stripe webhooks em PHP: um manipulador confiável em que você pode confiar

Em resumo

  • Trate o webhook, e não o redirecionamento do navegador, como a fonte da verdade para o status do pagamento.
  • Leia o corpo bruto da solicitação e verifique o cabeçalho Stripe-Signature antes de tocar na carga útil.
  • Retorne uma resposta 2xx rapidamente e faça o trabalho real em uma fila ou após armazenar o evento.
  • Armazene o ID do evento com um índice exclusivo: Stripe pode entregar o mesmo evento mais de uma vez e fora de ordem.
  • Teste com o Stripe CLI (stripe listen e stripe trigger) antes de entrar no ar.

Se você aceita pagamentos com Stripe, os webhooks Stripe em PHP são a parte que decide se os pedidos serão marcados como pagos, as assinaturas serão ativadas e os reembolsos serão refletidos em seu banco de dados. A página de checkout é apenas a porta de entrada: os clientes fecham as abas, os bancos confirmam com atraso e alguns métodos de pagamento liquidam minutos ou dias depois. O webhook é a mensagem do Stripe que diz o que realmente aconteceu.

Construímos e mantemos integrações de pagamento para clientes e para nossos próprios produtos (por exemplo , Bill, que recebe pagamentos Stripe e PayPal em uma página de pagamento pública). Este artigo é a lista de verificação que seguimos quando escrevemos um endpoint de webhook Stripe em PHP.

Como funcionam os webhooks Stripe em um minuto

Você cadastra uma URL HTTPS no Stripe Dashboard (ou via API) e escolhe quais tipos de eventos receber. Quando algo acontece - uma sessão de checkout é concluída, uma fatura é paga, uma cobrança é reembolsada - Stripe envia uma solicitação POST com um objeto JSON Event para esse URL. Seu servidor deve responder com um código de status 2xx. Caso contrário, Stripe tentará novamente com espera exponencial por até três dias no modo ao vivo.

Três fatos moldam todo o design:

  • Os eventos podem chegar mais de uma vez. Novas tentativas e problemas de rede tornam as duplicatas normais.
  • Os eventos podem chegar fora de ordem. Você pode ver invoice.paid antes de customer.subscription.created.
  • Qualquer pessoa pode POSTAR no seu URL. Sem verificação de assinatura, um invasor pode marcar seu próprio pedido como pago.

Etapa 1. Instale o SDK e crie o endpoint

Use a biblioteca oficial stripe/stripe-php e mantenha o endpoint secreto (ele começa com whsec_) em uma variável de ambiente, nunca no repositório.

composer require stripe/stripe-php

# .env
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

Cada endpoint de webhook no Dashboard tem seu próprio segredo de assinatura, e o segredo da stripe listen no desenvolvimento é diferente novamente. Um bug muito comum de “falha na verificação de assinatura” é simplesmente usar o errado.

Etapa 2. Verifique a assinatura no corpo bruto

Stripe assina cada solicitação. O cabeçalho Stripe-Signature contém um carimbo de data/hora e uma ou mais assinaturas calculadas como um HMAC-SHA256 de timestamp.payload com seu segredo de endpoint. A biblioteca verifica a assinatura e rejeita eventos com mais de cinco minutos por padrão.

O detalhe importante: a verificação precisa dos bytes brutos exatos enviados Stripe. Se uma estrutura analisar o JSON e você o recodificar, os espaços em branco serão alterados e a assinatura não corresponderá mais.

<?php
// public/webhooks/stripe.php (plain PHP)
require __DIR__ . '/../../vendor/autoload.php';

$payload   = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret    = getenv('STRIPE_WEBHOOK_SECRET');

try {
    $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\UnexpectedValueException $e) {
    http_response_code(400);   // invalid JSON
    exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    http_response_code(400);   // bad or missing signature
    exit;
}

No Laravel, leia $request->getContent() em vez de $request->all() e exclua a rota da proteção CSRF (webhooks não carregam um token CSRF; a assinatura é a proteção). Coloque a rota fora do grupo de middleware de sessão web, se puder.

Etapa 3. Torne o manipulador idempotente

Idempotente significa: processar o mesmo evento duas vezes dá o mesmo resultado que processá-lo uma vez. A abordagem mais simples e confiável é uma tabela de IDs de eventos processados com um índice exclusivo. Insira primeiro; se a inserção falhar porque o ID existe, você já viu o evento.

CREATE TABLE stripe_events (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  event_id      VARCHAR(64) NOT NULL,
  type          VARCHAR(100) NOT NULL,
  payload       JSON NOT NULL,
  status        ENUM('received','processed','failed') NOT NULL DEFAULT 'received',
  received_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  processed_at  TIMESTAMP NULL,
  UNIQUE KEY uniq_event (event_id)
);
try {
    $pdo->prepare('INSERT INTO stripe_events (event_id, type, payload) VALUES (?, ?, ?)')
        ->execute([$event->id, $event->type, $payload]);
} catch (PDOException $e) {
    if ($e->getCode() === '23000') {   // duplicate key: already received
        http_response_code(200);
        exit;
    }
    throw $e;
}

Armazenar a carga bruta fornece uma trilha de auditoria: quando um cliente pergunta "por que fui cobrado duas vezes?", você pode reproduzir exatamente o que Stripe lhe disse. Também torna possível reprocessar eventos com falha após a correção de um bug.

Passo 4. Confirme rapidamente, processe separadamente

Stripe espera uma resposta em alguns segundos. Se o seu gerenciador enviar e-mails, chamar outras APIs ou gerar PDFs in-line, o tempo expirará, Stripe tentará novamente e você fará o mesmo trabalho novamente. Faça o mínimo na solicitação - verifique, armazene, responda 200 - e execute a lógica real em um trabalhador de fila (filas Laravel, Symfony Messenger, um cron job lendo status = 'received' ou um trabalhador gerenciado por supervisor).

Se o evento não puder ser armazenado (banco de dados inativo), retorne um status 5xx. Esse é o caso em que você deseja que Stripe tente novamente mais tarde.

Etapa 5. Lide com os eventos que importam

Você não precisa de todos os tipos de evento. Assine apenas o que sua lógica de negócios usa; menos assinaturas significa menos ruído e uma superfície de ataque menor. Um conjunto típico:

  • checkout.session.completed - o cliente finalizou o Checkout. Procure seu pedido usando client_reference_id ou metadados que você definiu ao criar a sessão.
  • payment_intent.succeeded e payment_intent.payment_failed - para fluxos de pagamento personalizados.
  • invoice.paid e invoice.payment_failed - cobrança de assinatura.
  • customer.subscription.updated e customer.subscription.deleted - alterações e cancelamentos de planos.
  • charge.refunded e charge.dispute.created - dinheiro voltando.
switch ($event->type) {
    case 'checkout.session.completed':
        $session = $event->data->object;
        $orderId = $session->client_reference_id;
        if ($session->payment_status === 'paid') {
            markOrderPaid($orderId, $session->payment_intent);
        }
        break;

    case 'invoice.payment_failed':
        notifyBillingProblem($event->data->object->customer);
        break;

    case 'charge.refunded':
        markOrderRefunded($event->data->object->payment_intent);
        break;

    default:
        // unknown or unsubscribed type: acknowledge and ignore
        break;
}

Dois hábitos previnem a maioria dos bugs. Primeiro, nunca confie nos valores do navegador: compare o valor pago e a moeda do evento com o pedido em seu banco de dados. Em segundo lugar, como os eventos podem estar fora de ordem, torne as mudanças de estado monótonas: não mova um pedido de “pago” para “pendente” porque um evento mais antigo chegou atrasado. Em caso de dúvida, busque o objeto atual da API em vez de confiar no instantâneo do evento.

Etapa 6. Teste localmente com Stripe CLI

A Stripe CLI encaminha eventos reais em modo de teste para sua máquina, portanto você não precisa de um túnel público:

stripe login
stripe listen --forward-to http://localhost:8000/webhooks/stripe
# in another terminal:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

stripe listen imprime um segredo whsec_ temporário. Coloque isso em seu .env local. Teste também os caminhos infelizes propositalmente: envie uma solicitação com assinatura errada (espere 400), envie o mesmo evento duas vezes (espere um efeito de banco de dados) e pare o trabalhador da fila por um minuto para ver se os eventos se acumulam em vez de desaparecer.

Erros comuns que vemos em auditorias

  • Usando o redirecionamento como comprovante de pagamento. A página success_url pode ser aberta por qualquer pessoa; mostre "confirmando seu pagamento" até que o webhook chegue.
  • Analisando JSON antes da verificação. Corpos recodificados quebram a assinatura; analisadores de corpo de estrutura são um culpado frequente.
  • Um segredo para testar e viver. O modo de teste e ao vivo têm chaves separadas e endpoints de webhook separados.
  • Sem registro. Sem eventos armazenados e um canal de falha visível, bugs de pagamento são encontrados pelos clientes.
  • Manipuladores lentos. Qualquer coisa acima de alguns segundos aciona novas tentativas e duplica o trabalho.
  • Bloqueio de Stripe no firewall ou WAF. Se você usa Cloudflare ou outro WAF, permita o caminho do webhook e verifique se as regras do bot não estão retornando 403 aos servidores do Stripe.

Monitoramento: como você sabe que continua funcionando

Um webhook pode falhar meses após o lançamento: uma alteração implantada renomeia uma coluna, um segredo é alternado, um certificado expira. O Stripe Dashboard mostra tentativas de entrega e falhas por endpoint, e Stripe envia e-mails para você quando um endpoint continua falhando. De sua parte, fique atento a três coisas: uma contagem crescente de eventos received ou failed, uma queda repentina nos eventos por hora e qualquer taxa de 4xx ou 5xx na rota do webhook. Um pequeno trabalho diário de reconciliação que compara os pedidos pagos em seu banco de dados com a lista de pagamentos bem-sucedidos do Stripe captura o resto.

Como podemos ajudar

Projetamos e construímos integrações de pagamento de produção e webhook com verificações de assinatura, idempotência, novas tentativas e registros que sua equipe pode ler. Se você já tem um fluxo Stripe e deseja um segundo par de olhos, uma revisão ou consulta de arquitetura é a maneira mais rápida de encontrar os pontos fracos. Para uma visão mais ampla de como mover eventos críticos para fora do Zapier ou Make, leia quando nenhum código quebra e, para outro provedor, consulte nosso guia para integração WayForPay em PHP.

Perguntas frequentes

Por que a verificação da assinatura do webhook Stripe falha?

As causas comuns são o uso do segredo de assinatura incorreto (teste versus live, CLI versus endpoint do Dashboard), a verificação de um corpo analisado ou recodificado em vez do corpo da solicitação bruta ou um proxy que modifica a carga útil. Verifique os bytes brutos exatos com o próprio whsec_ secret do endpoint.

Como evito o processamento de um evento Stripe duas vezes?

Armazene cada ID de evento em uma tabela com um índice exclusivo e insira-o antes de realizar qualquer trabalho. Se a inserção falhar com uma chave duplicada, retorne 200 e pule o processamento. Torne também segura a repetição da ação comercial em si.

Qual código de resposta um webhook Stripe deve retornar?

Retorne um código 2xx (200 ou 204) assim que o evento for verificado e armazenado. Retorne 400 para uma assinatura inválida e 5xx somente quando você não conseguiu armazenar o evento e deseja que Stripe tente novamente.

Por quanto tempo Stripe tenta novamente webhooks com falha?

No modo ao vivo, Stripe tenta novamente com espera exponencial por até três dias. No modo de teste, a janela de novas tentativas é muito mais curta, portanto, não confie nas novas tentativas do modo de teste para avaliar o comportamento da produção.

Posso testar webhooks Stripe sem um URL público?

Sim. Execute stripe listen --forward-to com o Stripe CLI para encaminhar eventos de teste para localhost e use stripe trigger para gerar eventos de amostra.

Chamada de descoberta gratuita

Traga uma stack desordenada ou uma integração parada - a Integrono mapeia o primeiro passo mais inteligente.

Recebe uma leitura senior sobre armadilhas de orçamento, dependências e se convém comprar, colar ou construir - antes de começar o trabalho pago. O CEO permanece no loop.