Escritos

¡El usuario pulsó «Pagar» 3 veces! Diseño de Idempotency Keys en sistemas de pago

Un usuario con una conexión móvil inestable pulsa «Pagar ahora» tres veces y le cobran tres veces el mismo pedido. Por qué no basta con deshabilitar el botón en el frontend y cómo los backends al nivel de Stripe garantizan la ejecución exactly-once con Idempotency Keys.

Furkan Özay23 de octubre de 2026 · 4 min de lectura
czCZAY.DEV // EDITORIALES
SISTEM MIMARISI

#Sistem Mimarisi · 4 MIN DE LECTURA

FURKAN ÖZAYVOL. 2026 // #10EC

Imagina esta clásica pesadilla del comercio electrónico: un cliente viaja en el metro con una conexión 4G irregular. Pulsa «Pagar 2.500 TL». La solicitud llega a tu backend y se cobra su tarjeta, pero la respuesta se pierde antes de llegar a su teléfono. Frustrado, pulsa el botón dos veces más, o su cliente HTTP reintenta la solicitud automáticamente.

Diez segundos después, recibe tres SMS de su banco: le cobraron tres veces la tarjeta de crédito por exactamente el mismo pedido.

· · ·

1. Por qué «basta con deshabilitar el botón en React» falla en producción

Los desarrolladores junior suelen responder a este escenario diciendo: «¡Fácil! Solo pon disabled={isLoading} en el botón de envío en el frontend».

Aunque deshabilitar el botón es buena UX, nunca garantiza la seguridad del backend:

  1. Reintentos a nivel de red: las apps móviles, los API gateways y los emisores de webhooks reintentan las solicitudes automáticamente cuando se agota el tiempo de espera de una conexión TCP.
  2. Solicitudes desde varias pestañas o dispositivos: un usuario puede abrir el checkout en dos pestañas o saltarse por completo las restricciones de la interfaz del frontend.
  3. El problema de los Dos Generales: cuando una conexión de red se corta a mitad de camino, el cliente no tiene forma de saber si el servidor falló antes de cobrar la tarjeta o después de cobrarla.

Para resolver esto de forma segura, la operación de pago debe ser idempotente.

· · ·

2. ¿Qué es la idempotencia (y una Idempotency Key)?

En matemáticas y en informática, una operación es idempotente si ejecutarla una vez produce exactamente el mismo estado del sistema que ejecutarla diez veces.

  • GET /orders/123 y DELETE /orders/123 son idempotentes por naturaleza.
  • POST /payments/charge NO es idempotente por naturaleza: cada llamada crea un cobro nuevo.

Para hacer idempotente POST /payments/charge, el cliente genera una Idempotency Key única (normalmente un UUIDv4 asociado a esa intención de checkout concreta) y la envía en la cabecera HTTP:

http
POST /api/v1/payments/charge HTTP/1.1
Host: api.czay.dev
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
Content-Type: application/json
 
{
  "orderId": "ord_8841",
  "amount": 250000,
  "currency": "TRY"
}
· · ·

3. Implementación paso a paso en el backend (Redis + base de datos)

Cuando tu backend recibe una solicitud con una Idempotency-Key, ejecuta una máquina de estados atómica y estricta:

typescript
export async function processPayment(req, res) {
  const key = req.headers['idempotency-key'];
  if (!key) return res.status(400).json({ error: 'Missing Idempotency-Key header' });
 
  const redisKey = `idempotency:${req.user.id}:${key}`;
 
  // 1. Atomic Lock (SET if Not Exists with 24h TTL)
  const acquired = await redis.set(redisKey, JSON.stringify({ status: 'IN_PROGRESS' }), 'EX', 86400, 'NX');
 
  if (!acquired) {
    const existing = JSON.parse(await redis.get(redisKey));
    
    // If the first request is still talking to the bank right now:
    if (existing.status === 'IN_PROGRESS') {
      return res.status(409).json({ error: 'Payment is currently being processed. Please wait.' });
    }
    
    // If the first request already succeeded, return the cached receipt without charging again!
    return res.status(existing.statusCode).json(existing.body);
  }
 
  try {
    // 2. Execute the actual payment gateway call (iyzico / PayTR / Stripe)
    const paymentResult = await paymentGateway.charge(req.body);
 
    // 3. Cache the final response under the Idempotency Key
    const responsePayload = { status: 'COMPLETED', statusCode: 200, body: paymentResult };
    await redis.set(redisKey, JSON.stringify(responsePayload), 'EX', 86400);
 
    return res.status(200).json(paymentResult);
  } catch (err) {
    // If a transient network error occurred before reaching the bank, release the lock so retry is allowed
    await redis.del(redisKey);
    throw err;
  }
}

Información: Respalda siempre Redis con una restricción única en la base de datos

Además del lock rápido de Redis (SET NX), añade siempre un índice UNIQUE sobre idempotency_key (o sobre order_id para los cobros completados) en tu tabla payments de PostgreSQL como red de seguridad ACID definitiva.

Hoja de ruta y herramienta interactivas

Sigue leyendo