Blog

Chave de idempotência no checkout: cobrar uma vez sem travar o fluxo

O cliente clicou em finalizar compra, a resposta demorou, ele clicou de novo, e o extrato mostrou duas cobranças de trezentos e quarenta reais. O time colocou uma chave de idempotência, o problema da cobrança dupla acabou, e nasceu outro: a segunda requisição passou a esperar quarenta segundos pela primeira, e o cliente que desistia com duas cobranças passou a desistir com uma tela travada. Este artigo trata dos dois problemas juntos: por que a chave precisa ser gerada pelo cliente antes do primeiro envio e não pelo servidor, o que acontece quando duas requisições com a mesma chave chegam ao mesmo tempo e por que a resposta correta é um estado e não uma espera, por que a impressão digital do corpo é obrigatória para não devolver o resultado errado, como armazenar a resposta para repetir a mesma saída sem repetir o efeito, e quais testes de concorrência provam que a implementação funciona antes de o cliente descobrir.

2026-08-31 / Integrações / 16 min

01

A chave nasce no cliente, antes da primeira tentativa

A primeira decisão de projeto define se o resto funciona ou não: quem gera a chave. Se ela é gerada pelo servidor e devolvida ao cliente, a proteção só passa a valer depois que a primeira resposta chegou, e o caso que mais produz cobrança dupla é exatamente o caso em que a primeira resposta nunca chega. O cliente que perdeu a conexão no meio do envio não recebeu chave nenhuma, então a segunda tentativa dele é indistinguível de uma compra nova.

A chave tem que existir antes do primeiro byte sair do navegador. Na prática ela é criada no momento em que a tela de pagamento é montada e permanece a mesma enquanto o usuário estiver naquela tentativa de compra, sobrevivendo a um recarregamento da página se for guardada no armazenamento da sessão. Um identificador aleatório de cento e vinte e oito bits basta, e ele deve mudar apenas quando o usuário voltar e alterar algo do pedido, porque nesse momento a intenção passou a ser outra.

O erro simétrico é derivar a chave de dados do pedido, como o identificador do carrinho somado ao valor total. Parece elegante porque dispensa armazenar estado no cliente, e falha no caso legítimo em que a mesma pessoa compra o mesmo item duas vezes de propósito, num intervalo curto. Nesse cenário a segunda compra é engolida pela deduplicação, o cliente não recebe o produto e o suporte demora dias para entender o que aconteceu, porque nos registros do sistema não existe erro nenhum.

Origem da chaveProtege a retentativa após queda de rede?Bloqueia compra repetida legítima?Veredito
Gerada no cliente ao abrir o checkoutSim, existe antes do primeiro envioNão, muda a cada nova intenção de compraÉ o desenho correto
Gerada no servidor e devolvida na respostaNão, a resposta perdida é justamente o caso críticoNãoNão resolve o problema principal
Derivada de carrinho mais valor totalSimSim, engole a segunda compra idênticaCria um bug pior que o original
Identificador da sessão do usuárioSimSim, toda a sessão vira uma compra sóEscopo grande demais
Timestamp com precisão de segundoParcialmente, colide entre usuáriosDepende do relógioColisão entre clientes distintos

A quinta linha merece atenção porque o problema dela não é teórico. Uma chave que não inclui a identidade de quem paga permite que a requisição de um cliente devolva a resposta armazenada da compra de outro, e o resultado é vazamento de dado de pagamento entre contas. A chave precisa ser escopada pela identidade autenticada no servidor, nunca por um valor que veio somente do corpo da requisição.

02

Duas requisições ao mesmo tempo: responder estado, não esperar

O caso interessante não é a repetição depois que a primeira terminou, esse é fácil. O caso que trava o checkout é a repetição enquanto a primeira ainda está em andamento, e ele é frequente justamente porque a lentidão é o que motiva o segundo clique. A implementação ingênua tranca a segunda requisição num bloqueio e a faz esperar pela primeira, o que transforma um problema de duplicidade num problema de latência: se a primeira demora quarenta segundos, a segunda demora quarenta segundos também, e agora existem duas conexões presas em vez de uma.

