Skip to content

Idempotencia de Webhooks

La idempotencia garantiza que procesar el mismo evento de webhook múltiples veces produzca el mismo resultado que procesarlo una sola vez. Esta guía explica por qué es fundamental y cómo implementarla correctamente.

¿Qué es la idempotencia?

Una operación es idempotente cuando ejecutarla una o más veces produce exactamente el mismo resultado. En el contexto de webhooks, esto significa que si Zelta Pay envia el mismo evento payment.success dos veces, tu sistema solo debe procesar el pago una vez.

¿Por qué es importante?

Existen varios escenarios donde puedes recibir el mismo evento más de una vez:

  • Reintentos automáticos -- Si tu endpoint responde con timeout o error 5xx, Zelta Pay reintenta la entrega
  • Problemas de red -- Tu servidor recibe y procesa el evento, pero la respuesta se pierde en la red, provocando un reintento
  • Reintentos manuales -- Alguien reintenta un evento desde el DLQ en el dashboard
  • Condiciones de carrera -- En sistemas distribuidos, dos workers pueden recibir el mismo evento simultáneamente

Sin idempotencia

javascript
// PELIGROSO: Sin idempotencia
app.post('/webhooks/zelta-pay', async (req, res) => {
  const event = req.body;

  if (event.type === 'payment.success') {
    // Si este webhook llega 2 veces, el cliente recibe 2 emails
    await sendConfirmationEmail(event.customer.email);

    // Si este webhook llega 2 veces, el inventario se descuenta 2 veces
    await decrementInventory(event.paymentLink.metadata.productId);

    // Si este webhook llega 2 veces, se crean 2 registros de pago
    await createPaymentRecord(event.transaction);
  }

  res.status(200).json({ received: true });
});

Con idempotencia

javascript
// SEGURO: Con idempotencia
app.post('/webhooks/zelta-pay', async (req, res) => {
  const eventId = req.headers['zeltapay-event-id'];
  const event = req.body;

  // Verificar si ya procesamos este evento
  const alreadyProcessed = await db.query(
    'SELECT event_id FROM webhook_events WHERE event_id = $1',
    [eventId]
  );

  if (alreadyProcessed.rows.length > 0) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  if (event.type === 'payment.success') {
    // Procesar de forma atómica
    await db.transaction(async (tx) => {
      await tx.query(
        'INSERT INTO webhook_events (event_id, event_type, payload) VALUES ($1, $2, $3)',
        [eventId, event.type, JSON.stringify(event)]
      );
      await sendConfirmationEmail(event.customer.email);
      await decrementInventory(event.paymentLink.metadata.productId);
      await createPaymentRecord(event.transaction);
    });
  }

  res.status(200).json({ received: true });
});

Estrategias de implementación

1. Rastreo por Event ID

La estrategia más común: almacenar los IDs de eventos ya procesados y verificar antes de procesar.

:: tab Node.js (Express)

javascript
import express from 'express';

const app = express();
app.use(express.json());

app.post('/webhooks/zelta-pay', async (req, res) => {
  const eventId = req.headers['zeltapay-event-id'];

  try {
    // Intentar insertar el evento (falla si ya existe)
    await db.query(
      'INSERT INTO webhook_events (event_id, event_type, processed_at) VALUES ($1, $2, NOW())',
      [eventId, req.body.type]
    );
  } catch (error) {
    if (error.code === '23505') { // Unique violation en PostgreSQL
      console.log(`Evento duplicado ignorado: ${eventId}`);
      return res.status(200).json({ received: true, duplicate: true });
    }
    throw error;
  }

  // Procesar el evento
  await processEvent(req.body);

  res.status(200).json({ received: true });
});

::

:: tab Cloudflare Workers

javascript
export default {
  async fetch(request, env, ctx) {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const eventId = request.headers.get('zeltapay-event-id');

    // Verificar idempotencia con KV
    const existing = await env.WEBHOOK_KV.get(`event:${eventId}`);
    if (existing) {
      return Response.json({ received: true, duplicate: true }, { status: 200 });
    }

    const event = await request.json();

    // Marcar como procesado (TTL de 30 días)
    await env.WEBHOOK_KV.put(
      `event:${eventId}`,
      JSON.stringify({ processedAt: new Date().toISOString(), type: event.type }),
      { expirationTtl: 60 * 60 * 24 * 30 }
    );

    // Procesar de forma asíncrona
    ctx.waitUntil(processEvent(event, env));

    return Response.json({ received: true }, { status: 200 });
  }
};

