Idempotencia en APIs: evita pagos duplicados
Idempotencia en APIs: cómo evitar pagos y pedidos duplicados en tu aplicación web a medida
Hay un tipo de incidencia que pone nervioso a cualquier equipo de backend: el cliente jura que ha pagado una vez, pero en su extracto aparecen dos cargos idénticos con dos minutos de diferencia. O peor, el almacén recibe dos órdenes de envío para el mismo pedido y manda el producto por duplicado. Nadie tocó nada raro. Simplemente la red se portó como la red.
La buena noticia es que este problema tiene nombre, tiene solución conocida y no requiere magia. Se llama idempotencia, y montarla bien en tus APIs es la diferencia entre una integración de pagos que aguanta el mundo real y una que genera tickets de soporte cada lunes por la mañana.
Qué es la idempotencia (y por qué importa tanto en pagos)
Una operación es idempotente cuando ejecutarla una vez o varias veces produce exactamente el mismo resultado. Apagar una luz que ya está apagada no cambia nada. Sumar cero a un número, tampoco. La idea, trasladada a una API, es esta: si una petición llega dos veces, el servidor debe comportarse como si hubiera llegado una sola.
En el contexto de cobros y pedidos, esto deja de ser una curiosidad académica. Un POST /charges que se procesa dos veces significa dos cargos reales en la tarjeta de un cliente. Un POST /orders repetido significa stock comprometido de más, picking duplicado y, casi seguro, una devolución. La idempotencia es el mecanismo que garantiza que «crear este pago» signifique crear ese pago concreto, no «crea un pago cada vez que me oigas».
Conviene distinguirla de operaciones que ya son idempotentes por naturaleza. Marcar un pedido como pagado (estado = "pagado") da igual cuántas veces lo hagas: el estado final es el mismo. El problema aparece con las operaciones que tienen efecto acumulativo, como crear un recurso o mover dinero. Esas son las que hay que blindar.
De dónde salen los duplicados
El duplicado casi nunca es un bug de tu lógica de negocio. Es una consecuencia de cómo funcionan las redes y los navegadores. Hay tres sospechosos habituales.
Reintentos de red
El caso más insidioso. El cliente (un móvil, un navegador, otro microservicio) envía la petición, el servidor la procesa correctamente y empieza a devolver la respuesta. Pero la conexión se cae a mitad de camino: timeout, cambio de Wi-Fi a 4G, un proxy que cierra el socket. El cliente nunca recibió el 200 OK, así que asume que falló y reintenta.
Desde su punto de vista es razonable. Desde el del servidor, ha llegado la misma orden de cobro dos veces, y la segunda parece tan legítima como la primera. Sin idempotencia, cobras dos veces.
El doble clic
Mucho más prosaico. El usuario pulsa «Pagar», no ve respuesta inmediata porque la pasarela tarda un par de segundos, y vuelve a pulsar. O hace doble clic por costumbre. Deshabilitar el botón en el frontend ayuda, pero no es una defensa: cualquiera puede reenviar la petición con las herramientas de desarrollo, y un script malintencionado ni siquiera usa el botón. El frontend mitiga; el backend protege.
Webhooks y entregas «al menos una vez»
Las pasarelas de pago, las colas de mensajes y casi cualquier sistema de eventos te entregan los avisos con una garantía de tipo at-least-once. Es decir, prometen que el evento te llegará, pero no prometen que llegue una sola vez. Si tu endpoint tarda en responder o devuelve un 500 transitorio, el emisor reintenta. Recibirás el mismo payment_intent.succeeded dos o tres veces, y si por cada uno generas una factura, ya tienes el lío montado.
La idempotency key: una sola clave lo arregla casi todo
La solución estándar es que el cliente genere un identificador único por operación y lo envíe en una cabecera. Por convención se llama Idempotency-Key, y suele ser un UUID:
POST /v1/charges HTTP/1.1
Host: api.tuempresa.es
Content-Type: application/json
Idempotency-Key: 8f14e45f-ceea-467a-9b3a-1a2b3c4d5e6f
{ "importe": 4999, "moneda": "EUR", "pedido": "ORD-10293" }
La clave es importante que la genere el cliente, no el servidor, y que sea estable para una misma intención del usuario. Si el cliente reintenta tras un timeout, debe reenviar la misma clave. Así el servidor entiende «esto es el mismo intento de antes», no uno nuevo.
El servidor, al recibir la petición, mira si ya ha visto esa clave. Si no, procesa la operación, guarda la clave junto con la respuesta y la devuelve. Si sí, no vuelve a ejecutar nada: recupera la respuesta que guardó la primera vez y la devuelve tal cual. El cliente recibe el mismo 200 OK con el mismo identificador de cargo, sin enterarse de que su primera petición sí había funcionado.
Cómo implementarla sin tropezar
El esquema parece sencillo, y lo es, pero hay detalles que separan una implementación que funciona en la demo de una que aguanta producción.
Guarda la clave y la respuesta juntas
No basta con recordar que la clave existe. Hay que almacenar la respuesta completa asociada a ella: código de estado, cuerpo y, si toca, las cabeceras relevantes. Cuando llegue el reintento, devuelves exactamente eso. Una tabla mínima en tu base de datos:
CREATE TABLE idempotency_keys (
clave VARCHAR(64) PRIMARY KEY,
huella_peticion CHAR(64) NOT NULL,
estado_http SMALLINT,
respuesta JSONB,
creado_en TIMESTAMPTZ DEFAULT now()
);
La columna huella_peticion (un hash del cuerpo) sirve para detectar un caso desagradable: que alguien reutilice la misma clave con un cuerpo distinto. Si llega una clave conocida pero la huella no coincide, lo correcto es responder con un error (un 422 o un 409) en lugar de devolver la respuesta antigua, que no tiene nada que ver con lo que ahora se pide.
Bloquea la concurrencia
El doble clic rápido y los reintentos casi simultáneos pueden hacer que dos peticiones con la misma clave entren a la vez, antes de que la primera haya guardado nada. Si no lo controlas, ambas pasan el «¿existe la clave?» con un «no» y ambas cobran.
La defensa es atómica. Insertas la clave en la tabla antes de procesar, aprovechando que la PRIMARY KEY rechaza duplicados. La segunda petición chocará contra la restricción de unicidad y sabrás que hay otra en vuelo: o esperas a que la primera termine y devuelves su resultado, o respondes con un 409 Conflict indicando que la operación está en curso. Un bloqueo a nivel de fila o un advisory lock también valen. Lo que no vale es confiar en que «no pasará».
Pon una ventana de expiración
Las claves no pueden vivir para siempre; la tabla crecería sin límite. Lo habitual es conservarlas un tiempo razonable —24 horas suele ser un punto de partida sensato para pagos— y purgar las antiguas con una tarea programada. La ventana debe ser lo bastante amplia para cubrir los reintentos plausibles de un cliente, pero no eterna. Si después de un día llega de nuevo la misma clave, se tratará como una operación nueva, lo cual es aceptable porque a esas alturas ya no es un reintento, es otra cosa.
Métodos HTTP: cuáles son idempotentes de serie
HTTP ya trae parte del trabajo hecho, y conviene respetar sus reglas porque proxies, cachés y clientes las dan por buenas.
GET, PUT y DELETE se consideran idempotentes por definición. Un GET no debería cambiar estado. Un PUT reemplaza un recurso por completo, así que aplicarlo dos veces deja el mismo resultado. Un DELETE repetido borra algo que ya estaba borrado: el efecto final es idéntico (aunque el segundo devuelva un 404, el estado del servidor no cambia).
POST es el que no lo es, y no por casualidad: se usa precisamente para «crear algo nuevo cada vez». Por eso los cobros y los pedidos van por POST y por eso son justo las operaciones que necesitan la idempotency key. La cabecera es la forma de dotar a un POST de la seguridad que PUT tiene gratis.
Un apunte práctico: si puedes diseñar una operación como PUT sobre un recurso con identificador conocido por el cliente, te ahorras parte del problema. No siempre es posible, pero cuando lo es, simplifica.
Idempotencia en webhooks entrantes
Cuando tu aplicación es la que recibe eventos —de Stripe, de Redsys, de un ERP—, eres tú quien debe protegerse de los duplicados, porque el emisor te avisa con garantía at-least-once.
Por suerte, casi todos los proveedores incluyen un identificador único en cada evento (evt_1A2b3C... en Stripe, por ejemplo). La receta es directa: antes de procesar el evento, comprueba si ya tienes registrado ese ID. Si lo tienes, respondes 200 de inmediato y no haces nada más. Si no, lo procesas dentro de una transacción que también inserta el ID, de modo que «marcar como procesado» y «hacer el trabajo» se confirmen o fallen juntos.
recibir_webhook(evento):
si ya_procesado(evento.id):
responder 200 OK # ya lo tratamos, ignoramos el reenvío
return
en una transacción:
aplicar_efecto(evento) # crear factura, actualizar pedido...
marcar_procesado(evento.id)
responder 200 OK
Y una recomendación que ahorra disgustos: responde el 200 rápido y deja el trabajo pesado para un proceso en segundo plano. Si tardas demasiado en confirmar, el proveedor asumirá que fallaste y reintentará, multiplicando el tráfico justo cuando peor te viene.
Idempotencia en colas de mensajes
El mismo principio gobierna las colas. SQS, RabbitMQ, Kafka y compañía entregan, en la práctica, at-least-once. Un consumidor que procesa un mensaje pero muere antes de confirmar (ack) provocará que el mensaje se reentregue a otro consumidor. Si ese mensaje dispara un cobro o crea un registro, otra vez tenemos duplicado.
La solución es la de siempre con otro nombre: haz que el consumidor sea idempotente. Cada mensaje lleva un identificador estable; antes de actuar, el consumidor anota que ya lo trató y descarta cualquier reentrega. Esto es lo que en la jerga se llama deduplicación a nivel de consumidor, y es más fiable que confiar en las funciones de deduplicación del broker, que suelen tener ventanas de tiempo limitadas y no cubren todos los escenarios.
Ejemplo con pasarela de pago
Bajemos a un caso concreto. Imagina un checkout que cobra con tarjeta.
Con Stripe, la API admite la cabecera Idempotency-Key en las peticiones de creación. Generas un UUID en el momento en que el usuario confirma el pago y lo reutilizas en cualquier reintento de esa misma confirmación:
POST /v1/payment_intents
Idempotency-Key: ck_8f14e45f-ceea-467a-9b3a-1a2b3c4d5e6f
amount=4999¤cy=eur
Si la red se cae y tu backend reintenta con esa misma clave, Stripe no crea un segundo PaymentIntent: te devuelve el que ya existía. El cobro doble desaparece desde la propia pasarela.
Con Redsys, el planteamiento es distinto pero la idea persiste. Cada operación se identifica por un número de pedido (Ds_Merchant_Order) que debe ser único. Si reenvías una autorización con un número de pedido ya usado y liquidado, la pasarela la rechaza en lugar de duplicar el cargo. Ese número de pedido cumple, de hecho, el papel de clave de idempotencia: tu trabajo es generarlo de forma estable para cada intención de compra y no reutilizarlo a la ligera entre operaciones distintas.
En ambos casos, la protección de la pasarela es tu última línea, no la única. Tu propio backend debe ser idempotente antes incluso de llamar a la pasarela, para no disparar dos veces el proceso completo de checkout.
Antes de dar nada por cerrado, vale la pena probarlo a conciencia: lanza la misma petición dos veces seguidas, simula un timeout en mitad del cobro, reenvía un webhook a mano. Si el sistema responde igual y el cliente acaba con un solo cargo, vas bien.
Por dónde empezar a hacer tu API idempotente
No hace falta reescribir el backend de golpe. El camino sensato es por capas. Primero, identifica las operaciones peligrosas: las que mueven dinero o crean recursos con efecto real (pedidos, facturas, envíos). Segundo, añade la cabecera Idempotency-Key a esos endpoints y la tabla que guarda clave y respuesta, con su control de concurrencia y su ventana de expiración. Tercero, blinda los webhooks entrantes y los consumidores de cola con deduplicación por identificador de evento. Y por último, prueba los fallos a propósito, porque un sistema idempotente que nunca se ha enfrentado a un reintento real es una promesa, no una garantía.
Si tienes una aplicación a medida con pagos o pedidos y quieres asegurarte de que un reintento de red nunca se convierta en un cargo doble, cuéntanos cómo es tu integración y lo revisamos contigo.