A saída é tratar a chave como um registro com estado próprio, gravado antes de qualquer efeito acontecer. A primeira requisição insere o registro com estado em andamento e segue para o processamento. A segunda tenta inserir, colide na restrição de unicidade, lê o registro existente e responde imediatamente com um código que diz ao cliente que o pedido já está sendo processado. Não há espera, não há conexão retida, e o cliente pode consultar o resultado depois em vez de segurar a linha.

ESPERAR PELO BLOQUEIO (o que trava o checkout)

  req A  --> adquire lock --> processa 40s ---------> responde 201
  req B  --> espera lock ..........................--> responde 201
             40s de conexao presa sem fazer nada
             cliente ve tela travada, tenta de novo, req C espera tambem


RESPONDER ESTADO (o que mantem o fluxo)

  req A  --> INSERT chave (in_progress) --> processa 40s --> UPDATE
             |                                               (completed +
             |                                                resposta)
             v
  req B  --> INSERT falha na unicidade
             --> le o registro: in_progress
             --> responde 409 em 8ms com Retry-After: 2
                 cliente faz polling, nao segura conexao

  req D (depois de A terminar)
         --> INSERT falha na unicidade
             --> le o registro: completed
             --> devolve a MESMA resposta gravada, sem reprocessar

O detalhe que costuma passar batido é que o registro da chave precisa ser gravado e confirmado antes de o efeito começar, e não junto dele. Se a inserção da chave acontece na mesma transação que cria a cobrança, ela só fica visível para outras conexões quando a transação inteira confirmar, e durante os quarenta segundos de processamento a segunda requisição não enxerga nada: ela insere com sucesso e cria a segunda cobrança. A separação em duas transações é o que torna a proteção efetiva no intervalo em que ela realmente importa.

// checkout/idempotency.mjs
// A chave vira um registro com estado, gravado e CONFIRMADO antes do efeito.
// Se a insercao acontecesse na mesma transacao do pagamento, ela so ficaria
// visivel no commit, e durante o processamento a segunda requisicao nao veria
// nada: inseriria com sucesso e criaria a segunda cobranca.
//
// CREATE TABLE idempotency_keys (
//   key            text        NOT NULL,
//   subject_id     text        NOT NULL,  -- identidade AUTENTICADA, nao do corpo
//   endpoint       text        NOT NULL,  -- mesma chave em rotas diferentes e outra operacao
//   request_hash   text        NOT NULL,  -- impressao digital do corpo
//   status         text        NOT NULL,  -- in_progress | completed | failed
//   response_code  int,
//   response_body  jsonb,
//   created_at     timestamptz NOT NULL DEFAULT now(),
//   PRIMARY KEY (key, subject_id, endpoint)
// );

import { createHash } from 'node:crypto';

const IN_PROGRESS_TTL_MS = 90_000; // acima do timeout do gateway

export const fingerprint = (body) =>
  createHash('sha256')
    // Chaves ordenadas: { a, b } e { b, a } sao o mesmo pedido e precisam
    // produzir a mesma impressao digital, senao a retentativa vira conflito.
    .update(JSON.stringify(body, Object.keys(body).sort()))
    .digest('hex');

export class IdempotencyConflict extends Error {
  constructor(status, payload) {
    super('conflito de idempotencia');
    this.name = 'IdempotencyConflict';
    this.status = status;
    this.payload = payload;
  }
}