::

:: tab Hono

javascript
import { Hono } from 'hono';

const app = new Hono();

app.post('/webhooks/zelta-pay', async (c) => {
  const eventId = c.req.header('zeltapay-event-id');

  // Verificar idempotencia con KV
  const existing = await c.env.WEBHOOK_KV.get(`event:${eventId}`);
  if (existing) {
    return c.json({ received: true, duplicate: true }, 200);
  }

  const event = await c.req.json();

  // Marcar como procesado
  await c.env.WEBHOOK_KV.put(
    `event:${eventId}`,
    JSON.stringify({ processedAt: new Date().toISOString(), type: event.type }),
    { expirationTtl: 60 * 60 * 24 * 30 }
  );

  // Procesar de forma asíncrona
  c.executionCtx.waitUntil(processEvent(event, c.env));

  return c.json({ received: true }, 200);
});

export default app;

::

2. Idempotencia a nivel de base de datos

Usa restricciones únicas y transacciones para garantizar atomicidad:

javascript
async function processPaymentIdempotent(eventId, event) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // Intentar insertar el evento (falla si es duplicado)
    const insertResult = await client.query(
      `INSERT INTO webhook_events (event_id, event_type, payload, processed_at)
       VALUES ($1, $2, $3, NOW())
       ON CONFLICT (event_id) DO NOTHING
       RETURNING event_id`,
      [eventId, event.type, JSON.stringify(event)]
    );

    // Si no se insertó, es un duplicado
    if (insertResult.rows.length === 0) {
      await client.query('ROLLBACK');
      return { duplicate: true };
    }

    // Procesar la lógica de negocio dentro de la misma transacción
    if (event.type === 'payment.success') {
      await client.query(
        `UPDATE orders SET status = 'paid', paid_at = $1, transaction_id = $2
         WHERE id = $3 AND status != 'paid'`,
        [event.transaction.completedAt, event.transaction.id, event.paymentLink.metadata.orderId]
      );
    }

    await client.query('COMMIT');
    return { duplicate: false };
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

3. Idempotencia basada en Redis

Ideal para sistemas de alto rendimiento donde necesitas verificaciones rápidas:

javascript
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

async function processWithRedisIdempotency(eventId, event) {
  const lockKey = `webhook:lock:${eventId}`;
  const processedKey = `webhook:processed:${eventId}`;

  // Verificar si ya fue procesado
  const alreadyProcessed = await redis.get(processedKey);
  if (alreadyProcessed) {
    return { duplicate: true };
  }

  // Adquirir lock distribuido (NX = solo si no existe, EX = expiración en segundos)
  const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 30);
  if (!lockAcquired) {
    // Otro worker está procesando este evento
    return { duplicate: true, reason: 'locked' };
  }

  try {
    // Procesar el evento
    await processEvent(event);

    // Marcar como procesado (TTL de 30 días)
    await redis.set(processedKey, JSON.stringify({
      processedAt: new Date().toISOString(),
      type: event.type
    }), 'EX', 60 * 60 * 24 * 30);

    return { duplicate: false };
  } finally {
    // Liberar el lock
    await redis.del(lockKey);
  }
}

Implementaciones completas

Node.js con Express

javascript
import express from 'express';
import pg from 'pg';

const app = express();
app.use(express.json());

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

app.post('/webhooks/zelta-pay', async (req, res) => {
  const eventId = req.headers['zeltapay-event-id'];
  const eventType = req.headers['zeltapay-event-type'];
  const event = req.body;

  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // Verificar idempotencia
    const insertResult = await client.query(
      `INSERT INTO webhook_events (event_id, event_type, payload, processed_at)
       VALUES ($1, $2, $3, NOW())
       ON CONFLICT (event_id) DO NOTHING
       RETURNING event_id`,
      [eventId, eventType, JSON.stringify(event)]
    );

    if (insertResult.rows.length === 0) {
      await client.query('ROLLBACK');
      return res.status(200).json({ received: true, duplicate: true });
    }

    // Procesar según el tipo
    if (event.type === 'payment.success') {
      const { paymentLink, transaction } = event;

      // Actualizar orden
      await client.query(
        `UPDATE orders SET
          status = 'paid',
          paid_at = $1,
          transaction_id = $2,
          payment_method = $3
         WHERE id = $4 AND status != 'paid'`,
        [
          transaction.completedAt,
          transaction.id,
          transaction.paymentMethod,
          paymentLink.metadata.orderId
        ]
      );

      // Actualizar inventario
      if (paymentLink.metadata.productId) {
        await client.query(
          'UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0',
          [paymentLink.metadata.productId]
        );
      }
    }

    await client.query('COMMIT');

    // Acciones post-commit (no críticas)
    if (event.type === 'payment.success' && event.customer?.email) {
      sendConfirmationEmail(event.customer.email, event).catch(console.error);
    }

    res.status(200).json({ received: true });
  } catch (error) {
    await client.query('ROLLBACK');
    console.error(`Error procesando evento ${eventId}:`, error);
    res.status(200).json({ received: true, error: error.message });
  } finally {
    client.release();
  }
});

