Todo gateway de pagamento entrega webhooks com garantia at-least-once e sem garantia de ordem. Na prática, o mesmo evento chega duas ou três vezes, e o charge.paid pode chegar antes do charge.created. A solução que uso é o padrão inbox: gravar todo webhook recebido numa tabela com chave única pelo ID do evento, responder 200 imediatamente e processar depois, num job que serializa por recurso e decide o que fazer com eventos atrasados.

O inbox é o espelho do outbox. O outbox garante que você não perde eventos que publica. O inbox garante que você não processa duas vezes, nem na ordem errada, os eventos que recebe. Se você já leu o post sobre transactional outbox no Laravel, a mecânica vai parecer familiar.

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

O primeiro instinto é receber o POST, validar e atualizar o pagamento ali mesmo. Funciona no ambiente de teste e começa a quebrar em produção por quatro motivos:

  1. Timeout do provedor. Pagar.me, Stripe, Asaas e Zoop esperam uma resposta 2xx em poucos segundos. Se o seu processamento consulta outra API, dispara e-mail ou recalcula saldo, você estoura o tempo, o provedor considera falha e reenvia.
  2. Reenvio é normal, não exceção. Mesmo quando você responde 200, uma falha de rede no caminho de volta faz o provedor achar que não entregou. O evento chega de novo.
  3. Concorrência. Dois eventos do mesmo pagamento chegam em milissegundos de diferença, caem em dois workers PHP-FPM diferentes e os dois leem o mesmo estado antigo.
  4. Sem histórico. Quando um cliente reclama que o pagamento não confirmou, você não tem o payload original para investigar nem um jeito simples de reprocessar.

O inbox resolve os quatro de uma vez: o controller vira só uma porta de entrada rápida, e todo o resto acontece em background com o evento persistido.

Como modelar a tabela de inbox no PostgreSQL?

A peça central é a restrição de unicidade em (provider, event_id). É ela, e não um if no código, que garante a deduplicação. Qualquer verificação do tipo "já existe?" seguida de insert tem janela de corrida; o índice único não tem.

Schema::create('webhook_inbox', function (Blueprint $table) {
    $table->id();
    $table->string('provider', 30);
    $table->string('event_id', 150);
    $table->string('event_type', 80);
    $table->string('resource_id', 150)->nullable();
    $table->jsonb('payload');
    $table->timestampTz('occurred_at')->nullable();
    $table->timestampTz('received_at')->useCurrent();
    $table->string('status', 20)->default('pending');
    $table->unsignedSmallInteger('attempts')->default(0);
    $table->text('last_error')->nullable();
    $table->timestampTz('processed_at')->nullable();

    $table->unique(['provider', 'event_id']);
    $table->index(['status', 'received_at']);
    $table->index(['provider', 'resource_id']);
});

Alguns campos merecem explicação:

  • event_id: o identificador do evento (não do recurso) enviado pelo provedor. No Stripe é o evt_.... É diferente do ID da cobrança: uma cobrança gera vários eventos.
  • resource_id: o ID da cobrança, transferência ou conta a que o evento se refere. É a chave que uso para serializar o processamento.
  • occurred_at: quando o evento aconteceu no provedor, não quando chegou. É o que permite detectar evento atrasado.
  • status: pending, processed, ignored ou failed. O ignored é importante e volto nele mais adiante.

E quando o provedor não manda um ID de evento?

Nem todo provedor envia um identificador único por evento. Nesses casos eu derivo uma chave estável a partir do que identifica o fato de negócio: sha256(resource_id + event_type + status + occurred_at). Evito fazer hash do corpo inteiro, porque alguns provedores incluem campos como data de envio ou número da tentativa, e aí cada reenvio gera um hash diferente e a deduplicação some. O trade-off é que a chave derivada depende de você entender o payload; vale documentar a regra por provedor no próprio driver.

Como receber o webhook e responder rápido?

