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.