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
- HTTP Routes for route patterns
- Calling Agents from Routes for
c.waitUntil()handoff patterns