O controller faz três coisas, nessa ordem: valida a assinatura, grava no inbox com insertOrIgnore e despacha o job só se a linha for nova.

class WebhookController
{
    public function __construct(private WebhookDriverRegistry $drivers) {}

    public function __invoke(Request $request, string $provider)
    {
        $driver = $this->drivers->for($provider);

        if (! $driver->verifySignature($request)) {
            abort(401);
        }

        $event = $driver->parse($request); // DTO normalizado

        $inserted = DB::table('webhook_inbox')->insertOrIgnore([
            'provider'    => $provider,
            'event_id'    => $event->id,
            'event_type'  => $event->type,
            'resource_id' => $event->resourceId,
            'payload'     => $request->getContent(),
            'occurred_at' => $event->occurredAt,
        ]);

        if ($inserted === 1) {
            ProcessWebhookEvent::dispatch($provider, $event->id, $event->resourceId)
                ->onQueue('webhooks');
        }

        return response()->json(['received' => true]);
    }
}

No PostgreSQL, o insertOrIgnore do Laravel vira INSERT ... ON CONFLICT DO NOTHING e retorna o número de linhas afetadas. Se veio 0, é duplicata: respondo 200 do mesmo jeito, porque para o provedor o evento foi entregue com sucesso. Responder erro para duplicata só faz ele tentar de novo.

A validação de assinatura precisa usar o corpo cru ($request->getContent()), antes de qualquer parse. Reserializar o JSON muda espaços e ordem de chaves e o HMAC deixa de bater. Quando estou integrando um provedor novo e a assinatura não confere, colo o payload, o segredo e o header no testador de webhook para descobrir se o erro está no segredo, no encoding ou no algoritmo. Alguns provedores mandam um token fixo no header em vez de HMAC; nesse caso a comparação ainda deve ser com hash_equals, para não abrir espaço para timing attack.

Um detalhe: a fila webhooks deve ter workers próprios no Horizon. Se ela dividir worker com job de relatório, um pico de exportação atrasa a confirmação de pagamento. Escrevi sobre isso em bulkhead de filas no Laravel Horizon.

O que acontece se o Redis cair entre o insert e o dispatch?

O evento fica gravado como pending e nenhum job é criado. É exatamente por isso que o inbox é mais robusto que despachar direto: o dado está no PostgreSQL, então dá para recuperar. Um comando agendado varre eventos pendentes antigos e redespacha:

// routes/console.php
Schedule::call(function () {
    DB::table('webhook_inbox')
        ->where('status', 'pending')
        ->where('received_at', '<', now()->subMinutes(5))
        ->orderBy('received_at')
        ->limit(500)
        ->get(['provider', 'event_id', 'resource_id'])
        ->each(fn ($e) => ProcessWebhookEvent::dispatch(
            $e->provider, $e->event_id, $e->resource_id
        )->onQueue('webhooks'));
})->everyFiveMinutes()->withoutOverlapping();

Esse sweeper pode despachar um evento que já tem job na fila. Não tem problema, porque o job é idempotente: ele confere o status antes de agir. Redespachar demais é barato; deixar evento parado é caro.

Como processar o evento sem duplicar efeito?

O job abre uma transação, trava a linha do inbox com lockForUpdate, confere se ainda está pending, aplica o efeito no domínio e marca como processado. Tudo na mesma transação: ou o pagamento muda e o inbox fica processed, ou nada muda.

class ProcessWebhookEvent implements ShouldQueue
{
    use Queueable;

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

    public function __construct(
        public string $provider,
        public string $eventId,
        public ?string $resourceId,
    ) {}

    public function handle(WebhookEventRouter $router): void
    {
        DB::transaction(function () use ($router) {
            $inbox = WebhookInbox::where('provider', $this->provider)
                ->where('event_id', $this->eventId)
                ->lockForUpdate()
                ->firstOrFail();

            if ($inbox->status !== 'pending') {
                return; // já processado ou ignorado por outro worker
            }

            $outcome = $router->handle($inbox); // processed | ignored

            $inbox->update([
                'status'       => $outcome,
                'attempts'     => $inbox->attempts + 1,
                'processed_at' => now(),
            ]);
        });
    }

