Para receber webhooks de gateway de pagamento no Laravel sem perder nem duplicar eventos, faça quatro coisas. Valide a assinatura sobre o corpo cru da requisição. Grave o evento numa tabela com índice único antes de qualquer regra de negócio. Responda 2xx rápido e processe numa fila com lock. Por fim, trate o payload como aviso de que algo mudou, não como verdade sobre o estado atual. O resto é detalhe de implementação, e é nos detalhes que a cobrança aparece paga duas vezes ou não aparece nunca.

Trabalhando com Stripe, Asaas, Pagar.me e Zoop, aprendi que webhook não é uma chamada confiável. É uma entrega at-least-once: o provedor garante que tenta entregar pelo menos uma vez, mas pode entregar duas, cinco ou nenhuma. Ele também pode mandar fora de ordem, atrasado ou depois de você já ter descoberto o novo status por outro caminho. O código precisa assumir tudo isso desde o começo.

Por que processar o webhook direto no controller dá problema?

O primeiro instinto é escrever um controller que lê o payload, acha o pagamento e muda o status ali mesmo. Funciona no ambiente de teste e falha em produção por três motivos:

  1. Timeout do provedor. Os gateways esperam a resposta por poucos segundos. Se o handler faz consulta pesada, chama outra API ou dispara e-mail, a resposta atrasa. O provedor considera falha e reenvia. Você processa de novo um evento que já tinha processado.
  2. Erro no meio do processamento. Se der exceção depois de alterar metade do estado, o provedor reenvia e você não sabe o que já foi feito.
  3. Nenhum registro do que chegou. Quando o suporte pergunta por que a venda não caiu, você não tem como saber se o webhook chegou, chegou com erro ou nunca chegou.

A solução é separar recebimento de processamento. O controller só autentica, grava e enfileira. Quem aplica a regra de negócio é um job.

Como validar a assinatura HMAC do webhook no Laravel?

HMAC é um hash calculado com um segredo compartilhado entre você e o provedor. Se o hash que você calcula sobre o corpo bate com o que veio no header, a requisição veio de quem conhece o segredo e não foi alterada no caminho.

O erro mais comum é calcular o HMAC sobre json_encode($request->all()). O Laravel já decodificou o JSON, e recodificar muda espaços, ordem e escape de caracteres. A assinatura nunca bate, ou bate por acaso no teste e quebra com um payload que tem acento. Use sempre $request->getContent(), que é o corpo exatamente como chegou.

Um middleware para o formato da Stripe (header Stripe-Signature com t=timestamp e uma ou mais assinaturas v1) fica assim:

class VerifyStripeSignature
{
    public function handle(Request $request, Closure $next)
    {
        $parts = collect(explode(',', (string) $request->header('Stripe-Signature')))
            ->map(fn ($p) => explode('=', trim($p), 2))
            ->filter(fn ($p) => count($p) === 2);

        $timestamp = (int) $parts->firstWhere(0, 't')[1] ?? 0;
        $signatures = $parts->where(0, 'v1')->pluck(1);

        $expected = hash_hmac(
            'sha256',
            $timestamp . '.' . $request->getContent(),
            config('services.stripe.webhook_secret')
        );

        $valid = $signatures->contains(fn ($sig) => hash_equals($expected, $sig));

        if (! $valid || abs(time() - $timestamp) > 300) {
            abort(401);
        }

        return $next($request);
    }
}

Três detalhes importam aqui:

  • hash_equals compara em tempo constante. Com ===, um atacante poderia medir o tempo de resposta para descobrir a assinatura byte a byte.
  • A tolerância de timestamp, aqui de 5 minutos, impede replay attack: alguém que capturou uma requisição válida não consegue reenviá-la horas depois.
  • Cada provedor tem seu esquema. O Asaas, por exemplo, manda um token fixo que você configura no painel, e não um HMAC do corpo. Nesse caso a validação é uma comparação com hash_equals do header contra o token salvo. Não existe middleware universal. Crie um por provedor e registre na rota.

Quando a assinatura não bate e você não sabe se o problema é o segredo, o encoding ou a concatenação, o testador de webhook ajuda: você cola o payload, o segredo e a assinatura e vê no navegador se o HMAC confere.

Coloque essas rotas em routes/api.php para não passarem pelo middleware de CSRF, ou exclua o caminho explicitamente se estiverem em web.php.

Como evitar processar o mesmo webhook duas vezes?

Deduplicação tem que ser garantida pelo banco, não pela aplicação. Um if (WebhookEvent::where(...)->exists()) seguido de create tem condição de corrida: duas entregas simultâneas do mesmo evento passam pelo if ao mesmo tempo. E isso acontece, porque provedores às vezes disparam retries em paralelo.

