AI Agent Webhooks: HMAC, Idempotency & Retry Handling
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
INSERTwithPRIMARY KEYguarantees 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.