Email copiado — support@tuurt.com
Cargando experiencia
apis · 18 de octubre de 2026 · 7 min

Idempotency keys en APIs de pagos con Node y Spring: cómo evitamos cobros duplicados cuando el cliente reintenta

Un cliente reintenta un cobro que se cortó a mitad de camino. Así diseñamos la tabla de idempotencia en Node y Spring, y qué hacemos cuando la key no alcanza porque el proveedor ya procesó el pago.

Por Equipo Tuurt

Idempotency keys en APIs de pagos con Node y Spring: cómo evitamos cobros duplicados cuando el cliente reintenta

Un cliente manda un POST /pagos para cobrar una tarjeta. El request llega a nuestro servidor, el servidor llama al proveedor de pagos, el proveedor procesa el cobro, y en el medio de ese camino la conexión se corta: un timeout de red, un balanceador que cierra la conexión, un cliente móvil que pierde señal. El cliente nunca recibe la respuesta. Lo que hace casi cualquier cliente bien escrito -una app móvil, un SDK, un frontend con lógica de reintento- es volver a mandar exactamente el mismo request. Si no hicimos nada para evitarlo, ese segundo request cobra la tarjeta otra vez.

Este no es un caso raro. Es el caso normal de cualquier sistema distribuido sobre una red que no garantiza entrega ni lectura de la respuesta. La pregunta no es si va a pasar, es qué hacemos cuando pasa.

Por qué un cobro no es idempotente por naturaleza

Un GET se puede reintentar sin pensar porque no cambia nada. Un cobro es la operación opuesta: cada ejecución exitosa mueve dinero. Reintentar un cobro sin ningún mecanismo adicional no es un detalle de implementación descuidado, es simplemente incorrecto, porque el protocolo HTTP no nos da ninguna garantía de que un POST se ejecute una sola vez. La responsabilidad de que un cobro se ejecute una sola vez, aunque el cliente lo mande dos o tres veces, es nuestra, no del protocolo.

La solución estándar es que el cliente genere una idempotency key -típicamente un UUID- antes del primer intento, la mande en un header (Idempotency-Key), y la reutilice exactamente igual en cualquier reintento del mismo cobro. El servidor usa esa key para reconocer que un request es una repetición, no un cobro nuevo.

La tabla de idempotencia

Lo primero que probamos, y que no alcanzó, fue una restricción UNIQUE sobre la idempotency key en la tabla de pagos directamente. Funciona para el caso donde el primer request ya terminó: el segundo insert choca contra la restricción y lo rechazamos. Falla en el caso donde el segundo request llega mientras el primero todavía está en vuelo, procesándose contra el proveedor. En ese momento no hay ninguna fila todavía, así que ambos requests pasan la verificación y los dos terminan llamando al proveedor.

Lo que terminó funcionando fue una tabla separada, exclusivamente para idempotencia, donde la fila se crea antes de llamar al proveedor, no después:

create table idempotency_keys (
  key text primary key,
  endpoint text not null,
  request_hash text not null,
  status text not null check (status in ('pending', 'completed', 'failed')),
  response_body jsonb,
  response_status int,
  created_at timestamptz not null default now(),
  completed_at timestamptz
);

La key por sí sola no es suficiente: la combinamos con endpoint y con un hash del cuerpo del request (request_hash). Esto nos protege de un caso que no es malicia sino error de cliente: un bug en el SDK que reutiliza la misma idempotency key para dos pagos distintos con montos distintos. Si la key coincide pero el hash del cuerpo no, respondemos con un 409 en vez de devolver la respuesta cacheada de un pago que no es el que se está pidiendo.

El middleware en Node

En el servicio de Node (Express, con pg directo, sin ORM) el flujo es: intentar insertar la fila en estado pending con esa key; si el insert choca con la restricción de unicidad, leer la fila existente y decidir qué responder según su estado.

async function idempotencyMiddleware(req, res, next) {
  const key = req.header('Idempotency-Key');
  if (!key) return res.status(400).json({ error: 'falta Idempotency-Key' });

  const requestHash = hashBody(req.body);

  const inserted = await db.query(
    `insert into idempotency_keys (key, endpoint, request_hash, status)
     values ($1, $2, $3, 'pending')
     on conflict (key) do nothing
     returning *`,
    [key, req.path, requestHash]
  );

  if (inserted.rows.length > 0) {
    req.idempotencyKey = key;
    return next();
  }

  const existing = await db.query(
    'select * from idempotency_keys where key = $1', [key]
  );
  const row = existing.rows[0];

  if (row.request_hash !== requestHash) {
    return res.status(409).json({ error: 'la key ya se usó con otro request' });
  }
  if (row.status === 'pending') {
    return res.status(409).json({ error: 'ese pago ya se está procesando' });
  }
  return res.status(row.response_status).json(row.response_body);
}

