
Un cliente móvil envía un POST /pedidos. La red tarda, el cliente no recibe respuesta a tiempo y reintenta automáticamente. El servidor sí había recibido la primera petición y ya había creado el pedido — así que ahora hay dos. Este no es un caso raro: es el comportamiento por defecto de cualquier capa de reintentos (móvil, balanceador de carga, librería HTTP) cuando la operación repetida no es idempotente.
Qué significa idempotente, exactamente
Una operación es idempotente si ejecutarla una vez o N veces produce el mismo estado final. La propia especificación HTTP asigna esta propiedad por método:
- GET, PUT, DELETE: idempotentes por definición. Repetir un
DELETE /pedidos/42dos veces deja el mismo resultado que una: el pedido 42 no existe. - POST: no idempotente. Cada llamada se interpreta como una intención nueva — por diseño, para poder crear varios recursos con datos idénticos si hace falta.
El problema es que la mayoría de las operaciones que de verdad importan — crear un pedido, procesar un cobro, enviar una transferencia — se modelan con POST. Y son exactamente las que no puedes permitirte duplicar.
El patrón Idempotency-Key

La solución que usan Stripe, PayPal o AWS es la misma: el cliente genera una clave única antes del primer intento — normalmente un UUID — y la envía en una cabecera en todos los reintentos de esa misma operación:
POST /pedidos
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json
{
"producto_id": 88,
"cantidad": 2
}
El servidor guarda esa clave junto con el resultado la primera vez que procesa la petición. Si llega otra petición con la misma clave, no vuelve a ejecutar la lógica de negocio: devuelve la respuesta que ya tenía guardada.
Cómo se implementa en el servidor

Necesitas una tabla — o una entrada en Redis con TTL — que guarde, por clave: el hash del payload, el estado (processing / completed) y la respuesta final una vez calculada:
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'processing',
response_body JSONB,
response_code INT,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
El flujo, al recibir una petición:
- La clave no existe: se inserta con estado
processing, se ejecuta la lógica de negocio, y al terminar se actualiza acompletedcon la respuesta calculada. - La clave existe y está
completed: se compara el hash del payload actual con el guardado. Si coincide, se devuelve la respuesta guardada sin tocar la lógica de negocio. Si no coincide, es un error del cliente — está reutilizando una clave para una petición distinta — y se responde422 Unprocessable Entity. - La clave existe y sigue
processing: otra petición con la misma clave está en curso, probablemente en otra instancia del backend. Se responde409 Conflictpara que el cliente reintente pasado un momento, en vez de dejar que ambas ejecuten la lógica a la vez.
Ese último caso es la condición de carrera real del patrón: el INSERT inicial tiene que ser atómico — con una restricción UNIQUE sobre la clave — para que, si dos peticiones llegan casi a la vez, solo una consiga crear la fila y empezar a procesar. La otra falla el INSERT y cae directamente al caso de processing.
Buenas prácticas
- Que la genere el cliente, no el servidor: la clave tiene que existir antes del primer intento para poder reenviarla en los reintentos. Si el servidor la genera, no sirve de nada.
- Guarda el hash del payload junto a la clave: sin eso, no puedes distinguir un reintento legítimo de un cliente reutilizando la clave por error con datos distintos.
- Pon una expiración a las claves: 24–48 horas suele ser suficiente para cubrir cualquier reintento razonable, y evita que la tabla crezca sin límite.
- Aplícalo donde el efecto duele: pagos, creación de pedidos, envío de emails o notificaciones. No hace falta en operaciones de lectura, que ya son idempotentes.
- Documenta si lo soportas: muchos clientes HTTP reintentan automáticamente sin que el desarrollador se entere; que sepan que pueden mandar la cabecera con seguridad cambia cómo integran tu API.
Resuelto qué pasa cuando la misma petición llega dos veces por accidente, queda el problema inverso: qué pasa cuando llega miles de veces por segundo, a propósito.