Blog · Ingeniería
Idempotencia al enviar notificaciones: evita duplicados
Los avisos duplicados nacen de reintentos. Con la cabecera Idempotency-Key y una clave derivada del evento, repetir una petición devuelve la notificación original.
Las notificaciones duplicadas casi siempre nacen de un reintento: tu servidor envió la petición, la respuesta se perdió por el camino y el segundo intento creó otro envío. La solución es la cabecera Idempotency-Key: mandas la misma clave en todos los intentos de una misma operación y la API devuelve la notificación original en lugar de crear otra.
En resumen:
- Un
POSTno es idempotente: repetirlo puede crear dos notificaciones y avisar dos veces al mismo usuario.- Con
Idempotency-Key, los reintentos con la misma clave durante 24 horas devuelven la primera notificación.- La clave se deriva del evento de negocio (
order:8812:shipped), nunca se genera de nuevo en cada intento.- Reintenta solo errores de red, 429 y 5xx, de uno en uno y con espera creciente.
- Para reprocesos de días, añade una tabla con clave única en tu base de datos.
De dónde salen los duplicados
HTTP define qué métodos son idempotentes, es decir, cuáles puedes repetir sin cambiar el resultado. GET, PUT y DELETE lo son; POST no (lo detalla la sección 9.2.2 de RFC 9110). Crear una notificación es un POST, así que cada repetición es, en principio, un envío nuevo.
El caso típico es un timeout. Tu backend llama a la API, la notificación se crea y se pone en cola, pero la respuesta tarda más que tu límite de diez segundos o la conexión se corta. Tu código no sabe si funcionó, reintenta y el cliente recibe «Tu pedido va en camino» dos veces. Hay otras fuentes igual de habituales:
- Colas de trabajos con entrega «al menos una vez», que pueden entregar el mismo mensaje dos veces a tu consumidor.
- Webhooks de pagos o de logística que el proveedor reenvía si tu endpoint tardó en responder.
- Un cron que se solapa con la ejecución anterior o que corre en dos instancias tras un despliegue.
- Un doble clic en el botón «Enviar» de tu panel interno.
En avisos transaccionales el duplicado se nota mucho: dos «pago recibido» seguidos hacen dudar de si se cobró dos veces.
Cómo funciona Idempotency-Key
El patrón lo popularizaron las APIs de pagos y hoy hay un borrador del IETF que lo estandariza (aún no es RFC). La idea es simple: el cliente adjunta una clave única por operación; el servidor guarda la relación entre esa clave y el resultado, y si la clave vuelve a llegar, responde con el resultado guardado sin ejecutar nada.
En la API de castly, POST /v1/notifications acepta la cabecera y recuerda cada clave durante 24 horas:
curl -X POST https://castly.sh/v1/notifications \
-H "Authorization: Bearer $CASTLY_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order:8812:shipped" \
-d '{"to":{"type":"users","userIds":["u_42"]},
"title":"Tu pedido va en camino",
"url":"https://tutienda.com/pedidos/8812"}'
# HTTP 202
# {"id":"…","status":"queued"} Si repites esa misma llamada una hora después, recibes otra vez un 202 con el mismo id, y no se crea una segunda notificación ni se gastan envíos nuevos. Guarda ese id: con GET /v1/notifications/:id consultas después el estado de cada dispositivo. Todos los campos están en la referencia de la API.
Cómo construir una buena clave
La clave tiene que identificar la operación de negocio, no el intento HTTP. Si dos intentos describen el mismo hecho («el pedido 8812 salió del almacén»), deben llevar la misma clave; si son hechos distintos, claves distintas.
| Evento | Clave |
|---|---|
| Pedido enviado | order:8812:shipped |
| Recordatorio de cita, 24 h antes | booking:5531:reminder-24h |
| Nueva respuesta en un hilo | reply:99071 |
| Campaña a un segmento | campaign:2026-11-rebajas:v1 |
Errores frecuentes que anulan la protección:
- Un UUID nuevo en cada intento. Un UUID aleatorio vale si lo generas una vez, lo guardas junto al trabajo y lo reutilizas; generado dentro del bucle de reintentos no protege de nada.
- Una marca de tiempo en la clave.
order:8812:1763049600cambia en cada reintento si la hora sale del reloj actual. - Una clave demasiado amplia.
user:42bloquearía durante 24 horas cualquier otro aviso legítimo para ese usuario. - Reutilizar la clave con otro contenido. Si corriges el texto de una campaña ya enviada, eso es otra operación: usa
v2.
Reintentos seguros con la misma clave
La clave solo sirve si tu política de reintentos la respeta. Esta función en TypeScript reintenta errores de red, 429 y 5xx con espera exponencial, y falla de inmediato ante cualquier otro 4xx, porque repetir una petición mal formada no la arregla:
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function sendOnce(message: object, idempotencyKey: string) {
for (let attempt = 0; attempt < 4; attempt++) {
if (attempt > 0) await sleep(500 * 2 ** attempt); // 1 s, 2 s, 4 s
let res: Response;
try {
res = await fetch('https://castly.sh/v1/notifications', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTLY_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // la misma en cada intento
},
body: JSON.stringify(message),
signal: AbortSignal.timeout(10_000),
});
} catch {
continue; // red caída o timeout: quizá se creó, reintenta igual
}
if (res.ok) return (await res.json()) as { id: string; status: string };
if (res.status !== 429 && res.status < 500) {
throw new Error(`Petición rechazada (${res.status}): ${await res.text()}`);
}
}
throw new Error('Sin respuesta válida tras 4 intentos');
} Dos detalles importan. Primero, los intentos van en serie: no lances dos peticiones en paralelo con la misma clave «por si acaso». Segundo, el límite de 300 peticiones por minuto es por clave de API, así que un 429 en mitad de un envío masivo se resuelve esperando, no multiplicando reintentos. Si llamas a la API desde un Worker, el mismo patrón aplica; lo vemos en enviar web push desde Cloudflare Workers.
Más allá de 24 horas: deduplica en tu base de datos
La ventana de 24 horas cubre reintentos inmediatos y caídas cortas. No cubre un reproceso manual de una cola de mensajes fallidos tres días después, ni un script de migración que vuelve a disparar eventos antiguos. Para eso necesitas memoria propia, y la forma más sencilla es una bandeja de salida con clave única:
CREATE TABLE push_outbox (
event_key TEXT PRIMARY KEY, -- 'order:8812:shipped'
payload TEXT NOT NULL,
notification_id TEXT, -- id que devuelve la API
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Al ocurrir el evento: si ya existía, no se inserta nada
INSERT INTO push_outbox (event_key, payload) VALUES (?, ?)
ON CONFLICT (event_key) DO NOTHING; Un proceso recoge las filas sin notification_id, llama a sendOnce usando event_key como Idempotency-Key y guarda el id devuelto. Las dos capas se complementan: la tabla garantiza un único envío por evento para siempre, y la cabecera cubre el hueco peligroso entre que la API acepta la petición y tú guardas el id. Si el proceso se cae justo ahí, el siguiente intento recibe el mismo id en vez de duplicar.
Aplica la misma lógica a lo que recibes: si un webhook de pagos dispara tus avisos, registra su identificador de evento y descarta los repetidos. Y si consumes los webhooks de castly, verifica su firma y procesa cada evento una sola vez; lo explicamos en cómo verificar webhooks con HMAC.
Preguntas frecuentes
¿Qué pasa si reintento con la misma Idempotency-Key después de 24 horas?
Pasado ese plazo la clave ya no se recuerda y la petición se trata como nueva, así que se crea otra notificación. Si tus reprocesos pueden llegar más tarde, deduplica también en tu base de datos con una clave única por evento.
¿Puedo usar un UUID aleatorio como clave?
Sí, siempre que lo generes una sola vez por operación y lo guardes con el trabajo para reutilizarlo en cada intento. Una clave derivada del evento, como order:8812:shipped, suele ser más práctica porque cualquier proceso puede reconstruirla.
¿Qué devuelve la API cuando repito una petición con la misma clave?
Un 202 con el mismo id de la primera notificación. No se crea un envío nuevo, así que tampoco se consumen envíos de tu plan.
¿Qué errores debo reintentar y cuáles no?
Reintenta errores de red, timeouts, 429 y respuestas 5xx, siempre con la misma clave y con espera creciente. Un 400, 401 o 403 indica un problema en la petición o en la clave de API, y repetirla no lo arregla.
¿Idempotency-Key es un estándar?
Es un patrón muy extendido en APIs de pagos y mensajería, y existe un borrador del IETF para estandarizar la cabecera. A noviembre de 2026 todavía no se ha publicado como RFC.