# Webhooks de pagamento duplicados e fora de ordem: como processar cada evento uma única vez no Laravel

> Gateways reenviam webhooks e nem sempre na ordem certa. Veja como combinar chave única, lock por pagamento e máquina de estados para nunca creditar duas vezes.

Por Lucas Roberto · Pagamentos · publicado em 2026-10-09 · 7 min de leitura
Versão web: https://lucasroberto.com/blog/webhooks-de-pagamento-duplicados-e-fora-de-ordem-como-processar-cada-evento-uma-unica-vez-no-laravel

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).

```php
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.

```php
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:

```php
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:

```php
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.

```php
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:

```php
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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
