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:

  1. Timeout no seu endpoint. Seu servidor processou, mas demorou para responder. O gateway considera falha e reenvia.
  2. Erro de rede na volta. Você respondeu 200, mas a resposta se perdeu no caminho.
  3. Retentativas com backoff. Qualquer resposta fora da faixa 2xx gera nova tentativa, às vezes por dias.
  4. Reenvio manual. Alguém do suporte clica em "reenviar webhooks" no painel do gateway depois de um incidente.
  5. Eventos diferentes para o mesmo fato. No Asaas, por exemplo, um pagamento pode gerar PAYMENT_CONFIRMED e depois PAYMENT_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:

  1. 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.
  2. 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:

  1. Ordem: a API retorna o estado mais recente, então um evento atrasado leva ao estado correto de qualquer forma.
  2. Payload incompleto: alguns gateways mandam só IDs ou campos parciais.
  3. 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:

  1. Existe constraint única por (provider, external_id) e o INSERT usa createOrFirst ou insertOrIgnore?
  2. O endpoint responde 2xx em poucos milissegundos e delega o trabalho para fila?
  3. Existe um sweeper para eventos pending esquecidos?
  4. O job trava o pagamento com lockForUpdate e deixa chamadas HTTP fora da transação?
  5. O status é uma máquina de estados e os efeitos colaterais dependem da transição, não do evento?
  6. 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.