A tabela de entrada, que muita gente chama de inbox, fica assim:

Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider', 30);
    $table->string('event_id');
    $table->string('type');
    $table->jsonb('payload');
    $table->timestamp('received_at');
    $table->timestamp('processed_at')->nullable();
    $table->timestamp('failed_at')->nullable();
    $table->text('last_error')->nullable();
    $table->unsignedSmallInteger('attempts')->default(0);

    $table->unique(['provider', 'event_id']);
    $table->index(['processed_at', 'failed_at']);
});

O event_id é o identificador do evento, não do recurso. Na Stripe é o evt_.... Se o provedor não manda um ID de evento estável, monte um a partir do que define unicidade para você, como ID da cobrança mais status mais data do evento, e documente essa decisão. Hash do corpo cru é a última opção, porque dois reenvios do mesmo evento podem vir com corpo diferente se o provedor incluir o número da tentativa.

O controller usa createOrFirst, que tenta o insert e, se o índice único reclamar, busca o registro existente. No PostgreSQL ele usa savepoint, então não aborta uma transação externa:

class StripeWebhookController
{
    public function __invoke(Request $request)
    {
        $data = $request->json()->all();

        $event = WebhookEvent::createOrFirst(
            ['provider' => 'stripe', 'event_id' => $data['id']],
            [
                'type' => $data['type'],
                'payload' => $data,
                'received_at' => now(),
            ]
        );

        if ($event->wasRecentlyCreated) {
            ProcessWebhookEvent::dispatch($event->id)->onQueue('webhooks');
        }

        return response()->noContent();
    }
}

Repare que o duplicado também recebe 2xx. Responder erro para um evento repetido só faz o provedor insistir. A regra que sigo é: 401 para assinatura inválida, 2xx para todo o resto, inclusive tipos de evento que você ainda não trata. Esses ficam gravados e você decide depois se quer processá-los.

A fila webhooks deve ser exclusiva. Se ela divide workers com jobs de relatório, um relatório lento segura a confirmação de pagamento. Expliquei como separar isso no Horizon em bulkhead de filas no Laravel Horizon.

Como processar o webhook na fila com segurança?

O índice único impede gravar duas vezes, mas o job ainda pode rodar duas vezes. Isso acontece quando o worker morre depois de processar e antes de dar ack, ou quando alguém reprocessa manualmente. Por isso o job trava a linha do evento e confere processed_at antes de agir:

class ProcessWebhookEvent implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 6;
    public array $backoff = [10, 60, 300, 900, 3600];

    public function __construct(public int $eventId) {}

    public function handle(WebhookHandlers $handlers): void
    {
        $event = WebhookEvent::findOrFail($this->eventId);

        if ($event->processed_at) {
            return;
        }

        $handler = $handlers->for($event->provider, $event->type);
        $context = $handler?->prepare($event); // chamadas externas, fora da transação

        DB::transaction(function () use ($handler, $context) {
            $event = WebhookEvent::whereKey($this->eventId)->lockForUpdate()->first();

            if ($event->processed_at) {
                return;
            }

            $handler?->apply($event, $context);

            $event->forceFill([
                'processed_at' => now(),
                'attempts' => $event->attempts + 1,
            ])->save();
        });
    }

    public function failed(Throwable $e): void
    {
        WebhookEvent::whereKey($this->eventId)->update([
            'failed_at' => now(),
            'last_error' => mb_substr($e->getMessage(), 0, 2000),
        ]);
    }
}

A divisão entre prepare e apply é deliberada. Chamar a API do gateway dentro de uma transação que segura lockForUpdate prende a linha, e às vezes o pagamento inteiro, pelo tempo de resposta do provedor. Se o provedor demora, outros jobs ficam esperando o lock e a conexão com o PostgreSQL fica aberta à toa. O que é externo vai antes. Dentro da transação fica só leitura e escrita no banco.

Quando o processamento gera efeitos para outros sistemas, como notificar o lojista, atualizar o saldo ou disparar a conciliação, não chame esses serviços dentro do apply. Grave um evento de saída na mesma transação. É o padrão do transactional outbox no Laravel, e com ele inbox e outbox fecham as duas pontas: nada entra duplicado e nada sai perdido.

O que fazer quando os webhooks chegam fora de ordem?

Esse é o problema que mais demora para aparecer e mais estraga dado. Um cenário típico: a cobrança é paga e logo depois estornada. O provedor dispara charge.paid e charge.refunded quase juntos. O paid falha na primeira tentativa por um erro qualquer e só é processado no retry, minutos depois do refunded. Se o handler confiar cegamente no payload, o pagamento termina como pago, e o lojista vê na conta um dinheiro que já foi devolvido.

Há duas defesas, e uso as duas juntas:

  1. Buscar o estado atual na API (refetch). O webhook vira só um gatilho: "a cobrança X mudou". No prepare, o handler consulta a cobrança no gateway e usa o status que vier de lá. Assim não importa a ordem de chegada, porque o handler sempre aplica o estado mais recente.
  2. Máquina de estados que recusa regressão. Mesmo com refetch, dois jobs podem ler a API em momentos diferentes e gravar em ordem invertida. O apply só aplica a transição se ela for válida a partir do status atual. refunded para paid não existe, então o evento atrasado vira no-op. Detalhei esse modelo em máquina de estados para status de pagamento no Laravel.