// Retorna { claimed: true } quando esta requisicao ganhou o direito de
// executar o efeito. Nos demais casos lanca com a resposta ja pronta.
export const claim = async (db, { key, subjectId, endpoint, requestHash }) => {
  const inserted = await db.query(
    `INSERT INTO idempotency_keys (key, subject_id, endpoint, request_hash, status)
     VALUES ($1, $2, $3, $4, 'in_progress')
     ON CONFLICT (key, subject_id, endpoint) DO NOTHING
     RETURNING key`,
    [key, subjectId, endpoint, requestHash],
  );

  if (inserted.rowCount === 1) return { claimed: true };

  const [existing] = (
    await db.query(
      `SELECT request_hash, status, response_code, response_body, created_at
       FROM idempotency_keys
       WHERE key = $1 AND subject_id = $2 AND endpoint = $3`,
      [key, subjectId, endpoint],
    )
  ).rows;

  // Mesma chave com corpo diferente e erro do cliente, nao repeticao.
  // Devolver a resposta da primeira compra aqui confirmaria um pedido que
  // o cliente nao fez.
  if (existing.request_hash !== requestHash) {
    throw new IdempotencyConflict(422, {
      error: 'idempotency_key_reuse',
      message: 'a chave ja foi usada com um corpo diferente',
    });
  }

  if (existing.status === 'completed') {
    // Mesma saida, sem repetir o efeito.
    throw new IdempotencyConflict(existing.response_code, existing.response_body);
  }

  if (existing.status === 'failed') {
    // Falha definitiva ja registrada: repetir produziria o mesmo erro.
    throw new IdempotencyConflict(existing.response_code, existing.response_body);
  }

  const ageMs = Date.now() - new Date(existing.created_at).getTime();

  // Registro preso em andamento alem do TTL: o processo que o criou morreu
  // antes de finalizar. Liberar para uma nova tentativa em vez de deixar o
  // cliente travado para sempre.
  if (ageMs > IN_PROGRESS_TTL_MS) {
    const retaken = await db.query(
      `UPDATE idempotency_keys
       SET created_at = now()
       WHERE key = $1 AND subject_id = $2 AND endpoint = $3
         AND status = 'in_progress' AND created_at = $4
       RETURNING key`,
      [key, subjectId, endpoint, existing.created_at],
    );
    if (retaken.rowCount === 1) return { claimed: true };
  }

  // Em andamento dentro do prazo: responder AGORA, sem esperar.
  throw new IdempotencyConflict(409, {
    error: 'in_progress',
    message: 'o pedido ja esta sendo processado',
    retryAfterSeconds: 2,
  });
};

A comparação da impressão digital do corpo é a parte que quase sempre falta e a que tem a pior consequência quando falta. Sem ela, um cliente que reaproveita a chave por engano, seja porque o armazenamento da sessão não foi limpo ou porque o aplicativo móvel restaurou um estado antigo, recebe como resposta a confirmação de uma compra diferente da que acabou de pedir. Ele vê um pedido confirmado, o valor não bate, e o sistema não registrou erro nenhum porque do ponto de vista dele tudo funcionou.

03

A chave que atravessa a borda: repassar ao gateway, não recriar

Proteger a própria API é metade do caminho. A outra metade é o que acontece entre o seu servidor e o processador de pagamento, porque ali existe exatamente o mesmo problema com um agravante: quando a chamada ao gateway expira por timeout, você não sabe se a cobrança foi criada. A resposta se perdeu, mas o efeito do outro lado pode ter acontecido, e a decisão de tentar de novo é uma aposta de trezentos e quarenta reais.

Todos os gateways relevantes aceitam um cabeçalho de idempotência, e a regra é derivar esse valor de forma determinística a partir da sua chave interna, nunca gerar um novo por tentativa. Se cada retentativa envia um identificador aleatório diferente, o gateway trata cada uma como uma cobrança nova e a proteção some justamente onde o dinheiro está. A derivação determinística faz a segunda tentativa recair sobre a mesma chave e devolve a cobrança já criada em vez de criar outra.

// checkout/gateway.mjs
// A chave interna atravessa a borda de forma DETERMINISTICA. Gerar um valor
// novo por tentativa faz o gateway tratar cada retentativa como uma cobranca
// nova, e a protecao desaparece justamente onde o dinheiro esta.

