Idempotencia en APIs de pago: por qué un retry duplica cargos
ArquitecturaSistemas Críticos

Idempotencia en APIs de pago: por qué un retry duplica cargos

Un reintento de red puede cobrar dos veces al mismo cliente. Cómo las claves de idempotencia evitan cargos duplicados en sistemas de pago, con el patrón que usa Stripe.

Daniel Jordan
7 min read
Compartir:

Un cliente envía una petición de pago. La conexión se cae antes de que la respuesta regrese a su navegador.

Desde el punto de vista del cliente, la petición "falló". Sin embargo, en el servidor, la transacción SQL se completó con éxito milisegundos antes del corte de red. El pago se ha cobrado. El cliente no lo sabe.

El instinto natural de una aplicación cliente —o de un proxy— ante un timeout es reintentar. Sin un mecanismo de protección en el backend, ese reintento ejecuta un segundo cargo idéntico: mismo cliente, mismo importe, dos transacciones en su cuenta bancaria.

Por qué "reintentar" no es lo mismo que "seguro"

En redes no fiables, el protocolo HTTP no ofrece garantías de entrega exactamente una vez (exactly-once delivery). Ofrece, en el mejor de los casos, entrega al menos una vez (at-least-once delivery).

Esto convierte a cualquier endpoint que modifique el estado del sistema (crear un pago, reservar stock, emitir una factura) en un punto de riesgo operativo si no es capaz de tolerar ejecuciones repetidas.

Aquí suele aparecer una confusión habitual: confundir un endpoint seguro de reintentar con un endpoint idempotente.

  • Operación segura: No altera el estado del servidor (ej. GET o HEAD).
  • Operación idempotente: Puede ejecutarse múltiples veces con los mismos parámetros y el estado resultante en el servidor es idéntico al de la primera ejecución (ej. PUT o DELETE).

Dado que un pago suele procesarse mediante un POST, la idempotencia no viene dada por el protocolo HTTP; hay que construirla explícitamente a nivel de arquitectura y base de datos.

El patrón: Claves de Idempotencia (Idempotency Keys)

El patrón estándar de la industria consiste en que el cliente genere un identificador único (un UUID v4) antes de realizar el primer intento, enviándolo en la cabecera HTTP Idempotency-Key.

Si la conexión se interrumpe, el cliente reintenta la misma petición adjuntando exactamente la misma cabecera.

POST /v1/charges HTTP/1.1
Host: api.tuempresa.com
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json

{
  "amount": 5000,
  "currency": "eur",
  "customer_id": "cus_99182"
}

El servidor utiliza esta clave para garantizar que la lógica de negocio solo se ejecute una vez.

El peligro oculto: Concurrencia real en la Base de Datos

Muchos desarrolladores intentan implementar este patrón mediante una consulta de lectura previa en la aplicación:

1. Leer si la clave existe en la BD.
2. Si existe -> devolver la respuesta guardada.
3. Si no existe -> procesar el pago y guardar el resultado.

Esto falla estrepitosamente bajo alta concurrencia. Si dos peticiones idénticas llegan con 2 milisegundos de diferencia (dos hilos o dos instancias detrás del balanceador), ambas leerán que la clave "no existe" al mismo tiempo. Ambas procesarán el cargo.

Para que el patrón sea seguro, la verificación y el bloqueo deben ser atómicos, apoyándose en las garantías ACID de la base de datos.

Esquema SQL de Producción

CREATE TYPE idempotency_status AS ENUM ('STARTED', 'COMPLETED');

