Ir al contenido principal
AI Agent Webhooks: HMAC, Idempotency & Retry Handling

AI Agent Webhooks: HMAC, Idempotency & Retry Handling

AI Integration
5 min readPor Daily Miranda Pardo

Your billing agent has been in production for a week. Stripe calls it every time a payment completes. The agent extracts the amount, updates Supabase, and sends the customer notification. Everything works.

Then on a Friday at 6:47 PM, Stripe hits a latency spike. Your handler takes 32 seconds. Stripe retries. Your agent processes the same event twice. The customer gets two confirmation emails. Your accountant sees a duplicate entry in the database.

This failure has three distinct causes. And all three are solved with the same pattern.

The endpoint that accepts any POST

The version that ships to production before anyone thinks it through:

// ❌ This accepts any POST — from Stripe or anyone else
export async function POST(req: Request) {
  const body = await req.json();
  await runBillingAgent(body); // processes without validating anything
  return Response.json({ ok: true });
}

Without origin validation, anyone who knows your URL can trigger the agent with arbitrary data. Without duplicate control, every provider retry re-executes your business logic. Without a fast response, the provider timeout (Stripe: 30s, Slack: 3s) expires before you finish, and it retries again.

Three problems, three solution layers.

Layer 1: HMAC validation — only process what came from who you think

Stripe, Slack, GitHub and most modern services sign every webhook with HMAC-SHA256. You configure the secret in their dashboard and in your environment. If the signature doesn't match, you reject without processing anything.

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 prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(`v1=${expectedSig}`),
    Buffer.from(parts['v1'] ?? '')
  );
}

export async function POST(req: Request) {
  const payload = await req.text(); // raw text for the signature
  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);
  // ... continues with idempotency
}

Important: read the body as text (req.text()) before parsing. HMAC is calculated on the raw payload. If you parse to JSON first and re-serialize, the signature won't match.

Layer 2: idempotency — one event, one execution

HMAC solves origin validation. But Stripe can retry a valid event if your handler was slow. The solution: use the event ID as an idempotency key before processing.

In Supabase, the table is 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
);

And in the handler:

async function processIdempotently(
  eventId: string,
  source: string,
  fn: () => Promise<void>
): Promise<{ skipped: boolean }> {
  const supabase = createServerClient();

  // INSERT fails with 23505 if the ID already exists (PRIMARY KEY)
  const { error } = await supabase
    .from('processed_webhooks')
    .insert({ webhook_id: eventId, source });

  if (error?.code === '23505') {
    // Already processed — return 200 without doing anything
    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;
  }
}

The key is inserting the ID before processing, not after. If the process fails halfway through, the event stays in failed state for auditing. If a retry arrives while you're processing, the INSERT fails immediately and you return 200 without duplicating.

Layer 3: fast ACK, process in background

Slack requires a response within 3 seconds. Your customer support agent might take two minutes. If you wait for the agent to finish before returning 200, Slack sees a timeout and retries — and now you have the Layer 2 problem again.

The solution: split the handler into two responsibilities.

// Route handler: validate and enqueue (< 200ms)
export async function POST(req: Request) {
  const payload = await req.text();

  // 1. Validate signature
  if (!verifySlackSignature(payload, req.headers)) {
    return new Response('Unauthorized', { status: 401 });
  }

  const event = JSON.parse(payload);

  // 2. Idempotency
  const { skipped } = await processIdempotently(
    event.event_id,
    'slack',
    async () => {
      // 3. Enqueue for background (instant)
      await supabase.from('agent_jobs').insert({
        type: 'slack_message',
        payload: event,
        status: 'pending',
      });
    }
  );

  return Response.json({ ok: true }); // ← Slack gets this in < 200ms
}

// Separate worker (cron, Supabase Edge Function, or Node process)
// Runs the agent without any time constraint
async function processAgentJob(job: AgentJob) {
  await runSlackAgent(job.payload);
}

This pattern — immediate ACK plus background queue for the real work — is the same one used in agent integrations with external APIs. The webhook responds before the provider loses patience. The agent runs without time pressure. And if the agent fails, the job stays in pending or failed for manual or automatic retry.

What this solves in production

With these three layers active:

  • 0 unauthorized calls reach the agent — any POST without a valid signature returns 401 before executing anything
  • 0 duplicates even if the provider retries 3, 5, or 10 times — the INSERT with PRIMARY KEY guarantees a single execution
  • Response < 200ms in all cases — the provider never sees a timeout

The pattern works the same for Stripe, Slack, GitHub, Notion, or any service that sends webhooks. The only thing that changes is the header and the signature algorithm — the handler structure is always the same.

If you're building agents that connect to external systems and don't have these three layers, it's not a question of whether you'll see duplicates or unauthorized calls. It's a question of when.


Building webhook-driven agent integrations and want the architecture solid from day one? Tell me how your current flow is set up and we'll go through what's missing.

Let's talk on WhatsApp →

Compartir artículo

LinkedInXWhatsApp

¿Procesos repetitivos en tu empresa?

Descarga gratis el Mapa de Automatización IA — los 5 procesos que más tiempo roban y cómo resolverlos.

Sin spam. Solo el PDF. Puedes darte de baja cuando quieras.

Escrito por Daily Miranda Pardo

Ayudo a empresas a automatizar procesos, crear agentes IA y conectar sistemas inteligentes.