import { createHash } from 'node:crypto';

// Prefixo por operacao: a mesma chave interna pode originar uma autorizacao e,
// depois, uma captura. Sem o prefixo, a captura recairia sobre a chave da
// autorizacao e o gateway devolveria a autorizacao ja feita.
const gatewayKey = (internalKey, operation) =>
  createHash('sha256').update(`${operation}:${internalKey}`).digest('hex').slice(0, 40);

export const authorize = async (gateway, { internalKey, amountCents, currency, source }) => {
  const headers = { 'Idempotency-Key': gatewayKey(internalKey, 'authorize') };

  try {
    return await gateway.post('/charges', { amountCents, currency, source }, { headers });
  } catch (error) {
    // Timeout e o caso ambiguo: a cobranca pode ter sido criada do outro lado.
    // NUNCA assumir que nao foi. Repetir com a MESMA chave e seguro; repetir
    // com uma chave nova cobra duas vezes.
    if (error.code === 'ETIMEDOUT' || error.status >= 500) {
      return await gateway.post('/charges', { amountCents, currency, source }, { headers });
    }
    throw error;
  }
};

// Finalizacao: grava o resultado no registro da chave para que qualquer
// repeticao futura devolva a MESMA saida sem tocar no gateway de novo.
export const settle = async (db, { key, subjectId, endpoint, status, code, body }) => {
  await db.query(
    `UPDATE idempotency_keys
     SET status = $4, response_code = $5, response_body = $6
     WHERE key = $1 AND subject_id = $2 AND endpoint = $3`,
    [key, subjectId, endpoint, status, code, body],
  );
};

// Uso no handler. A ordem importa: reivindicar, executar, gravar o resultado.
export const handleCheckout = async (db, gateway, request) => {
  const requestHash = fingerprint(request.body);
  const scope = {
    key: request.headers['idempotency-key'],
    subjectId: request.auth.customerId, // do token, nunca do corpo
    endpoint: 'POST /checkout',
    requestHash,
  };

  await claim(db, scope); // lanca IdempotencyConflict com a resposta pronta

  try {
    const charge = await authorize(gateway, {
      internalKey: scope.key,
      amountCents: request.body.amountCents,
      currency: request.body.currency,
      source: request.body.source,
    });

    const body = { orderId: charge.id, status: 'confirmed' };
    await settle(db, { ...scope, status: 'completed', code: 201, body });
    return { code: 201, body };
  } catch (error) {
    // Recusa do emissor e resultado definitivo: gravar como falha para que a
    // repeticao devolva a mesma recusa em vez de tentar cobrar de novo.
    if (error.declined) {
      const body = { error: 'card_declined', reason: error.reason };
      await settle(db, { ...scope, status: 'failed', code: 402, body });
      return { code: 402, body };
    }

    // Falha transitoria: apagar o registro para que a proxima tentativa do
    // cliente possa reivindicar de novo. Deixar em andamento travaria o
    // checkout ate o TTL expirar.
    await db.query(
      `DELETE FROM idempotency_keys
       WHERE key = $1 AND subject_id = $2 AND endpoint = $3 AND status = 'in_progress'`,
      [scope.key, scope.subjectId, scope.endpoint],
    );
    throw error;
  }
};

A distinção entre falha definitiva e falha transitória no bloco final é o que separa uma implementação usável de uma que gera chamado de suporte. Um cartão recusado é um resultado, e repetir a requisição deve devolver a mesma recusa sem uma nova tentativa de cobrança no emissor, porque tentativas repetidas em cartões recusados afetam a reputação do estabelecimento junto às bandeiras. Já uma indisponibilidade momentânea do gateway não é um resultado, e manter o registro travado nesse caso impede o cliente de tentar de novo durante o tempo inteiro do TTL.

04

Escopo, expiração e o custo de guardar a resposta