El punto que nos costó entender la primera vez: el request que está en vuelo no puede simplemente esperar a que el primero termine y devolver su resultado, porque eso exigiría coordinación adicional (polling de la fila, o un mecanismo de notificación) que complica el camino caliente sin necesidad real. Devolver 409 en ese caso y dejar que el cliente reintente con backoff es más simple y, en la práctica, suficiente: el cliente ya tiene lógica de reintento, que es justamente lo que nos trajo este problema.

El mismo patrón en Spring

En un servicio Java con Spring, el mismo mecanismo se implementa como un filtro (OncePerRequestFilter) que corre antes del controlador, con la misma tabla de idempotencia accedida vía JPA:

@Component
public class IdempotencyFilter extends OncePerRequestFilter {

    @Autowired
    private IdempotencyKeyRepository repository;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws IOException, ServletException {
        String key = request.getHeader("Idempotency-Key");
        if (key == null) {
            response.sendError(400, "falta Idempotency-Key");
            return;
        }

        String body = readBody(request);
        String requestHash = DigestUtils.sha256Hex(body);

        try {
            repository.insertPending(key, request.getRequestURI(), requestHash);
        } catch (DataIntegrityViolationException e) {
            IdempotencyKey existing = repository.findByKey(key).orElseThrow();
            if (!existing.getRequestHash().equals(requestHash)) {
                response.sendError(409, "la key ya se usó con otro request");
            } else if ("pending".equals(existing.getStatus())) {
                response.sendError(409, "ese pago ya se está procesando");
            } else {
                writeResponse(response, existing.getResponseStatus(), existing.getResponseBody());
            }
            return;
        }

        chain.doFilter(request, response);
    }
}

La parte que tuvimos que ajustar en Spring y que en Node no apareció igual: el insertPending necesita correr en su propia transacción, separada de la transacción del controlador que procesa el pago. Si todo corre en la misma transacción y el controlador falla después de llamar al proveedor, el rollback también deshace el insert de la idempotency key, y el próximo reintento no la encuentra: vuelve a creer que es la primera vez. Separar esa transacción fue el cambio que nos costó más tiempo diagnosticar, porque en pruebas manuales con pocos fallos simulados el problema no se manifestaba casi nunca.

Cuando la idempotency key no alcanza

Hay un caso que la key no resuelve, y es el más incómodo de los tres: el servidor llama al proveedor de pagos, el proveedor procesa el cobro exitosamente de su lado, y antes de que la respuesta del proveedor vuelva a nuestro servidor para que podamos guardarla, el proceso se cae -un deploy, un out-of-memory, un crash del contenedor. Cuando el proceso vuelve a arrancar, la fila de idempotencia quedó en estado pending para siempre, aunque el cobro ya ocurrió del lado del proveedor.

Un reintento del cliente en ese estado, con nuestra lógica tal como está descrita arriba, devuelve 409 indefinidamente, lo cual evita el doble cobro pero deja al cliente sin una respuesta útil. La solución no está en nuestra tabla: está en que la mayoría de los proveedores de pago aceptan su propia idempotency key en la llamada que les hacemos, de modo que si nuestro proceso se recupera y decide reintentar la llamada al proveedor con la misma key, el proveedor reconoce que ya procesó ese cobro y devuelve el resultado original en vez de cobrar de nuevo. Por eso generamos una key propia para el proveedor, derivada de nuestra idempotency key interna pero no idéntica, y la persistimos junto con la fila pending antes de hacer la llamada saliente. Un proceso de reconciliación corre periódicamente sobre las filas que quedaron en pending más tiempo del esperado, consulta el estado real en el proveedor usando esa key, y completa la fila con el resultado verdadero.

Qué revisamos antes de dar por buena una ruta de pago

Antes de aceptar un endpoint de cobro como idempotente, confirmamos que la fila de idempotencia se crea antes de la llamada al proveedor, no después, y en una transacción que sobrevive aunque el resto del request falle. Confirmamos que la key se combina con un hash del cuerpo del request, no se usa sola. Confirmamos que generamos y persistimos nuestra propia idempotency key hacia el proveedor, porque es la única pieza que nos permite recuperarnos de un crash entre el cobro exitoso y nuestra confirmación de ese éxito. Y aceptamos que un request concurrente mientras el primero sigue en vuelo recibe un 409 en vez de esperar, porque intentar resolver eso con coordinación adicional agrega una complejidad que el cliente, con su propia lógica de reintento, ya resuelve mejor que nosotros.

node spring apis pagos
← Volver al blog