DEV Community

Denis Augusto
Denis Augusto

Posted on

Idempotência: a palavra difícil que salva seu webhook

O cliente pagou uma vez. Seu sistema registrou duas.

Sexta-feira, seis da tarde. Chega a mensagem no suporte: "fui cobrado duas vezes".

Você abre o banco e é pior do que parecia. Não foi o gateway que cobrou duas vezes — foi o seu sistema que criou dois pagamentos, liberou dois créditos e mandou dois e-mails de confirmação. O cliente pagou uma única vez.

Você vai no log do webhook. Dois POST /webhooks/pagamento. Mesmo event_id. Intervalo de 40 segundos.

E aí cai a ficha: o gateway não errou. Ele reenviou. É o comportamento documentado dele. Quem errou foi seu código, que tratou o segundo envio como se fosse um pagamento novo.

Por que todo webhook é reenviado

Isso não é bug do provedor, é o desenho do sistema. Stripe, Mercado Pago, Asaas, PagSeguro — todos funcionam igual: eles te enviam o evento e esperam um 2xx. Se não vier 2xx em alguns segundos, eles tentam de novo. E de novo.

O detalhe cruel: seu processamento pode ter dado certo e a resposta não chegar. Timeout de 30 segundos porque você mandou e-mail dentro do request. Deploy no meio do caminho. Nginx devolvendo 502 depois do commit no banco.

Do lado do gateway, isso é indistinguível de falha. Então ele reenvia. E seu código roda tudo de novo.

O webhook que aceita tudo

public function handle(Request $request)
{
    // "chegou evento, então é pagamento novo" 🙃
    $pagamento = Pagamento::create([
        'pedido_id' => $request->input('data.pedido_id'),
        'valor'     => $request->input('data.valor'),
        'status'    => 'pago',
    ]);

    $pagamento->pedido->liberarAcesso();

    Mail::to($pagamento->pedido->cliente)->send(new PagamentoConfirmado($pagamento));

    return response()->json(['ok' => true]);
}
Enter fullscreen mode Exit fullscreen mode

Funciona perfeitamente no teste. Funciona no primeiro envio real.

No reenvio, cria o segundo pagamento, libera acesso de novo e dispara o segundo e-mail. E como esse método é lento (e-mail dentro do request!), ele aumenta a chance de timeout — ou seja, aumenta a chance de reenvio. O código causa o próprio problema.

Idempotência em uma frase

A palavra assusta, o conceito é bobo: uma operação idempotente pode rodar dez vezes e o efeito é o mesmo de ter rodado uma.

É o botão de elevador. Você aperta o 5 uma vez ou quinze vezes com raiva — o elevador vai uma vez pro quinto andar.

Um DELETE /pedidos/10 é naturalmente idempotente: apagou uma vez, apagar de novo não muda nada. Um INSERT não é. E webhook quase sempre virou INSERT na sua mão.

O que você precisa é de uma forma de perguntar "eu já vi esse evento antes?" — e a resposta tem que ser confiável mesmo com dois requests chegando ao mesmo tempo.

A solução: deixe o banco decidir

Todo provedor manda um ID único no evento. É esse ID que você guarda.

Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider');
    $table->string('event_id');
    $table->timestamps();

    // o guarda de verdade mora aqui
    $table->unique(['provider', 'event_id']);
});
Enter fullscreen mode Exit fullscreen mode

E o controller fica assim:

use Illuminate\Database\UniqueConstraintViolationException;

public function handle(Request $request)
{
    try {
        WebhookEvent::create([
            'provider' => 'gateway',
            'event_id' => $request->input('id'),
        ]);
    } catch (UniqueConstraintViolationException) {
        // já processamos esse evento. responde 200 pra ele parar de reenviar.
        return response()->noContent();
    }

    ProcessarPagamento::dispatch($request->input('id'), $request->all());

    return response()->noContent();
}
Enter fullscreen mode Exit fullscreen mode

Três coisas mudaram, e cada uma resolve um problema diferente:

1. A checagem é um INSERT, não um SELECT. Essa é a parte que quase todo mundo erra. if (WebhookEvent::where('event_id', $id)->exists()) parece resolver, mas não resolve: dois requests simultâneos passam os dois pelo if antes de qualquer um inserir. O unique no banco é o único lugar onde essa disputa tem um vencedor só.

2. Reenvio devolve 200, não erro. Evento repetido não é falha — é o gateway fazendo o trabalho dele. Se você responder 500, ele reenvia mais ainda.

3. O trabalho pesado foi pra fila. O controller agora responde em milissegundos. Menos timeout, menos reenvio, e o e-mail que travava tudo saiu do caminho do request.

Como usar na prática

API pública sua: aceite um header Idempotency-Key do cliente e guarde na mesma tabela. Assim o app mobile pode repetir o POST /pedidos sem medo quando o 4G cair no meio.

Jobs na fila: job também repete. Se o worker morre depois do charge() e antes do save(), o retry cobra de novo. A mesma guarda vale ali dentro.

Importação de planilha: updateOrCreate com uma chave natural (CPF, código externo) em vez de create. Rodar a importação duas vezes deixa de duplicar a base.

A pegadinha: ShouldBeUnique não é idempotência

Vejo essa confusão direto. Marcar o job com ShouldBeUnique evita dois jobs iguais na fila ao mesmo tempo. Ele solta o lock quando o job termina.

Se o webhook chegar de novo cinco minutos depois — e vai — o job entra tranquilo e processa tudo outra vez. ShouldBeUnique é controle de concorrência, não memória de longo prazo. A tabela com unique é a memória.

Bônus: não confie no corpo do evento

Enquanto você está aí, uma segunda blindagem que custa pouco: valide a assinatura do webhook (todo provedor manda uma) e, para operações sensíveis, use o evento só como gatilho.

Em vez de confiar no valor que veio no payload, pegue o ID e consulte a API do gateway pra saber o estado real. Payload é aviso; a fonte da verdade é o provedor.

E confira se sua rota de webhook está fora do VerifyCsrfToken e do throttle agressivo — dá pra passar um bom tempo caçando um "webhook que não chega" que na verdade está tomando 419 ou 429.

Antes de você fechar a aba

Idempotência é uma daquelas coisas que ninguém implementa antes do primeiro incidente. E o primeiro incidente costuma envolver dinheiro do cliente.

O bom é que o custo de acertar é ridículo: uma tabela com dois campos, uma constraint unique e um try/catch. Vinte minutos hoje contra uma sexta-feira inteira depois.

Agora me conta: qual foi o seu evento duplicado? Cobrança em dobro, e-mail repetido, estoque baixado duas vezes? Eu já vi um webhook de nota fiscal emitir três notas pro mesmo pedido — e o contador descobriu antes do dev. 😅

Top comments (0)