    public function failed(Throwable $e): void
    {
        WebhookInbox::where('provider', $this->provider)
            ->where('event_id', $this->eventId)
            ->update(['status' => 'failed', 'last_error' => $e->getMessage()]);
    }
}

O lock na linha do inbox resolve o caso de dois jobs para o mesmo evento. Mas ele não resolve dois eventos diferentes do mesmo pagamento chegando juntos, que é o cenário de entrega fora de ordem. Para isso o handler precisa travar o recurso de domínio:

class ChargePaidHandler
{
    public function __invoke(WebhookInbox $inbox): string
    {
        $payment = Payment::where('gateway_charge_id', $inbox->resource_id)
            ->lockForUpdate()
            ->first();

        if (! $payment) {
            throw new PaymentNotYetKnown($inbox->resource_id); // retry com backoff
        }

        if ($payment->last_gateway_event_at?->gte($inbox->occurred_at)) {
            return 'ignored'; // evento mais velho que o estado atual
        }

        if (! $payment->status->canTransitionTo(PaymentStatus::Paid)) {
            return 'ignored';
        }

        $payment->markAsPaid(occurredAt: $inbox->occurred_at);

        return 'processed';
    }
}

Repare na exceção PaymentNotYetKnown. Ela cobre o caso em que o webhook chega antes de você ter gravado o pagamento localmente, o que acontece quando a resposta síncrona da criação da cobrança demora mais que o webhook. Lançar exceção devolve o job para a fila com backoff, e na segunda ou terceira tentativa o pagamento já existe.

Quando o handler mexe em mais de uma tabela e não existe uma linha óbvia para travar, uso o middleware WithoutOverlapping do Laravel com a chave do recurso (new WithoutOverlapping($this->resourceId) com releaseAfter). Ele serializa jobs do mesmo recurso via cache lock no Redis. Prefiro o lockForUpdate sempre que dá, porque o lock vive na mesma transação do dado e não depende de TTL.

Como lidar com webhook que chega fora de ordem?

Esse é o ponto em que mais vejo implementação ingênua: o sistema aplica cada evento na ordem em que chega e um charge.pending atrasado rebaixa um pagamento que já estava pago. Existem três estratégias, e elas não são excludentes:

Estratégia Como funciona Vantagem Custo
Máquina de estados Rejeita transições inválidas, como paid voltando para pending Simples, protege o domínio mesmo contra bugs Não resolve transições válidas em ordem errada
Comparar occurred_at Guarda o instante do último evento aplicado e ignora os mais velhos Barato, sem chamada externa Depende do relógio e da precisão do provedor
Refetch do recurso Trata o webhook só como aviso e consulta GET /charges/{id} Sempre aplica o estado verdadeiro Uma chamada de API por evento, sujeita a rate limit

Uso a máquina de estados sempre, como guarda de última linha. Escrevi sobre ela em máquina de estados para status de pagamento no Laravel. Por cima, escolho entre comparar occurred_at e refetch conforme o provedor:

  1. Se o provedor manda timestamp confiável e com boa precisão, comparo occurred_at. É o caminho mais barato.
  2. Se o timestamp é só em segundos ou não existe, ou se o recurso tem muitas transições (estornos parciais, disputas), faço refetch.
  3. Se o evento é de alto impacto financeiro, como liberação de saldo ou chargeback, faço refetch mesmo com timestamp bom. Prefiro pagar uma chamada a mais do que confiar no payload.

No refetch, a chamada à API do gateway fica fora da transação. Buscar o estado com lock aberto segura a linha durante a latência de rede e, se o gateway ficar lento, você empilha locks no PostgreSQL. O fluxo fica: buscar o estado atual, abrir a transação, travar o pagamento, aplicar se a versão buscada for mais nova que a local. A chamada sai do job, então ela precisa respeitar o rate limit do provedor e um circuit breaker quando ele estiver fora do ar.

