Idempotência no PIX: o erro de arquitetura que duplica pagamento
Todo sistema de pagamento que não trata idempotência vai pagar duas vezes — é questão de volume, não de sorte. O culpado quase nunca é o PIX: é o retry sobre um timeout que na verdade tinha dado certo.
O bug que só aparece quando dá certo
O relato é sempre parecido. O sistema roda meses sem incidente. Aí vem um pico — Black Friday, fechamento de mês, campanha — e aparecem pagamentos duplicados. Mesma transação, dois débitos, dois comprovantes.
O time olha o log e não encontra bug de lógica. O código está certo. E está mesmo.
O que aconteceu foi isto:
`` Aplicação ──── POST /pix ────▶ PSP │ ├── processa ├── DÉBITA A CONTA ✓ │ └── resposta ✗ (timeout de rede) Aplicação ← ← ← ← ← ← ← ← ← ← ← ← ← │ │ "não recebi resposta, então falhou" │ └──── POST /pix (retry) ───▶ PSP └── DÉBITA DE NOVO ✓ ``
O timeout não disse "falhou". Ele disse "não sei". E tratar "não sei" como "falhou" é o que duplica o pagamento.
Isso é especialmente cruel no PIX porque a liquidação é imediata e irrevogável. Não existe janela de cancelamento antes da compensação, como em boleto ou em cartão pré-autorizado. Debitou, saiu. A correção vira devolução — outra transação, com outra reconciliação, outro atrito com o cliente.
---
As três respostas possíveis, e o erro de tratar duas como uma
Toda chamada a um sistema de pagamento tem três desfechos:
| Resposta | Significado | O que fazer | |---|---|---| | 2xx | sucesso confirmado | seguir | | 4xx | falha determinística (dado inválido, saldo insuficiente) | não repetir — vai falhar igual | | timeout / 5xx / conexão caiu | estado desconhecido | consultar antes de repetir |
O bug nasce quando a terceira linha é implementada como se fosse a segunda. Retry cego sobre estado desconhecido é a definição do problema.
---
Idempotência: a mesma chamada, o mesmo efeito
Uma operação é idempotente quando executá-la N vezes produz o mesmo efeito de executá-la uma vez. Você consegue isso com uma chave de idempotência: um identificador que o cliente gera, envia junto e repete em todas as tentativas da mesma intenção de pagamento.
``` POST /pagamentos Idempotency-Key: 7f3a9c2e-...
1ª chamada → processa, guarda (chave → resultado), devolve 201 2ª chamada → encontra a chave, NÃO processa, devolve o MESMO resultado ```
Três detalhes decidem se isso funciona ou vira teatro:
A chave é da intenção, não da requisição. Ela precisa ser gerada uma vez, quando o usuário aperta "pagar", e sobreviver a todas as tentativas. Se você gerar um UUID novo a cada retry, tem chave de idempotência e não tem idempotência.
O registro da chave e o efeito precisam ser atômicos. Se você grava a chave numa transação e debita em outra, existe uma janela em que o processo morre no meio e você fica com o pior dos dois mundos. Uma transação só, ou nenhuma garantia.
A chave precisa persistir mais que a janela de retry. Guardar em memória de processo não resolve: o retry pode cair em outra instância. Precisa ser armazenamento compartilhado, com TTL maior que o maior retry possível — 24h é um ponto de partida razoável.
---
O que fazer quando o estado é desconhecido
Idempotência protege a escrita. Falta decidir o que fazer com a incerteza. A regra:
Diante de estado desconhecido, consulte antes de repetir.
`` timeout │ ▼ consultar por end-to-end ID / chave de idempotência │ ├── existe e liquidou → registrar como sucesso, não repetir ├── existe e falhou → pode repetir com NOVA intenção └── não existe → repetir com a MESMA chave ``
Isso pressupõe que você guarde o identificador antes de chamar o PSP. Guardar depois da resposta é o mesmo erro de novo: se a resposta não chega, você não tem por onde consultar.
Ordem correta:
```
- gerar e PERSISTIR a intenção (status: pendente, com chave)
- chamar o PSP
- atualizar a intenção com o resultado
```
Nunca 2 → 1.
---
Retry sem backoff é ataque ao seu próprio provedor
Repetir imediatamente, em loop, no momento em que o PSP está degradado, é adicionar carga a um sistema que já está sofrendo — e a sua fila cresce junto.
O mínimo aceitável: backoff exponencial com jitter e teto de tentativas.
`` tentativa 1 → espera ~1s tentativa 2 → espera ~2s tentativa 3 → espera ~4s tentativa 4 → espera ~8s depois disso → fila de conciliação, intervenção humana ``
O jitter (variação aleatória) evita que mil clientes que falharam juntos voltem exatamente juntos. Sem ele, você constrói uma onda que derruba o provedor no momento em que ele tenta se recuperar.
E retry infinito não existe. Depois do teto, a operação vai para conciliação — não fica girando para sempre.
---
Webhook não substitui conciliação
Muita implementação assume que o webhook do PSP é a fonte da verdade. Webhook se perde, chega fora de ordem, chega duplicado e às vezes chega antes de a sua própria transação ter commitado.
Por isso:
- Trate o webhook como idempotente também. Mesmo evento pode chegar três vezes.
- Não confie na ordem. O evento de liquidação pode chegar antes do de criação.
- Tenha conciliação ativa. Um processo que, periodicamente, lista as transações pendentes há mais de X minutos e consulta o estado real no PSP. É esse processo que fecha o buraco quando o webhook falha — e ele vai falhar.
---
Checklist
- [ ] Chave de idempotência gerada na intenção, não na requisição
- [ ] Chave persistida antes da chamada ao PSP
- [ ] Registro da chave e efeito na mesma transação
- [ ] Armazenamento compartilhado, TTL > maior janela de retry
- [ ] Timeout tratado como desconhecido, nunca como falha
- [ ] Consulta de estado antes de qualquer repetição
- [ ] Backoff exponencial com jitter e teto de tentativas
- [ ] Webhook idempotente e tolerante a fora-de-ordem
- [ ] Conciliação ativa para pendentes
- [ ] Alerta para transações presas em pendente
Nenhum item é exótico. É que cada um deles só cobra a conta sob concorrência — e sob concorrência é sempre com dinheiro de cliente.