CREATE TABLE idempotency_keys (
    idempotency_key UUID PRIMARY KEY,
    request_hash TEXT NOT NULL, -- SHA-256 del payload para detectar alteraciones
    status idempotency_status NOT NULL DEFAULT 'STARTED',
    response_status INT NULL,
    response_body JSONB NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Índice para limpieza periódica (TTL)
CREATE INDEX idx_idempotency_keys_created_at ON idempotency_keys (created_at);

El flujo atómico en PostgreSQL

Cuando llega la petición, la aplicación intenta registrar la clave antes de llamar a la pasarela de pagos o modificar el dominio:

INSERT INTO idempotency_keys (idempotency_key, request_hash, status)
VALUES ('8e03978e-40d5-43e8-bc93-6894a57f9324', 'sha256-del-payload...', 'STARTED')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING status;

Casos de respuesta del motor de base de datos:

  1. Inserción exitosa (retorna STARTED): La petición es la primera en llegar. Se procede a ejecutar el cargo en la pasarela de pagos. Al finalizar, se actualiza la fila a COMPLETED guardando el response_status y el response_body.
  2. Conflicto (RETURNING vacío): La clave ya existe. Se consulta la fila existente:
    • Si el estado es COMPLETED: Se devuelve inmediatamente el response_body guardado con su código HTTP original sin volver a tocar la pasarela de pagos.
    • Si el estado sigue en STARTED: Significa que la primera petición aún se está procesando. La aplicación debe responder con un código 409 Conflict o poner en espera (polling) el hilo durante unos milisegundos hasta que termine la transacción original.

Tres detalles de implementación que separan la teoría de producción

1. Detección de Payloads alterados (Payload Mismatch)

¿Qué ocurre si un cliente envía la clave UUID-1 con un cargo de 50€ y, por un bug en su código, reintenta usando la misma clave UUID-1 pero con un importe de 500€?

Nunca proceses la petición. Debes comparar el request_hash (SHA-256 de la cabecera + cuerpo) guardado en la base de datos con el del reintento. Si los hashes no coinciden, debes retornar un error 400 Bad Request indicando reutilización indebida de la clave de idempotencia.

2. TTL y Política de Retención

Guardar cada transacción en la tabla idempotency_keys para siempre destruirá el rendimiento de la base de datos.

Las claves de idempotencia son mecanismos para solventar fallos de red o reintentos inmediatos del cliente. Un TTL (Time to Live) de 24 a 48 horas es suficiente. Puedes purgar filas antiguas mediante un trabajo en segundo plano (batch) usando particionamiento por fecha o ejecuciones de eliminación por rangos fuera de horas pico.

3. ¿PostgreSQL o Redis?

  • PostgreSQL / MySQL: Recomendable cuando la clave de idempotencia debe ser estrictamente atómica dentro de la misma transacción SQL que modifica tu base de datos local.
  • Redis (SET key value NX GET EX 86400): Recomendable para arquitecturas de microservicios orientadas a eventos donde se prioriza la latencia ultra baja (sub-milisegundo) a costa de no disponer de garantías transaccionales ACID estrictas entre la caché y la persistencia.

El estándar de la industria

Stripe popularizó este enfoque en sus APIs financieras, guardando el cuerpo y código de respuesta exactos durante 24 horas.

Hoy en día, este estándar ha trascendido los proveedores privados y se encuentra en fase de estandarización por la IETF en el borrador The Idempotency-Key HTTP Header Field, co-redactado por ingenieros de PayPal.

Cuando los mayores procesadores de tráfico y pagos del mundo coinciden en el mismo patrón de diseño, no es casualidad: es arquitectura defensiva.

Criterio de decisión técnico

Aplica la regla de oro antes de escribir código:

Si un endpoint modifica estado en la base de datos y su ejecución repetida genera duplicidad de datos, cobros múltiples o inconsistencias financieras, el endpoint REQUIERE idempotencia explícita en el backend.

No confíes en que el cliente "no reintentará". Diseña el sistema asumiendo que el cliente reintentará en el peor momento posible.

Referencias

  • Stripe Documentation: Idempotent requests (Documentación oficial de la implementación de referencia en APIs de pagos).
  • IETF Internet-Draft: The Idempotency-Key HTTP Header Field (Borrador del estándar oficial IETF redactado por ingenieros de PayPal y la comunidad HTTPAPI).
  • PostgreSQL Documentation: INSERT ... ON CONFLICT (Manejo de atomicidad e inserción condicional para evitar condiciones de carrera).
  • Martin Fowler: Idempotent Receiver (Patrón de arquitectura para consistencia ante mensajes repetidos en sistemas distribuidos).

Lecturas relacionadas

Tags:#idempotencia#apis-de-pago#sistemas-distribuidos#resiliencia#http