Pagar duas vezes porque a tela piscou: idempotência no pagamento
Como usar uma chave de idempotência para que a segunda tentativa de um pagamento devolva o resultado da primeira, em vez de cobrar de novo.
A pessoa clicou em "Pagar", a rede do celular caiu na volta da resposta e a tela ficou girando. Ela clicou de novo. No extrato, duas cobranças. O servidor fez exatamente o que foi pedido, duas vezes. A resposta curta: cada operação que só pode acontecer uma vez precisa de uma chave de idempotência, gerada no cliente antes do primeiro envio e repetida em toda nova tentativa. O servidor grava a chave junto com o resultado. Se a mesma chave chegar de novo, ele devolve o resultado guardado e não cobra outra vez.
Desabilitar o botão depois do clique ajuda, mas é conforto de interface. Não protege contra reenvio automático, aba duplicada, retry do aplicativo ou proxy que repete a requisição. A garantia tem de morar no servidor.
Por que um timeout não significa que não cobrou
Quando o cliente desiste de esperar, ele não sabe em que ponto a requisição parou. Existem pelo menos três cenários que, do lado de quem clicou, parecem iguais:
- A requisição nunca chegou ao servidor. Nada aconteceu.
- Chegou, o servidor está processando e ainda vai cobrar.
- Chegou, o servidor cobrou, e a resposta se perdeu no caminho de volta.
Repetir a requisição é a reação certa no primeiro caso e um desastre nos outros dois. Sem um identificador comum, o servidor não tem como distinguir "pedido novo" de "o mesmo pedido de novo". O método POST, pela especificação do HTTP, não é idempotente: duas chamadas iguais podem criar dois recursos. Idempotência é uma propriedade que você constrói em cima dele.
Os grandes processadores de pagamento já aceitam um cabeçalho para isso, em geral chamado Idempotency-Key, e existe um rascunho de padronização desse cabeçalho na IETF. O nome não importa tanto quanto a regra: a chave identifica a intenção, não a requisição.
Como implementar a chave de idempotência
- No cliente, gere um identificador único (um UUID) quando a pessoa inicia o pagamento, antes do primeiro envio. Guarde-o enquanto essa intenção existir, inclusive se a página recarregar.
- Envie a chave num cabeçalho em toda tentativa daquele mesmo pagamento.
- No servidor, tente gravar a chave numa tabela com restrição de unicidade, já marcada como "processando". O banco decide quem chegou primeiro.
- Se a gravação falhar porque a chave já existe, não processe. Devolva o resultado salvo, ou avise que ainda está em andamento.
- Ao terminar, salve o código HTTP e o corpo da resposta junto com a chave.
- Repasse a mesma chave ao provedor de pagamento, se ele aceitar. Assim a proteção vale também entre você e ele.
- Guarde também um hash do corpo do pedido. Mesma chave com conteúdo diferente é erro do cliente, não repetição.
A tabela
Exemplo em PostgreSQL. Em sistema com várias contas, inclua o identificador do cliente na chave primária, para que a chave de uma pessoa nunca colida com a de outra.
CREATE TABLE idempotencia (
chave text PRIMARY KEY,
hash_pedido text NOT NULL,
status text NOT NULL,
codigo_http integer,
resposta jsonb,
criado_em timestamptz NOT NULL DEFAULT now()
);
O servidor em Node.js com Express e pg
const express = require('express');
const crypto = require('crypto');
const { Pool } = require('pg');
const { cobrar } = require('./gateway');
const pool = new Pool();
const app = express();
app.use(express.json());
// Express 5 repassa ao tratador de erros a exceção de um handler async.
// No Express 4, envolva o handler num try/catch ou numa função que chame next(erro).
app.post('/pagamentos', async (req, res) => {
const chave = req.get('Idempotency-Key');
if (!chave) {
return res.status(400).json({ erro: 'Envie o cabeçalho Idempotency-Key' });
}
// Campos em ordem fixa: o hash não muda se o cliente reordenar o JSON.
const { pedidoId, valorCentavos, moeda } = req.body;
const pedido = { pedidoId, valorCentavos, moeda };
const hash = crypto.createHash('sha256')
.update(JSON.stringify([pedidoId, valorCentavos, moeda]))
.digest('hex');
const novo = await pool.query(
"INSERT INTO idempotencia (chave, hash_pedido, status) VALUES ($1, $2, 'processando') ON CONFLICT (chave) DO NOTHING",
[chave, hash]
);
if (novo.rowCount === 0) {
const { rows } = await pool.query(
'SELECT hash_pedido, status, codigo_http, resposta FROM idempotencia WHERE chave = $1',
[chave]
);
const anterior = rows[0];
if (anterior.hash_pedido !== hash) {
return res.status(422).json({ erro: 'Chave já usada com outro pedido' });
}
if (anterior.status === 'processando') {
return res.status(409).json({ erro: 'Pagamento em andamento, consulte em instantes' });
}
return res.status(anterior.codigo_http).json(anterior.resposta);
}
try {
const resultado = await cobrar(pedido, chave);
await pool.query(
"UPDATE idempotencia SET status = 'concluido', codigo_http = 201, resposta = $2 WHERE chave = $1",
[chave, resultado]
);
return res.status(201).json(resultado);
} catch (erro) {
if (erro.recusado) {
const corpo = { erro: 'Pagamento recusado' };
await pool.query(
"UPDATE idempotencia SET status = 'concluido', codigo_http = 402, resposta = $2 WHERE chave = $1",
[chave, corpo]
);
return res.status(402).json(corpo);
}
return res.status(503).json({ erro: 'Resultado incerto, consulte o status do pagamento' });
}
});
app.listen(3000);
O módulo ./gateway é a sua integração com o provedor: a função cobrar chama a API dele repassando a chave e lança um erro com recusado: true quando o provedor responde que o cartão foi negado. Repare no que o código faz com o erro desconhecido, como um timeout com o provedor: ele não apaga a chave nem marca como falha. O pagamento pode ter acontecido. A linha fica em "processando" até uma rotina de conciliação consultar o provedor, ou até o webhook dele confirmar o resultado. Se você tratar timeout como "não cobrou" e liberar a chave, volta a ter cobrança dupla, só que com mais passos. Para o webhook, veja como validar o webhook com segredo.
O cliente
const chavePagamento = crypto.randomUUID();
async function pagar(dados) {
const resposta = await fetch('/pagamentos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': chavePagamento
},
body: JSON.stringify(dados),
signal: AbortSignal.timeout(15000)
});
return resposta.json();
}
A chave nasce uma vez, fora da função. Cada chamada a pagar reusa a mesma. Uma nova chave só deve ser gerada quando a pessoa realmente começa outro pagamento, por exemplo depois de alterar o valor ou o carrinho. Prazo de espera no cliente é tema de outro guia: toda chamada externa precisa de um prazo para desistir.
Erros comuns
- Gerar a chave dentro da função de envio. Cada tentativa ganha uma chave nova e o servidor trata cada uma como pagamento diferente. A proteção existe só no papel.
- Verificar se a chave existe com SELECT e depois inserir. Duas requisições simultâneas passam pela verificação antes de qualquer uma gravar. Use a restrição de unicidade do banco como árbitro.
- Apagar a chave quando a chamada ao provedor dá timeout. O provedor pode ter cobrado. A próxima tentativa cobra de novo.
- Usar o hash do corpo como chave. Duas compras legítimas e idênticas, como dois cafés seguidos, viram uma só. A chave identifica a intenção, não o conteúdo.
- Confiar só no botão desabilitado. Recarregar a página, abrir em outra aba ou um retry automático da biblioteca passam por cima.
- Não definir validade para as chaves. A tabela cresce para sempre. Combine um prazo de retenção maior que o maior intervalo realista de nova tentativa e limpe o que passou dele.
Checklist
- A chave é gerada uma vez por intenção de pagamento, antes do primeiro envio.
- O servidor recusa pedido de pagamento sem chave.
- A unicidade é garantida por restrição no banco, não por consulta prévia.
- Repetir a mesma requisição devolve o mesmo código HTTP e o mesmo corpo da primeira.
- Mesma chave com corpo diferente devolve erro.
- Timeout com o provedor deixa o pagamento como incerto e dispara conciliação.
- A chave é repassada ao provedor, quando ele aceita.
- Os valores trafegam como inteiros em centavos, como em dinheiro não mora em ponto flutuante.
Faça o teste no seu ambiente de homologação: dispare a mesma requisição de pagamento duas vezes, com um segundo de intervalo, como se a primeira resposta tivesse se perdido. A segunda chamada devolve o resultado da primeira, ou aparece uma segunda cobrança no painel do provedor?
Referência: https://developer.mozilla.org/docs/Web/HTTP/Methods/POST