A chave primária composta por chave, identidade e rota não é preciosismo de modelagem. O identificador do sujeito evita que a chave de um cliente devolva a resposta de outro, o que seria vazamento de dado. A rota evita que a mesma chave, reaproveitada por um aplicativo que a gera uma vez por sessão, faça um pedido de reembolso receber como resposta a confirmação da compra original. Cada componente fecha uma porta específica, e retirar qualquer um deles abre exatamente a porta correspondente.

A expiração tem dois prazos diferentes e confundi-los causa problemas opostos. O prazo do registro em andamento é curto, na casa dos noventa segundos, e existe apenas para destravar o cliente quando o processo que reivindicou a chave morreu. O prazo do registro finalizado é longo, tipicamente vinte e quatro horas, e existe para que a retentativa do aplicativo móvel que ficou offline continue encontrando a resposta gravada. Usar o prazo curto para os dois faz o cliente conseguir cobrar duas vezes com um intervalo de dois minutos.

DecisãoEscolha recomendadaO que quebra se você errar
Escopo da chaveChave mais identidade autenticada mais rotaResposta de um cliente devolvida para outro
Momento de gravar o registroAntes do efeito, em transação própria já confirmadaA janela de processamento fica desprotegida
Prazo do estado em andamentoNoventa segundos, acima do timeout do gatewayCurto trava o fluxo, longo permite cobrança dupla
Prazo do estado finalizadoVinte e quatro horasRetentativa tardia vira uma segunda compra
Corpo divergente com a mesma chaveRecusar com quatrocentos e vinte e doisCliente recebe a confirmação de outro pedido
Chave enviada ao gatewayDerivada da interna com prefixo por operaçãoRetentativa cria uma segunda cobrança real

Guardar o corpo da resposta tem um custo de armazenamento que costuma ser levantado como objeção e quase nunca se sustenta. Uma resposta de checkout serializada ocupa algo entre quinhentos bytes e dois quilobytes, e um sistema com cem mil pedidos por dia acumula menos de duzentos megabytes em vinte e quatro horas, que é o prazo em que a linha ainda serve para alguma coisa. A rotina de limpeza que apaga registros finalizados além do prazo cabe em uma consulta e roda em minutos, e o custo real dessa tabela é irrelevante perto do custo de um único estorno.

05

Provar que funciona antes de o cliente descobrir

Testes sequenciais dão uma falsa sensação de cobertura porque passam mesmo quando a implementação está errada. Chamar o handler duas vezes uma depois da outra exercita o caminho em que a primeira já terminou, que é justamente o caminho fácil, e não toca no caminho concorrente, que é onde a cobrança dupla nasce. O teste que importa dispara as duas chamadas em paralelo, com a primeira segurada de propósito num ponto controlado do processamento.

São quatro asserções, e todas são consequências diretas das decisões das seções anteriores: que a chamada concorrente responde imediatamente em vez de esperar, que o gateway foi acionado exatamente uma vez, que a repetição posterior devolve o corpo idêntico ao da primeira resposta e que a mesma chave com corpo diferente é recusada em vez de confirmar um pedido que o cliente não fez.

// checkout/idempotency.test.mjs
// O teste sequencial passa mesmo com a implementacao errada, porque exercita
// so o caminho em que a primeira ja terminou. O caso que gera cobranca dupla
// e o concorrente, e ele precisa de uma barreira controlada.

import assert from 'node:assert/strict';
import { test } from 'node:test';

import { handleCheckout, IdempotencyConflict } from './gateway.mjs';

const pedido = (overrides = {}) => ({
  headers: { 'idempotency-key': 'k-abc-123' },
  auth: { customerId: 'cus_42' },
  body: { amountCents: 34000, currency: 'BRL', source: 'tok_visa', ...overrides },
});

