Webhooks en agentes IA: HMAC, idempotencia y reintentos
Tu agente de facturación lleva una semana en producción. Stripe lo llama cada vez que se completa un pago. El agente extrae el importe, actualiza Supabase, manda la notificación al cliente. Todo bien.
Hasta que un viernes a las 18:47 Stripe tiene un spike de latencia. Tu handler tarda 32 segundos. Stripe reintenta. Tu agente procesa el mismo evento dos veces. El cliente recibe dos emails de confirmación. Tu contable ve una entrada duplicada en la BD.
Este fallo tiene tres causas distintas. Y las tres se resuelven con el mismo patrón.
El endpoint que acepta cualquier POST
La versión que acaba en producción antes de que alguien lo piense:
// ❌ Esto acepta cualquier POST — de Stripe o de cualquier otro
export async function POST(req: Request) {
const body = await req.json();
await runBillingAgent(body); // procesa sin validar nada
return Response.json({ ok: true });
}
Sin validación de origen, cualquiera que conozca tu URL puede disparar el agente con datos arbitrarios. Sin control de duplicados, cada reintento del proveedor ejecuta la lógica de negocio de nuevo. Sin respuesta rápida, el timeout del proveedor (Stripe: 30s, Slack: 3s) se agota antes de que termines y vuelve a reintentar.
Tres problemas, tres capas de solución.
Capa 1: validación HMAC — solo procesas lo que vino de quien crees
Stripe, Slack, GitHub y la mayoría de servicios modernos firman cada webhook con HMAC-SHA256. El secreto lo configuras en su dashboard y en tu entorno. Si la firma no cuadra, rechazas sin procesar nada.
import crypto from 'crypto';
function verifyStripeSignature(
payload: string,
sigHeader: string,
secret: string
): boolean {
// sigHeader format: "t=1234567890,v1=abc123..."
const parts = Object.fromEntries(
sigHeader.split(',').map(p => {
const idx = p.indexOf('=');
return [p.slice(0, idx), p.slice(idx + 1)];
})
);
const timestamp = parts['t'];
const expectedSig = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
// timingSafeEqual previene timing attacks
return crypto.timingSafeEqual(
Buffer.from(`v1=${expectedSig}`),
Buffer.from(parts['v1'] ?? '')
);
}
export async function POST(req: Request) {
const payload = await req.text(); // texto plano para la firma
const sig = req.headers.get('stripe-signature') ?? '';
if (!verifyStripeSignature(payload, sig, process.env.STRIPE_WEBHOOK_SECRET!)) {
return new Response('Unauthorized', { status: 401 });
}
const event = JSON.parse(payload);
// ... continúa con idempotencia
}
Importante: lee el body como texto (req.text()) antes de parsearlo. El HMAC se calcula sobre el payload en crudo. Si parseas a JSON primero y luego re-serializas, la firma no cuadra.
Capa 2: idempotencia — un evento, una ejecución
Con HMAC resuelves el origen. Pero Stripe puede reintentar un evento válido si tu handler fue lento. La solución: usa el ID del evento como clave de idempotencia antes de procesar.
En Supabase, la tabla es simple:
CREATE TABLE processed_webhooks (
webhook_id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'unknown',
status TEXT NOT NULL DEFAULT 'processing',
received_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
Y en el handler:
async function processIdempotently(
eventId: string,
source: string,
fn: () => Promise<void>
): Promise<{ skipped: boolean }> {
const supabase = createServerClient();
// INSERT falla con 23505 si el ID ya existe (PRIMARY KEY)
const { error } = await supabase
.from('processed_webhooks')
.insert({ webhook_id: eventId, source });
if (error?.code === '23505') {
// Ya procesado — devuelve 200 sin hacer nada
return { skipped: true };
}
try {
await fn();
await supabase
.from('processed_webhooks')
.update({ status: 'done', processed_at: new Date().toISOString() })
.eq('webhook_id', eventId);
return { skipped: false };
} catch (err) {
await supabase
.from('processed_webhooks')
.update({ status: 'failed' })
.eq('webhook_id', eventId);
throw err;
}
}
La clave está en insertar el ID antes de procesar, no después. Si el proceso falla a mitad, el evento queda en estado failed y puedes auditarlo. Si un reintento llega mientras estás procesando, el INSERT falla inmediatamente y devuelves 200 sin duplicar.
Capa 3: ACK rápido, procesa en background
Slack requiere respuesta en 3 segundos. Tu agente de atención al cliente puede tardar dos minutos. Si esperas a que el agente termine antes de devolver 200, Slack ve un timeout y reintenta — y ahora tienes el problema de la capa 2 otra vez.
La solución: divide el handler en dos responsabilidades.
// Route handler: valida y encola (< 200ms)
export async function POST(req: Request) {
const payload = await req.text();
// 1. Validar firma
if (!verifySlackSignature(payload, req.headers)) {
return new Response('Unauthorized', { status: 401 });
}
const event = JSON.parse(payload);
// 2. Idempotencia
const { skipped } = await processIdempotently(
event.event_id,
'slack',
async () => {
// 3. Encolar para background (instantáneo)
await supabase.from('agent_jobs').insert({
type: 'slack_message',
payload: event,
status: 'pending',
});
}
);
return Response.json({ ok: true }); // ← Slack recibe esto en < 200ms
}
// Worker separado (cron, Supabase Edge Function, o proceso Node)
// Ejecuta el agente sin restricción de tiempo
async function processAgentJob(job: AgentJob) {
await runSlackAgent(job.payload);
}
Este patrón — ACK inmediato + cola para el trabajo real — es el mismo que usa la integración de agentes con n8n. El webhook responde antes de que el proveedor se impaciente. El agente corre sin presión de tiempo. Y si el agente falla, el job queda en pending o failed para reintento manual o automático.
Lo que esto resuelve en producción
Con estas tres capas activas:
- 0 llamadas no autorizadas llegan al agente — cualquier POST sin firma válida devuelve 401 antes de ejecutar nada
- 0 duplicados aunque el proveedor reintente 3, 5 o 10 veces — el
INSERTconPRIMARY KEYgarantiza una sola ejecución - Respuesta < 200ms en todos los casos — el proveedor nunca ve un timeout
El patrón funciona igual para Stripe, Slack, GitHub, Notion, o cualquier servicio que envíe webhooks. Lo único que cambia es la cabecera y el algoritmo de firma — la estructura del handler es siempre la misma.
Si estás construyendo agentes que conectan con sistemas externos y no tienes estas tres capas, no es cuestión de si habrá duplicados o llamadas no autorizadas. Es cuestión de cuándo.
¿Estás integrando webhooks en tu stack de automatización y quieres que la arquitectura sea sólida desde el primer día? Cuéntame cómo tienes montado ahora el flujo y vemos qué falta.