Para processar webhooks de pagamento uma única vez, você precisa de três camadas: uma chave única no banco que impede gravar o mesmo evento duas vezes, um lock por pagamento que impede dois workers de mexer no mesmo registro ao mesmo tempo, e uma máquina de estados que ignora transições inválidas quando os eventos chegam fora de ordem. Nenhuma das três resolve o problema sozinha. No Laravel, isso cabe em uma tabela, um controller enxuto e um job com lockForUpdate.
Trabalhando com Pagar.me, Stripe, Asaas e Zoop, aprendi que o webhook duplicado não é exceção. É o comportamento esperado. Quem trata como caso raro descobre o problema no dia em que um cliente recebe crédito em dobro na conta digital.
Por que o gateway manda o mesmo webhook mais de uma vez?
Praticamente todo gateway trabalha com entrega at-least-once: a garantia é que o evento chega pelo menos uma vez, não exatamente uma vez. Os motivos mais comuns:
- Timeout no seu endpoint. Seu servidor processou, mas demorou para responder. O gateway considera falha e reenvia.
- Erro de rede na volta. Você respondeu 200, mas a resposta se perdeu no caminho.
- Retentativas com backoff. Qualquer resposta fora da faixa 2xx gera nova tentativa, às vezes por dias.
- Reenvio manual. Alguém do suporte clica em "reenviar webhooks" no painel do gateway depois de um incidente.
- Eventos diferentes para o mesmo fato. No Asaas, por exemplo, um pagamento pode gerar
PAYMENT_CONFIRMEDe depoisPAYMENT_RECEIVED. São eventos distintos, com IDs distintos, mas se seu código credita saldo nos dois, você creditou duas vezes.
O item 5 é o mais traiçoeiro, porque nenhuma deduplicação por ID de evento pega. É aí que entra a máquina de estados, que vou mostrar mais abaixo.
Onde deduplicar: no recebimento ou no processamento?
Nos dois, com objetivos diferentes. A tabela resume o que cada camada protege:
| Camada | O que impede | O que não impede |
|---|---|---|
Chave única por (provider, external_id) |
Gravar e enfileirar o mesmo evento duas vezes | Dois eventos diferentes sobre o mesmo pagamento |
| Lock pessimista no pagamento | Dois workers alterando o mesmo pagamento em paralelo | Transição inválida feita em sequência |
| Máquina de estados | Regressão de status e efeito colateral repetido | Corrida entre workers (sem o lock) |
Na prática, a chave única elimina a maior parte do ruído barato, e o lock mais a máquina de estados garantem a correção.
Como registrar o webhook com chave única no Laravel?
Crio uma tabela que guarda o evento cru antes de qualquer lógica de negócio. Ela também vira um log auditável, o que ajuda muito na conciliação (o processo de bater o que o gateway diz que aconteceu com o que seu sistema registrou).
Schema::create('webhook_events', function (Blueprint $table) { $table->id(); $table->string('provider'); $table->string('external_id'); $table->string('type'); $table->jsonb('payload'); $table->string('status')->default('pending'); $table->unsignedInteger('attempts')->default(0); $table->text('last_error')->nullable(); $table->timestamp('processed_at')->nullable(); $table->timestamps(); $table->unique(['provider', 'external_id']); });
O controller faz o mínimo: valida a assinatura, grava e enfileira.
public function __invoke(Request $request, string $provider) { $event = $this->verifiers->for($provider)->verify($request); // lança exceção se a assinatura for inválida $record = WebhookEvent::createOrFirst( ['provider' => $provider, 'external_id' => $event->id], ['type' => $event->type, 'payload' => $event->payload], ); if ($record->wasRecentlyCreated) { ProcessWebhookEvent::dispatch($record->id); } return response()->noContent(); }
Uso createOrFirst em vez de firstOrCreate de propósito. O firstOrCreate faz um SELECT e depois um INSERT, e entre os dois cabe outra requisição com o mesmo evento: as duas veem "não existe" e uma delas estoura com violação de unicidade. O createOrFirst (disponível a partir do Laravel 10) tenta o INSERT primeiro e, se a constraint reclamar, busca o registro existente. Quem decide é o banco, não o código.
Se você ainda não está numa versão com createOrFirst, insertOrIgnore seguido de um where resolve o mesmo problema.
Por que responder 2xx antes de processar o webhook?
Porque o tempo de resposta do seu endpoint é o que mais gera duplicatas. Se o processamento envolve consultar a API do gateway, atualizar saldo, disparar notificação no WhatsApp e gerar comprovante, você vai estourar o timeout do gateway em algum momento de pico. E aí ele reenvia um evento que você já processou.
Responder rápido tem um custo: se o dispatch falhar depois do INSERT (Redis fora do ar, por exemplo), o evento fica gravado como pending e ninguém processa. Resolvo isso com um comando agendado que varre eventos parados:
Schedule::call(function () { WebhookEvent::where('status', 'pending') ->where('created_at', '<', now()->subMinutes(5)) ->pluck('id') ->each(fn ($id) => ProcessWebhookEvent::dispatch($id)); })->everyFiveMinutes();
Esse sweeper só é seguro porque o job é idempotente (executar duas vezes produz o mesmo resultado que executar uma). Se o job não for, o sweeper vira mais uma fonte de duplicação.
Como evitar condição de corrida entre workers do Horizon?
Com vários workers no Horizon, dois eventos diferentes sobre o mesmo pagamento podem ser processados no mesmo milissegundo. Sem lock, os dois leem o status pending, os dois decidem creditar, os dois gravam.
A solução que uso é lock pessimista no registro do pagamento dentro de uma transação no PostgreSQL:
public function handle(PaymentGateway $gateway): void { $event = WebhookEvent::findOrFail($this->eventId); if ($event->status === 'processed') { return; } // Consulta a API do gateway FORA da transação $remote = $gateway->fetchCharge($event->payload['charge_id']); DB::transaction(function () use ($event, $remote) { $payment = Payment::where('gateway_id', $remote->id) ->lockForUpdate() ->firstOrFail(); $payment->transitionTo($remote->status); $event->update(['status' => 'processed', 'processed_at' => now()]); }); }
Dois detalhes importantes:
- A chamada HTTP fica fora da transação. Segurar um lock de linha enquanto espera a resposta de uma API externa é pedir para travar o banco quando o gateway estiver lento.
- O lock é no pagamento, não no evento. O que precisa ser serializado é a alteração de estado do pagamento, independentemente de qual evento a provocou.
Uma alternativa é o middleware WithoutOverlapping do Laravel com a chave do pagamento, que usa lock atômico no Redis. Funciona, mas prefiro o lock no PostgreSQL porque ele vive na mesma transação que grava o resultado. Lock no Redis com escrita no banco abre uma janela em que o lock expira e a transação ainda não terminou.
O que fazer quando os webhooks chegam fora de ordem?
Gateways não garantem ordem. É perfeitamente possível receber paid antes de pending, ou um refunded antes do paid ter sido processado por causa de uma retentativa. Se seu código simplesmente faz $payment->status = $evento->status, um evento atrasado faz o pagamento regredir.
Por isso modelo o status como máquina de estados: um conjunto fechado de estados e as transições permitidas entre eles.
enum PaymentStatus: string { case Pending = 'pending'; case Paid = 'paid'; case Refunded = 'refunded'; case Failed = 'failed'; public function canTransitionTo(self $next): bool { return match ($this) { self::Pending => in_array($next, [self::Paid, self::Failed]), self::Paid => $next === self::Refunded, self::Refunded, self::Failed => false, }; } }
E no model:
public function transitionTo(PaymentStatus $next): void { if ($this->status === $next || ! $this->status->canTransitionTo($next)) { return; // evento repetido ou atrasado: ignora sem erro } $this->update(['status' => $next]); if ($next === PaymentStatus::Paid) { $this->account->credit($this->net_amount, reference: $this); } }
O efeito colateral (o crédito) está amarrado à transição, não ao evento. Não importa se chegaram PAYMENT_CONFIRMED, PAYMENT_RECEIVED e três reenvios manuais: a transição pending → paid só acontece uma vez, então o crédito só acontece uma vez.
Como cinto de segurança extra, o lançamento de crédito também tem chave única por (reference_type, reference_id, kind). Se algum dia alguém escrever código que pule a máquina de estados, o banco recusa o segundo lançamento.
Devo confiar no payload do webhook?
Para decidir o estado, não. Trato o webhook como uma notificação de que algo mudou, e busco o estado atual na API do gateway (o fetchCharge do exemplo). Isso resolve de uma vez três problemas:
- Ordem: a API retorna o estado mais recente, então um evento atrasado leva ao estado correto de qualquer forma.
- Payload incompleto: alguns gateways mandam só IDs ou campos parciais.
- Falsificação: mesmo que a validação de assinatura falhe por algum bug, o estado vem de uma chamada autenticada sua.
O trade-off é custo e latência: uma requisição extra por evento e exposição ao rate limit do gateway. Em volume alto, vale filtrar antes quais tipos de evento realmente exigem consulta. Eventos puramente informativos podem ser gravados e ignorados.
Conclusão prática
Se eu fosse revisar hoje uma integração de webhooks de pagamento, olharia nesta ordem:
- Existe constraint única por
(provider, external_id)e o INSERT usacreateOrFirstouinsertOrIgnore? - O endpoint responde 2xx em poucos milissegundos e delega o trabalho para fila?
- Existe um sweeper para eventos
pendingesquecidos? - O job trava o pagamento com
lockForUpdatee deixa chamadas HTTP fora da transação? - O status é uma máquina de estados e os efeitos colaterais dependem da transição, não do evento?
- O lançamento financeiro tem chave única própria como última barreira?
Se a resposta for "não" para o item 5, comece por ele. É o que mais protege dinheiro de verdade, porque cobre o caso que nenhuma deduplicação por ID enxerga: dois eventos legítimos e diferentes descrevendo o mesmo pagamento.