Cloudflare Workers con KV

javascript
export default {
  async fetch(request, env, ctx) {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const eventId = request.headers.get('zeltapay-event-id');
    const eventType = request.headers.get('zeltapay-event-type');

    // Verificar idempotencia
    const existing = await env.WEBHOOK_KV.get(`event:${eventId}`);
    if (existing) {
      return Response.json({ received: true, duplicate: true }, { status: 200 });
    }

    const event = await request.json();

    // Marcar como en proceso
    await env.WEBHOOK_KV.put(
      `event:${eventId}`,
      JSON.stringify({
        type: eventType,
        status: 'processing',
        receivedAt: new Date().toISOString()
      }),
      { expirationTtl: 60 * 60 * 24 * 30 }
    );

    // Procesar de forma asíncrona
    ctx.waitUntil((async () => {
      try {
        if (event.type === 'payment.success') {
          // Enviar a tu API backend para procesar
          await fetch(`${env.BACKEND_URL}/internal/process-payment`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(event)
          });
        }

        // Actualizar estado
        await env.WEBHOOK_KV.put(
          `event:${eventId}`,
          JSON.stringify({
            type: eventType,
            status: 'processed',
            processedAt: new Date().toISOString()
          }),
          { expirationTtl: 60 * 60 * 24 * 30 }
        );
      } catch (error) {
        console.error(`Error procesando evento ${eventId}:`, error);
        await env.WEBHOOK_KV.put(
          `event:${eventId}`,
          JSON.stringify({
            type: eventType,
            status: 'failed',
            error: error.message,
            failedAt: new Date().toISOString()
          }),
          { expirationTtl: 60 * 60 * 24 * 30 }
        );
      }
    })());

    return Response.json({ received: true }, { status: 200 });
  }
};

Hono con Cloudflare Workers

javascript
import { Hono } from 'hono';

const app = new Hono();

app.post('/webhooks/zelta-pay', async (c) => {
  const eventId = c.req.header('zeltapay-event-id');
  const eventType = c.req.header('zeltapay-event-type');

  // Verificar idempotencia
  const existing = await c.env.WEBHOOK_KV.get(`event:${eventId}`);
  if (existing) {
    return c.json({ received: true, duplicate: true }, 200);
  }

  const event = await c.req.json();

  // Marcar como procesado
  await c.env.WEBHOOK_KV.put(
    `event:${eventId}`,
    JSON.stringify({
      type: eventType,
      processedAt: new Date().toISOString()
    }),
    { expirationTtl: 60 * 60 * 24 * 30 }
  );

  // Procesar de forma asíncrona
  c.executionCtx.waitUntil((async () => {
    try {
      if (event.type === 'payment.success') {
        await processPaymentSuccess(event, c.env);
      }
    } catch (error) {
      console.error(`Error procesando evento ${eventId}:`, error);
    }
  })());

  return c.json({ received: true }, 200);
});

async function processPaymentSuccess(event, env) {
  const { paymentLink, transaction, customer } = event;

  // Tu lógica de negocio aquí
  console.log(`Procesando pago: ${paymentLink.id}`);
  console.log(`Monto: $${(paymentLink.amount / 100).toFixed(2)}`);
  console.log(`Método: ${transaction.paymentMethod}`);
}

export default app;

Python con Flask

python
from flask import Flask, request, jsonify
import psycopg2
import json
import os

app = Flask(__name__)

def get_db_connection():
    return psycopg2.connect(os.environ['DATABASE_URL'])