// Gateway que segura a primeira chamada ate ser liberado de proposito.
const gatewayComBarreira = () => {
  let liberar;
  const barreira = new Promise((resolve) => {
    liberar = resolve;
  });
  const chamadas = [];

  return {
    chamadas,
    liberar: () => liberar(),
    post: async (path, body, options) => {
      chamadas.push({ path, body, headers: options.headers });
      if (chamadas.length === 1) await barreira;
      return { id: `ch_${chamadas.length}` };
    },
  };
};

test('a requisicao concorrente responde na hora e nao espera a primeira', async () => {
  const db = criarBancoDeTeste();
  const gateway = gatewayComBarreira();

  const primeira = handleCheckout(db, gateway, pedido());

  // Enquanto a primeira esta presa no gateway, a segunda chega.
  const segunda = await handleCheckout(db, gateway, pedido()).catch((error) => error);

  assert.ok(segunda instanceof IdempotencyConflict);
  assert.equal(segunda.status, 409);
  assert.equal(segunda.payload.error, 'in_progress');

  gateway.liberar();
  const resultado = await primeira;
  assert.equal(resultado.code, 201);

  // A assercao que prova a ausencia de cobranca dupla.
  assert.equal(gateway.chamadas.length, 1, 'o gateway foi acionado mais de uma vez');
});

test('a repeticao posterior devolve o corpo identico sem tocar no gateway', async () => {
  const db = criarBancoDeTeste();
  const gateway = gatewayComBarreira();
  gateway.liberar();

  const primeira = await handleCheckout(db, gateway, pedido());
  const repetida = await handleCheckout(db, gateway, pedido()).catch((error) => error);

  assert.deepEqual(repetida.payload, primeira.body);
  assert.equal(repetida.status, primeira.code);
  assert.equal(gateway.chamadas.length, 1);
});

test('a mesma chave com corpo diferente e recusada', async () => {
  const db = criarBancoDeTeste();
  const gateway = gatewayComBarreira();
  gateway.liberar();

  await handleCheckout(db, gateway, pedido());

  // Mesma chave, valor diferente: aplicativo que restaurou um estado antigo.
  // Devolver a resposta da primeira compra confirmaria um pedido inexistente.
  const conflito = await handleCheckout(db, gateway, pedido({ amountCents: 99900 })).catch(
    (error) => error,
  );

  assert.equal(conflito.status, 422);
  assert.equal(conflito.payload.error, 'idempotency_key_reuse');
  assert.equal(gateway.chamadas.length, 1);
});
  1. Gere a chave no cliente ao montar a tela de pagamento e mantenha o mesmo valor enquanto o pedido não mudar, guardando no armazenamento da sessão para sobreviver a recarregamento.
  2. Crie a tabela com chave primária composta por chave, identidade autenticada e rota, e grave a impressão digital do corpo junto do registro.
  3. Reivindique a chave em uma transação própria já confirmada antes de iniciar o efeito, nunca dentro da transação que cria a cobrança.
  4. Responda quatrocentos e nove com indicação de espera para a requisição concorrente, sem bloquear, e ajuste o cliente para consultar o resultado em vez de segurar a conexão.
  5. Derive a chave enviada ao gateway da chave interna com um prefixo por operação, e repita com o mesmo valor em caso de timeout ou erro do servidor.
  6. Separe os prazos de expiração em andamento e finalizado, e cubra a implementação com o teste concorrente que conta quantas vezes o gateway foi acionado.

A contagem de chamadas ao gateway é a asserção mais valiosa do conjunto porque é a única que mede o efeito real em vez do formato da resposta. Uma implementação com o registro gravado na transação errada devolve exatamente os mesmos códigos e os mesmos corpos que a implementação correta, e passa em qualquer teste que só verifique a saída. Ela só se distingue pelo número de cobranças criadas, que é precisamente o que o cliente vê no extrato.

FAQ

Perguntas frequentes

A chave de idempotência substitui o tratamento de webhook do gateway?

