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]);
}
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']);
});
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();
}
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)