Por que o status ignored existe?

Porque "descartei de propósito" e "deu erro" são coisas diferentes, e misturar as duas polui o monitoramento. Um evento ignored foi avaliado e não tinha nada a mudar: duplicata semântica, evento atrasado, tipo que não interessa. Um failed esgotou as tentativas e precisa de gente olhando. Meu alerta dispara para failed, nunca para ignored. Ainda assim, acompanho a proporção de ignorados por provedor: se ela sobe de repente, normalmente o provedor mudou alguma coisa no formato ou na ordem de envio.

Como reprocessar eventos que falharam?

Essa é a vantagem que mais aparece no dia a dia. Com o payload original gravado, reprocessar vira um comando:

Artisan::command('webhooks:replay {provider} {--since=} {--status=failed}', function () {
    WebhookInbox::where('provider', $this->argument('provider'))
        ->where('status', $this->option('status'))
        ->when($this->option('since'), fn ($q, $since) => $q->where('received_at', '>=', $since))
        ->update(['status' => 'pending', 'last_error' => null]);

    $this->info('Eventos voltaram para pending; o sweeper vai despachar.');
});

O cenário típico: um deploy introduz um bug no handler de estorno, os eventos falham por algumas horas, você corrige e roda o replay. Como os handlers são idempotentes e protegidos pela máquina de estados, reprocessar um lote grande não duplica nada. Sem o inbox, a alternativa seria pedir ao provedor para reenviar, o que nem todo painel oferece, ou reconstruir o estado na mão a partir da conciliação.

Quanto tempo guardar os eventos no inbox?

A tabela cresce rápido, porque cada cobrança gera vários eventos. Mantenho duas regras:

  1. A janela de deduplicação precisa ser maior que a janela de reenvio do provedor. Se ele tenta por até alguns dias, apagar o evento antes disso faz um reenvio tardio passar como novo. Na dúvida, guardo bem mais que o maior prazo documentado entre os provedores.
  2. O payload completo tem valor de auditoria e suporte. Depois da janela operacional, movo para armazenamento frio em vez de simplesmente apagar.

Quando o volume justifica, particionar a tabela por mês em received_at deixa a limpeza barata: você descarta partição inteira em vez de rodar DELETE grande. O custo é que a restrição única no PostgreSQL particionado precisa incluir a coluna de partição, então a deduplicação passa a valer só dentro da partição. Se isso for problema, mantenho a tabela sem partição e faço a limpeza em lotes pequenos agendados.

Quando o inbox é exagero?

Para um webhook que só dispara uma notificação interna, sem efeito financeiro, o inbox completo é cerimônia demais: um job na fila com ShouldBeUnique costuma bastar. Eu adoto o padrão completo quando o evento muda saldo, status de pagamento, limite de crédito ou qualquer coisa que entre na conciliação. É onde duplicata e ordem errada viram dinheiro errado.

Conclusão prática

Se você for implementar amanhã, a ordem que eu seguiria é esta: crie a tabela com índice único em (provider, event_id) e troque o processamento síncrono do controller por insertOrIgnore e dispatch. Só isso já elimina a cobrança confirmada duas vezes e o timeout do provedor. Em seguida, coloque lockForUpdate no recurso de domínio dentro do handler e a máquina de estados como guarda. Depois adicione o sweeper de pendentes e o comando de replay, que são as duas peças que vão te salvar no primeiro incidente. Por último, decida provedor por provedor entre comparar occurred_at e fazer refetch, começando pelo refetch nos eventos que mexem em saldo. A regra que fica por trás de tudo: o webhook é um aviso de que algo mudou, não a fonte da verdade, e seu sistema precisa continuar correto mesmo recebendo o mesmo aviso três vezes e na ordem errada.