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
// 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
// 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)
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
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
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:
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:
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
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
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
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
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
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)
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:
// 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:
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:
-- Eliminar eventos procesados hace más de 30 días
DELETE FROM webhook_events WHERE processed_at < NOW() - INTERVAL '30 days';// 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:
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:
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:
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
| Problema | Causa probable | Solución |
|---|---|---|
| Registros duplicados en la base de datos | Verificación de idempotencia no es atómica | Usa INSERT ... ON CONFLICT dentro de una transacción |
| Lock contention alto | Muchos eventos concurrentes para el mismo ID | Usa ON CONFLICT DO NOTHING en lugar de SELECT + INSERT |
| Tabla de eventos crece indefinidamente | No hay proceso de limpieza | Implementa un job de limpieza periódica |
| Falsos duplicados | Cache o KV retorna datos obsoletos | Verifica los TTL y la consistencia de tu almacenamiento |
| Eventos perdidos entre verificación y procesamiento | Operaciones no atómicas | Ejecuta 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