@app.route('/webhooks/zelta-pay', methods=['POST'])
def handle_webhook():
    event_id = request.headers.get('Zeltapay-Event-Id')
    event_type = request.headers.get('Zeltapay-Event-Type')
    event = request.get_json()

    conn = get_db_connection()
    cursor = conn.cursor()

    try:
        # Verificar idempotencia
        cursor.execute(
            """INSERT INTO webhook_events (event_id, event_type, payload, processed_at)
               VALUES (%s, %s, %s, NOW())
               ON CONFLICT (event_id) DO NOTHING
               RETURNING event_id""",
            (event_id, event_type, json.dumps(event))
        )

        if cursor.fetchone() is None:
            conn.rollback()
            return jsonify({'received': True, 'duplicate': True}), 200

        # Procesar el evento
        if event.get('type') == 'payment.success':
            payment_link = event['paymentLink']
            transaction = event['transaction']

            cursor.execute(
                """UPDATE orders SET
                    status = 'paid',
                    paid_at = %s,
                    transaction_id = %s,
                    payment_method = %s
                   WHERE id = %s AND status != 'paid'""",
                (
                    transaction['completedAt'],
                    transaction['id'],
                    transaction['paymentMethod'],
                    payment_link['metadata']['orderId']
                )
            )

        conn.commit()
        return jsonify({'received': True}), 200

    except Exception as e:
        conn.rollback()
        print(f'Error procesando evento {event_id}: {e}')
        return jsonify({'received': True, 'error': str(e)}), 200

    finally:
        cursor.close()
        conn.close()

Esquema de base de datos

Tabla de idempotencia de webhooks

