Idempotency key: evite cobrar duas vezes no retry
Entenda por que retry de rede, duplo clique ou timeout podem reenviar a mesma requisição de pagamento, e como uma chave de idempotência garante que a segunda tentativa não processa a ação de novo.
Já toquei de raspão nesse assunto nos posts de API pra júnior e de webhook, mas idempotência merece um post próprio, porque é um dos conceitos que mais previne prejuízo real num sistema que lida com pagamento, estoque ou qualquer ação que não pode acontecer duas vezes por engano.
O cenário comum: a requisição que se repete sem querer
Imagina um endpoint de cobrança:
app.post("/pagamentos", async (req, res) => {
const cobranca = await gateway.cobrar(req.body.valor, req.body.cartao);
res.json({ id: cobranca.id, status: "aprovado" });
});O frontend chama esse endpoint, mas a resposta demora um pouco mais que o normal. A conexão cai, ou o timeout configurado no cliente estoura antes da resposta chegar. Do ponto de vista de quem chamou, não ficou claro se a cobrança realmente aconteceu ou não, então o comportamento comum (seja um retry automático de uma biblioteca HTTP, seja o usuário clicando de novo no botão "Pagar" por impaciência) é mandar a mesma requisição outra vez.
O problema é que a primeira cobrança pode ter sido processada com sucesso do lado do gateway, só a resposta que não chegou de volta a tempo. Reenviar a mesma requisição, sem nenhuma proteção, processa uma segunda cobrança real, idêntica, no cartão da pessoa.
O que idempotência significa aqui
Uma operação é idempotente quando executar ela uma vez ou várias vezes, com os mesmos parâmetros, produz exatamente o mesmo resultado, sem efeito colateral duplicado. Um GET buscando um dado já é naturalmente idempotente, não importa quantas vezes você busca a mesma coisa. Um POST criando uma cobrança não é, por padrão, porque cada chamada cria algo novo. A idempotency key existe justamente pra tornar esse tipo de operação segura contra repetição.
Como funciona na prática
O cliente gera um identificador único antes de fazer a primeira tentativa, e envia esse identificador num header junto da requisição:
const idempotencyKey = crypto.randomUUID();
await fetch("/pagamentos", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ valor: 100, cartao: "..." }),
});O detalhe importante é que esse mesmo identificador precisa ser reaproveitado em qualquer retry daquela mesma tentativa de pagamento específica, não gerado de novo a cada chamada. Se a requisição falhar por timeout e o cliente tentar de novo, ele reenvia com a mesma Idempotency-Key de antes, não uma nova.
Do lado do servidor, antes de processar a cobrança, a rota confere se já existe um registro daquela chave:
app.post("/pagamentos", async (req, res) => {
const chave = req.headers["idempotency-key"];
const existente = await db.query(
"SELECT resposta FROM idempotency_keys WHERE chave = $1",
[chave],
);
if (existente) {
return res.json(existente.resposta); // devolve o resultado já processado antes
}
const cobranca = await gateway.cobrar(req.body.valor, req.body.cartao);
const resposta = { id: cobranca.id, status: "aprovado" };
await db.query(
"INSERT INTO idempotency_keys (chave, resposta) VALUES ($1, $2)",
[chave, resposta],
);
res.json(resposta);
});Se a chave já existe, o servidor nunca chega a chamar o gateway de pagamento de novo, só devolve exatamente a resposta que já foi gerada na primeira tentativa, como se aquela segunda chamada nunca tivesse acontecido de fato. Isso resolve o retry de forma segura: não importa quantas vezes a mesma requisição chegue, com a mesma chave, só a primeira é realmente processada.
O cuidado com concorrência
Existe uma brecha sutil nesse desenho: se duas requisições com a mesma chave chegarem quase ao mesmo tempo, as duas podem passar pela checagem "não existe" antes de qualquer uma terminar de gravar o registro, criando a mesma condição de corrida que já expliquei aqui. A correção segue o mesmo princípio: usar uma constraint única na coluna chave do banco, fazendo a segunda tentativa de inserção falhar por violação de unicidade, em vez de depender só da leitura prévia feita no código da aplicação.
ALTER TABLE idempotency_keys ADD CONSTRAINT chave_unica UNIQUE (chave);Com essa constraint, mesmo que as duas requisições cheguem exatamente ao mesmo tempo, só uma delas consegue inserir o registro com sucesso, e a outra recebe um erro de violação que o código pode tratar buscando o resultado já salvo, em vez de processar o pagamento duas vezes.
Fechando
Idempotency key resolve um problema que não é sobre bug no código, é sobre a realidade de qualquer rede: requisição pode falhar, timeout pode acontecer, retry pode ser disparado por engano ou por design. Nenhuma dessas situações deveria custar uma cobrança duplicada, um pedido criado duas vezes, ou qualquer ação sensível repetida sem intenção. Uma chave gerada pelo cliente e checada no servidor, protegida por uma constraint de unicidade, é o que garante que repetir a mesma tentativa nunca produz um resultado diferente do que já aconteceu na primeira vez.
Direto na sua
caixa de entrada.
Um aviso por e-mail sempre que eu publicar um post novo. Sem spam, sem newsletter chata, só isso.
Leia também
- Claude Design: os 5 níveis de uso, do básico ao pro19 de outubro de 2026 · 6 min
- CSRF: o ataque que usa sua sessão contra você14 de outubro de 2026 · 4 min
- Event loop do Node: por que JS não trava sem thread13 de outubro de 2026 · 4 min
- Bolio UI: componentes React que a IA realmente entende12 de outubro de 2026 · 4 min