class ChargeUpdatedHandler implements WebhookHandler
{
    public function __construct(private PaymentGateway $gateway) {}

    public function prepare(WebhookEvent $event): GatewayCharge
    {
        return $this->gateway->fetchCharge($event->payload['data']['object']['id']);
    }

    public function apply(WebhookEvent $event, mixed $charge): void
    {
        $payment = Payment::where('gateway_charge_id', $charge->id)
            ->lockForUpdate()
            ->firstOrFail();

        $target = PaymentStatus::fromGateway($charge->status);

        if ($payment->status === $target || ! $payment->status->canTransitionTo($target)) {
            return; // repetido ou atrasado: nada a fazer
        }

        $payment->transitionTo($target);
    }
}

Na prática, a escolha entre confiar no payload e buscar na API fica assim:

Critério Confiar no payload Refetch na API
Chamadas externas Nenhuma Uma por evento
Eventos fora de ordem Precisa de versão ou timestamp no payload Resolvido naturalmente
Rate limit do provedor Não consome Consome, e pico de eventos pode estourar
Payload incompleto Você fica refém do que veio Pega o recurso completo
Provedor fora do ar Processa normalmente Job falha e entra em retry

Para eventos que mexem com dinheiro (pago, estornado, contestado, transferência liquidada), faço refetch sempre. Para eventos informativos, como atualização de cadastro, o payload basta. O custo do refetch é o rate limit. Em pico de vendas, a fila de webhooks pode disparar centenas de consultas por minuto, então o job precisa respeitar o limite do provedor.

Como reprocessar webhooks que falharam?

Como todo evento fica gravado, reprocessar vira um comando Artisan, não uma conversa com o suporte do gateway para pedir reenvio:

class ReplayWebhooks extends Command
{
    protected $signature = 'webhooks:replay {--provider=} {--since=} {--id=*}';

    public function handle(): void
    {
        WebhookEvent::query()
            ->whereNull('processed_at')
            ->when($this->option('provider'), fn ($q, $p) => $q->where('provider', $p))
            ->when($this->option('since'), fn ($q, $s) => $q->where('received_at', '>=', $s))
            ->when($this->option('id'), fn ($q, $ids) => $q->whereKey($ids))
            ->lazyById()
            ->each(function (WebhookEvent $event) {
                $event->update(['failed_at' => null, 'last_error' => null]);
                ProcessWebhookEvent::dispatch($event->id)->onQueue('webhooks');
            });
    }
}

Como o job confere processed_at sob lock e a máquina de estados ignora transições inválidas, rodar o replay sobre algo que já deu certo não causa estrago. Essa é a vantagem de ter idempotência em todas as camadas: o reprocessamento deixa de ser uma operação arriscada.

Duas rotinas agendadas completam o pipeline:

  1. Alerta de eventos parados. Um comando no scheduler conta eventos com processed_at nulo e received_at acima de alguns minutos, e avisa se passar de um limite. Fila travada aparece aqui antes de aparecer no suporte.
  2. Retenção. Payload de gateway carrega nome, e-mail, às vezes documento do comprador. Use o trait MassPrunable no model para apagar eventos processados depois do prazo que sua política de LGPD define. Mantenha só o necessário para auditoria.

Webhook sozinho é suficiente para manter o status em dia?

Não. Mesmo com tudo isso, alguns eventos nunca chegam: o provedor desiste depois de N tentativas durante uma janela de deploy, alguém troca a URL no painel, um certificado expira. Por isso trato webhook como o caminho rápido, não como o único. Um job periódico busca na API as cobranças que estão há muito tempo em estado intermediário, como pendente ou em processamento, e passa pelo mesmo apply. Como a lógica é a mesma e é idempotente, polling e webhook não brigam entre si. O que chegar primeiro aplica a transição, e o segundo vira no-op.

O que eu faria de novo em qualquer integração

Se eu fosse começar a integração com um gateway novo amanhã, a ordem seria esta:

  1. Middleware de assinatura sobre getContent(), com hash_equals e tolerância de timestamp quando o provedor manda um.
  2. Tabela webhook_events com índice único em provider + event_id, e createOrFirst no controller.
  3. Resposta 2xx imediata para tudo que passou na assinatura, inclusive duplicados e tipos desconhecidos.
  4. Fila dedicada, job com lockForUpdate e checagem de processed_at, chamadas externas fora da transação.
  5. Refetch na API para eventos financeiros e máquina de estados recusando regressão.
  6. Comando de replay, alerta de eventos parados e polling de segurança para os estados intermediários.

Nada disso é sofisticado, e é justamente por isso que vale fazer desde o primeiro dia. Adicionar a tabela de inbox depois que já existe pagamento duplicado em produção significa fazer a correção e, junto, uma conciliação manual de tudo o que passou antes dela.