Webhook Handler — Agentuity Documentation

Webhook Handler

Handle incoming webhooks with signature verification and background processing

Process webhooks from external services (Stripe, GitHub, Slack) with proper signature verification and fast response times.

The Pattern

Webhooks require quick responses, usually under 3 seconds. Verify the raw request body first, then use waitUntil to acknowledge immediately and process in the background.

For Stripe, use the official Node SDK to verify the stripe-signature header against the raw body:

typescriptsrc/api/webhooks/route.ts
import { Hono } from 'hono';
import type { Env } from '@agentuity/runtime';
import Stripe from 'stripe';
import stripeEventHandler from '@agent/stripe-event-handler/agent';
 
const router = new Hono<Env>();
 
router.post('/stripe', async (c) => {
  const rawBody = await c.req.text();
  const signature = c.req.header('stripe-signature');
  const apiKey = process.env.STRIPE_SECRET_KEY;
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
 
  if (!apiKey || !webhookSecret || !signature) {
    c.var.logger.error('Stripe webhook is missing configuration');
    return c.text('Webhook not configured', 500);
  }
 
  const stripe = new Stripe(apiKey);
  let event: Stripe.Event;
 
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); 
  } catch (error) {
    c.var.logger.warn('Invalid Stripe webhook signature', { error });
    return c.text('Invalid signature', 400);
  }
 
  c.var.logger.info('Webhook received', { type: event.type });
 
  c.waitUntil(async () => { 
    try {
      await stripeEventHandler.run({
        eventId: event.id,
        type: event.type,
        payload: event.data.object,
      });
    } catch (error) {
      c.var.logger.error('Webhook processing failed', { error, eventType: event.type });
 
      await c.var.kv.set('failed-webhooks', event.id, {
        event,
        error: error instanceof Error ? error.message : 'Unknown error',
        timestamp: Date.now(),
      }, { ttl: 86400 }); // 24 hours
    }
  });
 
  return c.json({ received: true }); 
});
 
export default router;

Slack Webhook Example

typescriptsrc/api/webhooks/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import slackHandler from '@agent/slack-handler/agent';
 
router.post('/slack', async (c) => {
  // Slack retries on failure, skip duplicates
  if (c.req.header('x-slack-retry-num')) {
    return c.text('OK');
  }
 
  const rawBody = await c.req.text();
 
  const timestamp = c.req.header('x-slack-request-timestamp');
  const signature = c.req.header('x-slack-signature');
  const secret = process.env.SLACK_SIGNING_SECRET;
 
  if (!verifySlackSignature(rawBody, timestamp, signature, secret)) {
    return c.text('Invalid signature', 401);
  }
 
  const payload: unknown = JSON.parse(rawBody);
 
  if (isSlackUrlVerification(payload)) {
    return c.text(payload.challenge);
  }
 
  c.waitUntil(async () => { 
    await slackHandler.run(payload);
  });
 
  return c.text('OK');
});
 
function verifySlackSignature(
  rawBody: string,
  timestamp: string | undefined,
  signature: string | undefined,
  secret: string | undefined
): boolean {
  if (!timestamp || !signature || !secret) return false;
 
  const timestampSeconds = Number(timestamp);
  if (!Number.isFinite(timestampSeconds)) return false;
 
  const ageSeconds = Math.abs(Date.now() / 1000 - timestampSeconds);
  if (ageSeconds > 60 * 5) return false;
 
  const expected = 'v0=' + createHmac('sha256', secret)
    .update(`v0:${timestamp}:${rawBody}`)
    .digest('hex');
 
  const expectedBuffer = Buffer.from(expected, 'utf8');
  const signatureBuffer = Buffer.from(signature, 'utf8');
 
  if (expectedBuffer.length !== signatureBuffer.length) return false;
  return timingSafeEqual(expectedBuffer, signatureBuffer);
}
 
function isSlackUrlVerification(
  payload: unknown
): payload is { type: 'url_verification'; challenge: string } {
  return (
    typeof payload === 'object' &&
    payload !== null &&
    'type' in payload &&
    payload.type === 'url_verification' &&
    'challenge' in payload &&
    typeof payload.challenge === 'string'
  );
}

Key Points

  • Raw body first: Read body as text before parsing for signature verification
  • Fast response: Return a 2xx quickly, process with waitUntil
  • Error handling: Store failed webhooks for retry/debugging
  • Signature verification: Always verify webhooks from external services

See Also