Idempotencia en webhooks de agentes: no cobres ni envíes dos veces
Resumen
Stripe reintenta un evento hasta tres días; Telegram reenvía el Update si no respondes 2xx. Esta guía enseña a deduplicar por event.id o update_id, firmar el payload, devolver 200 rápido y mover el trabajo del agente a una cola. Sin eso, un timeout duplica cargos y mensajes.

Qué resuelve
Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.
Un agente que “funciona en local” suele romper el primer día en producción por un detalle aburrido: el proveedor reenvía el mismo evento. Stripe lo documenta: en live mode intenta entregar hasta tres días con backoff exponencial. Telegram, si tu setWebhook no responde 2xx, repite el POST y se rinde tras un número razonable de intentos. Si tu handler llama al modelo y a una tool de cobro en el mismo request, el segundo POST cobra otra vez.
Esta guía no sustituye la arquitectura mínima de webhooks, colas y memoria. Aquí el foco es un solo contrato: el mismo evento produce el mismo efecto. El anuncio de webhooks de Gemini cubre jobs largos; esto cubre duplicados.
El contrato en una frase
El endpoint HTTP no es el agente. El endpoint:
- verifica firma,
- registra el id del evento,
- responde
200en milisegundos, - encola el trabajo.
El agente corre después, leyendo un job con event_id único.

Qué reintenta cada proveedor
| Fuente | Identificador estable | Reintento documentado | Firma |
|---|---|---|---|
| Stripe Events | event.id (evt_…) | Hasta 3 días en live, backoff exponencial | Header Stripe-Signature + secret whsec_ |
| Telegram Update | update_id | Reintenta si no hay 2xx; updates se guardan ≤24 h | Header X-Telegram-Bot-Api-Secret-Token si pasaste secret_token |
| Tu tool de cobro (salida) | Idempotency-Key (UUID v4, ≤255 chars) | Tú reintentas el POST | N/A — la API guarda status+body 24 h |
Stripe guarda el primer status y body de esa clave, incluso un 500. Si reusas la clave con otro body, error. GET/DELETE no llevan clave: ya son idempotentes. Telegram dice que update_id sirve para ignorar repeticiones o reordenar. Si no hay updates una semana, el siguiente id puede saltar al azar: no uses secuencias locales como prueba de unicidad.
Handler mínimo (sí, es corto a propósito)
// ponytail: SET NX 48h; pasa a tabla processed_events si hay varios workers
app.post("/webhooks/stripe", async (req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(req.rawBody, sig, process.env.STRIPE_WHSEC);
} catch {
return res.status(400).send("bad sig");
}
const fresh = await redis.set(`evt:${event.id}`, "1", "NX", "EX", 172800);
if (!fresh) return res.json({ ok: true, dup: true });
await queue.add("agent-job", { eventId: event.id, type: event.type });
return res.json({ ok: true });
});
Tres trampas que Stripe documenta y que tumbaron integraciones reales:
- Cuerpo crudo.
constructEventfalla si el framework parseó JSON y re-serializó. GuardarawBody. - 200 mentiroso. Si respondes 200 y luego el agente falla, Stripe no reintenta. La cola y un dead-letter son tu retry, no el webhook.
- 4xx vs 5xx. Un 400 por firma mala no se “arregla” reintentando el mismo payload. Un 500 sí invita a más POSTs.
Para Telegram el mismo patrón: compara secret_token, SET NX update:{update_id}, 200 inmediato. La Bot API permite responder el método (sendMessage) en el propio 200 del webhook. Eso es tentador y peligroso: si el POST se reintenta, mandas dos mensajes. Encola.
Tools que cobran o escriben
El webhook duplicado es la entrada. El agente también sale hacia APIs. Cada tool que crea un recurso (cargo, ticket, email) debe llevar una clave derivada del evento, no un UUID fresco por intento:
Idempotency-Key: stripe:${event.id}:refund
Así un worker que reintenta el job no crea un segundo refund. Stripe sugiere UUID v4; una clave determinista del event.id es mejor para agentes porque el retry es el mismo trabajo. No pongas emails ni PII en la clave.
Si tu modelo “decide” el monto, no uses esa decisión como parte de la clave después del primer intento: el body debe coincidir. Fija el plan de acción antes del primer POST.

Checklist de producción
- Firma verificada con secret de ese endpoint (test y live son distintos).
- Dedup
NX≥ 48 h (Stripe reintenta 3 días; 72 h es más seguro). - HTTP 200 antes del LLM.
- Job con
event_id+type; el agente es consumidor, no el handler. - Tools de escritura con
Idempotency-Keyestable. - Dead-letter + alerta; no “reenviar a mano” el webhook de Stripe si tu cola ya lo tiene: el retry automático sigue.
- Logs con
event.id/update_id, nunca el payload crudo con PII.
El resto de operación (sandbox, spend limits, traces) vive en seguridad de agentes de código y en prompt injection. El curso instalar un agente cubre el loop local; esto es el borde HTTP.
FAQ
¿Puedo devolver 200 y procesar en el mismo proceso? Solo si el trabajo es <1 s y no tiene side effects. El timeout del proxy convierte ese patrón en duplicados.
¿Una tabla SQL en vez de Redis? Sí: INSERT processed_events(id) ON CONFLICT DO NOTHING y encola solo si rowcount = 1. Es el mismo NX.
¿El update_id de Telegram es global? Es por bot. No lo mezcles entre entornos.
¿Stripe guarda fallos 500 en la Idempotency-Key? Sí. Si el primer intento devolvió 500, los retries con la misma clave repiten el 500. Cambia de clave solo cuando el body también cambie de verdad — y entonces ya no es el mismo trabajo.
Cuándo esto no basta
At-least-once en la cola sigue existiendo: dos workers pueden leer el mismo job si no hay lock. Añade un lease (GET worker, TTL 60 s) o un unique index en jobs(event_id). Si necesitas exactamente-una-vez de verdad, no lo prometas: promete efectos idempotentes.
Verificado 2026-09-03 contra Stripe Idempotent requests, Stripe webhooks (retries y firma) y Telegram Bot API (Update.update_id, setWebhook).
Lecturas relacionadas
Sigue explorando AgentOps y otras piezas para builders.

Timeouts en tools HTTP: AbortSignal.timeout, no un sleep eterno

Tests de tools de agentes sin LLM: Vitest primero, evals después

AWS empuja AgentOps con AgentCore: observabilidad, evals y gobernanza dejan de ser extras para agentes