sql
CREATE TABLE webhook_events (
  event_id VARCHAR(255) PRIMARY KEY,
  event_type VARCHAR(100) NOT NULL,
  payload JSONB NOT NULL,
  processed_at TIMESTAMP NOT NULL DEFAULT NOW(),
  created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Índice para limpiar eventos antiguos
CREATE INDEX idx_webhook_events_processed_at ON webhook_events (processed_at);

Tabla de ordenes (ejemplo)

sql
CREATE TABLE orders (
  id VARCHAR(255) PRIMARY KEY,
  status VARCHAR(50) NOT NULL DEFAULT 'pending',
  amount INTEGER NOT NULL,
  customer_name VARCHAR(255) NOT NULL,
  customer_email VARCHAR(255),
  transaction_id VARCHAR(255),
  payment_method VARCHAR(50),
  paid_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Índice para evitar actualizaciones duplicadas
CREATE UNIQUE INDEX idx_orders_transaction_id ON orders (transaction_id) WHERE transaction_id IS NOT NULL;

Buenas prácticas

Operaciones atómicas

Siempre ejecuta la verificación de idempotencia y la lógica de negocio dentro de la misma transacción de base de datos. Esto evita condiciones de carrera:

javascript
// Bueno: atómico
await db.transaction(async (tx) => {
  const inserted = await tx.insertIfNotExists('webhook_events', { event_id: eventId });
  if (!inserted) return; // Duplicado
  await tx.updateOrder(orderId, { status: 'paid' });
});

// Malo: no atómico (condición de carrera)
const exists = await db.checkEventExists(eventId);
if (exists) return;
// Otro request podría llegar aquí antes del insert
await db.insertEvent(eventId);
await db.updateOrder(orderId, { status: 'paid' });

Maneja fallos parciales

Si tu procesamiento involucra múltiples pasos, asegúrate de que un fallo parcial no deje tu sistema en un estado inconsistente:

javascript
async function processPaymentSuccess(tx, event) {
  const orderId = event.paymentLink.metadata.orderId;

  // Paso 1: Actualizar orden
  await tx.query('UPDATE orders SET status = $1 WHERE id = $2', ['paid', orderId]);

  // Paso 2: Actualizar inventario
  await tx.query('UPDATE products SET stock = stock - 1 WHERE id = $1', [
    event.paymentLink.metadata.productId
  ]);

  // Si el paso 2 falla, el paso 1 también se revierte (transacción)
}

Implementa limpieza

Los registros de idempotencia crecen con el tiempo. Implementa un proceso de limpieza periódica:

sql
-- Eliminar eventos procesados hace más de 30 días
DELETE FROM webhook_events WHERE processed_at < NOW() - INTERVAL '30 days';
javascript
// Job de limpieza (ejecutar diariamente)
async function cleanupOldEvents() {
  const result = await db.query(
    "DELETE FROM webhook_events WHERE processed_at < NOW() - INTERVAL '30 days'"
  );
  console.log(`Eliminados ${result.rowCount} eventos antiguos`);
}

Monitorea la idempotencia

Rastrea la tasa de eventos duplicados para detectar problemas:

javascript
const metrics = { total: 0, duplicates: 0 };

app.post('/webhooks/zelta-pay', async (req, res) => {
  metrics.total++;

  const eventId = req.headers['zeltapay-event-id'];
  const isDuplicate = await checkIfProcessed(eventId);

  if (isDuplicate) {
    metrics.duplicates++;
    console.log(`Tasa de duplicados: ${((metrics.duplicates / metrics.total) * 100).toFixed(2)}%`);
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Procesar...
  res.status(200).json({ received: true });
});

Testing de idempotencia

Test de eventos duplicados

Verifica que tu handler maneja correctamente los eventos duplicados:

javascript
import { describe, it, expect } from 'vitest';

describe('Idempotencia de webhooks', () => {
  it('debe procesar el primer evento exitosamente', async () => {
    const response = await sendWebhook({
      eventId: 'evt_test_001',
      payload: mockPaymentSuccessEvent
    });

    expect(response.status).toBe(200);
    expect(response.body.duplicate).toBeUndefined();
  });

  it('debe detectar y manejar eventos duplicados', async () => {
    // Enviar el mismo evento dos veces
    await sendWebhook({
      eventId: 'evt_test_002',
      payload: mockPaymentSuccessEvent
    });

    const response = await sendWebhook({
      eventId: 'evt_test_002',
      payload: mockPaymentSuccessEvent
    });

    expect(response.status).toBe(200);
    expect(response.body.duplicate).toBe(true);
  });

  it('no debe crear registros duplicados en la base de datos', async () => {
    const eventId = 'evt_test_003';
    const orderId = mockPaymentSuccessEvent.paymentLink.metadata.orderId;

    // Enviar 3 veces
    await sendWebhook({ eventId, payload: mockPaymentSuccessEvent });
    await sendWebhook({ eventId, payload: mockPaymentSuccessEvent });
    await sendWebhook({ eventId, payload: mockPaymentSuccessEvent });

    // Verificar que solo existe 1 registro
    const events = await db.query(
      'SELECT COUNT(*) FROM webhook_events WHERE event_id = $1',
      [eventId]
    );
    expect(parseInt(events.rows[0].count)).toBe(1);

    // Verificar que la orden solo se actualizó una vez
    const order = await db.query('SELECT * FROM orders WHERE id = $1', [orderId]);
    expect(order.rows[0].status).toBe('paid');
  });
});

Test de condiciones de carrera

Verifica que tu implementación maneja solicitudes concurrentes:

javascript
it('debe manejar solicitudes concurrentes del mismo evento', async () => {
  const eventId = 'evt_race_001';

  // Enviar el mismo evento 10 veces en paralelo
  const promises = Array.from({ length: 10 }, () =>
    sendWebhook({ eventId, payload: mockPaymentSuccessEvent })
  );

  const responses = await Promise.all(promises);

  // Todas deben responder 200
  responses.forEach(r => expect(r.status).toBe(200));

  // Solo 1 debe ser procesado como nuevo
  const nonDuplicates = responses.filter(r => !r.body.duplicate);
  expect(nonDuplicates.length).toBe(1);

  // Verificar en la base de datos
  const events = await db.query(
    'SELECT COUNT(*) FROM webhook_events WHERE event_id = $1',
    [eventId]
  );
  expect(parseInt(events.rows[0].count)).toBe(1);
});

Solución de problemas

ProblemaCausa probableSolución
Registros duplicados en la base de datosVerificación de idempotencia no es atómicaUsa INSERT ... ON CONFLICT dentro de una transacción
Lock contention altoMuchos eventos concurrentes para el mismo IDUsa ON CONFLICT DO NOTHING en lugar de SELECT + INSERT
Tabla de eventos crece indefinidamenteNo hay proceso de limpiezaImplementa un job de limpieza periódica
Falsos duplicadosCache o KV retorna datos obsoletosVerifica los TTL y la consistencia de tu almacenamiento
Eventos perdidos entre verificación y procesamientoOperaciones no atómicasEjecuta todo dentro de una transacción de base de datos

Siguientes pasos

  • -- Guía general de webhooks
  • -- Referencia completa de eventos y payloads
  • -- Reintentos, DLQ y lógica de entrega
  • -- Verificar autenticidad de los webhooks

Documentación oficial de Zelta