Não, os dois resolvem problemas diferentes e um sistema de pagamento sério precisa dos dois. A chave de idempotência protege a direção de saída, que é a sua aplicação chamando o gateway, e responde à pergunta de quantas cobranças foram criadas quando o cliente clicou duas vezes ou a rede caiu no meio. O tratamento idempotente de webhook protege a direção de entrada, que é o gateway notificando a sua aplicação, e responde à pergunta de quantas vezes você creditou o pedido quando o provedor entregou o mesmo evento três vezes, o que é o comportamento normal de qualquer entrega com garantia de pelo menos uma vez. São caminhos distintos, com tabelas distintas e chaves derivadas de forma distinta: no checkout a chave vem do cliente antes da tentativa, no webhook ela é derivada do recurso, da transição de estado e da referência externa, porque ali quem gera o evento é o provedor. Confundir os dois costuma produzir um sistema que não cobra duas vezes mas credita três, ou o contrário, e nos dois casos o sintoma que chega ao suporte é o mesmo: o valor no extrato não bate com o pedido.

O que fazer quando o gateway responde com timeout e não se sabe se a cobrança existe?

Repetir a chamada com exatamente a mesma chave de idempotência, e essa é a única ação segura das três possíveis. Assumir que a cobrança não existe e criar outra sem a chave produz cobrança dupla quando ela existia, e assumir que existe e confirmar o pedido para o cliente entrega o produto sem receber quando ela não existia. Com a mesma chave, o gateway devolve a cobrança já criada se ela existir e cria uma nova se não existir, e nos dois casos o resultado final é uma cobrança só. É por isso que a derivação determinística da chave importa tanto: se cada retentativa gerar um identificador novo, essa propriedade desaparece justamente no momento em que ela é necessária. Quando o timeout se repete e não é possível concluir a chamada, a decisão correta é deixar o pedido em um estado explícito de aguardando confirmação, com uma tarefa em segundo plano que consulta o gateway pela chave até obter uma resposta definitiva, e comunicar isso ao cliente. Confirmar um pedido cujo pagamento você não conseguiu verificar transfere o risco para a operação, e reverter isso depois custa mais que a espera de alguns minutos.

Devolver quatrocentos e nove na requisição concorrente não piora a experiência do usuário?

Piora se o cliente exibir o código bruto ao usuário, e melhora bastante se ele tratar o estado como o que é. O contraste correto não é entre um erro e um sucesso, é entre uma resposta imediata que diz que o pedido está sendo processado e uma tela congelada por quarenta segundos sem nenhuma informação, que é o que a espera pelo bloqueio produz. Com a resposta de estado, a interface mostra que o pagamento está em andamento, desabilita o botão e passa a consultar o resultado a cada dois segundos, o que é exatamente o que uma barra de progresso honesta faria. Do lado do servidor o ganho é maior ainda: nenhuma conexão fica retida esperando outra terminar, o pool não satura quando o gateway degrada, e o segundo clique do usuário deixa de ser um multiplicador de carga. Vale notar que alguns times preferem devolver duzentos e dois com o mesmo corpo de estado em vez de quatrocentos e nove, e isso é uma escolha de contrato legítima desde que o cliente saiba distinguir uma resposta de aceitação de uma resposta de conclusão. O que não funciona é qualquer desenho em que a segunda requisição fique esperando a primeira.

Cobrar uma vez só não pode custar quarenta segundos de tela travada

A chave de idempotência resolve a cobrança dupla e, mal implementada, cria um problema de latência no lugar dela. A diferença está em tratar a chave como um registro com estado, gravado antes do efeito e respondido de imediato, em vez de um bloqueio que faz a segunda requisição esperar pela primeira. Posso desenhar o escopo e os prazos da tabela de chaves no seu checkout, separar a reivindicação do efeito nas transações certas, derivar a chave que atravessa a borda até o gateway, ajustar o cliente para consultar o estado em vez de segurar a conexão e deixar o teste concorrente que conta as chamadas ao gateway rodando no seu pipeline.