Webhook Integration
Webhooks are the recommended way to stay in sync with Otesse. This guide walks through building a production-ready webhook handler.
Architecture
Otesse Event → HTTP POST → Your Endpoint → Queue → Worker → Process
↓
Verify Signature
↓
Return 200 OK
The key principle: accept fast, process later. Your webhook endpoint should verify the signature, enqueue the event, and return 200 within a few seconds. Processing happens asynchronously.
Complete Handler Example (Node.js + Express)
import express from 'express';
import crypto from 'crypto';
import { Queue } from 'bullmq';
const app = express();
const eventQueue = new Queue('otesse-events');
// Use raw body for signature verification
app.use('/webhooks/otesse', express.raw({ type: 'application/json' }));
app.post('/webhooks/otesse', async (req, res) => {
const signature = req.headers['x-otesse-signature'] as string;
const deliveryId = req.headers['x-otesse-delivery'] as string;
const rawBody = req.body.toString();
// Step 1: Verify signature
const expected = crypto
.createHmac('sha256', process.env.OTESSE_WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Step 2: Parse the event
const event = JSON.parse(rawBody);
// Step 3: Check for duplicates
const processed = await redis.get(`webhook:${deliveryId}`);
if (processed) {
return res.status(200).json({ status: 'already_processed' });
}
// Step 4: Enqueue for async processing
await eventQueue.add(event.type, {
event,
deliveryId,
receivedAt: Date.now(),
});
// Step 5: Mark as received and return quickly
await redis.set(`webhook:${deliveryId}`, '1', 'EX', 86400 * 7);
res.status(200).json({ status: 'received' });
});
Event Processing Worker
import { Worker } from 'bullmq';
const worker = new Worker('otesse-events', async (job) => {
const { event, deliveryId } = job.data;
switch (event.type) {
case 'booking.created':
await handleBookingCreated(event.data);
break;
case 'booking.completed':
await handleBookingCompleted(event.data);
break;
case 'payment.succeeded':
await handlePaymentSucceeded(event.data);
break;
case 'payment.failed':
await handlePaymentFailed(event.data);
break;
case 'customer.created':
await handleCustomerCreated(event.data);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}, {
connection: redisConnection,
concurrency: 5,
});
// Example handler
async function handleBookingCreated(booking: any) {
// Sync to external calendar
await calendarService.createEvent({
title: `Otesse - ${booking.service.industry}`,
start: new Date(booking.scheduled_at),
duration: booking.estimated_duration,
location: `${booking.address.street}, ${booking.address.city}`,
});
// Notify internal team via Slack
await slack.post('#new-bookings', {
text: `New booking: ${booking.id} for ${booking.customer.name}`,
});
}
Testing Your Handler
Unit Tests
describe('Webhook Handler', () => {
it('rejects invalid signatures', async () => {
const response = await request(app)
.post('/webhooks/otesse')
.set('X-Otesse-Signature', 'invalid')
.send({ type: 'booking.created', data: {} });
expect(response.status).toBe(401);
});
it('processes valid events', async () => {
const payload = JSON.stringify({ type: 'booking.created', data: mockBooking });
const signature = createValidSignature(payload);
const response = await request(app)
.post('/webhooks/otesse')
.set('X-Otesse-Signature', signature)
.set('X-Otesse-Delivery', 'test-delivery-1')
.type('application/json')
.send(payload);
expect(response.status).toBe(200);
});
});
Use the Test Endpoint
From the Otesse dashboard, send test events to your endpoint to verify it handles each event type correctly.
On this page