Webhook Handler Examples

Complete, production-ready examples for handling AI Interview webhooks in various languages and frameworks.

Node.js + Express

const express = require('express');
const crypto = require('crypto');
const app = express();

// Important: preserve raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

function verifySignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// Idempotency tracking (use Redis in production)
const processedEvents = new Set();

app.post('/webhooks/ai-interview', async (req, res) => {
  // 1. Verify signature
  const signature = req.headers['x-webhook-signature'];
  if (!verifySignature(req.rawBody, signature, WEBHOOK_SECRET)) {
    console.error('Invalid signature');
    return res.status(401).send('Invalid signature');
  }

  // 2. Check idempotency
  const eventKey = `${req.body.event}:${req.body.session_id}`;
  if (processedEvents.has(eventKey)) {
    console.log('Already processed:', eventKey);
    return res.status(200).send('Already processed');
  }

  // 3. Respond quickly (queue for async processing)
  res.status(200).send('OK');

  // 4. Process asynchronously
  processedEvents.add(eventKey);
  
  try {
    await processWebhook(req.body);
  } catch (error) {
    console.error('Webhook processing failed:', error);
    processedEvents.delete(eventKey); // Retry on next delivery
  }
});

async function processWebhook(payload) {
  const { event, session_id, invitee_email } = payload;

  if (event === 'interview.completed') {
    console.log(`Interview completed for ${invitee_email}`);
    
    // Download transcript
    if (payload.transcript_url) {
      const transcriptRes = await fetch(payload.transcript_url);
      const transcript = await transcriptRes.json();
      
      // Store in database
      await db.interviews.create({
        session_id,
        participant_email: invitee_email,
        transcript,
        duration_minutes: payload.total_minutes,
        completed_at: payload.timestamp,
      });
    }
    
    // Notify team via Slack
    await notifySlack({
      text: `New interview completed: ${invitee_email}`,
      session_id,
    });
    
  } else if (event === 'session.feedback_submitted') {
    console.log(`Feedback submitted for ${session_id}`);
    
    await db.feedback.create({
      session_id,
      ...payload.feedback,
    });
  }
}

async function notifySlack({ text, session_id }) {
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text,
      blocks: [
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text: `*${text}*\nSession: \`${session_id}\``,
          },
        },
      ],
    }),
  });
}

app.listen(3000, () => {
  console.log('Webhook server running on port 3000');
});

Python + Flask

import hmac
import hashlib
import os
from flask import Flask, request
from datetime import datetime

app = Flask(__name__)

WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET'].encode()

# Idempotency tracking (use Redis in production)
processed_events = set()

def verify_signature(payload, signature):
    expected_signature = hmac.new(
        WEBHOOK_SECRET,
        payload,
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, expected_signature)

@app.route('/webhooks/ai-interview', methods=['POST'])
def handle_webhook():
    # 1. Verify signature
    signature = request.headers.get('X-Webhook-Signature')
    payload = request.get_data()
    
    if not verify_signature(payload, signature):
        return 'Invalid signature', 401
    
    # 2. Parse data
    data = request.get_json()
    
    # 3. Check idempotency
    event_key = f"{data['event']}:{data['session_id']}"
    if event_key in processed_events:
        return 'Already processed', 200
    
    # 4. Respond quickly
    processed_events.add(event_key)
    
    # 5. Process webhook (in background thread/queue in production)
    try:
        process_webhook(data)
    except Exception as e:
        print(f"Error processing webhook: {e}")
        processed_events.remove(event_key)
        return 'Processing failed', 500
    
    return 'OK', 200

def process_webhook(payload):
    event = payload['event']
    session_id = payload['session_id']
    
    if event == 'interview.completed':
        print(f"Interview completed: {session_id}")
        
        # Download transcript
        if payload.get('transcript_url'):
            import requests
            response = requests.get(payload['transcript_url'])
            transcript = response.json()
            
            # Store in database
            from models import db, Interview
            interview = Interview(
                session_id=session_id,
                participant_email=payload['invitee_email'],
                transcript=transcript,
                duration_minutes=payload.get('total_minutes'),
                completed_at=datetime.fromisoformat(payload['timestamp'])
            )
            db.session.add(interview)
            db.session.commit()
        
        # Send notification
        notify_team(f"Interview completed: {payload['invitee_email']}")
        
    elif event == 'session.feedback_submitted':
        print(f"Feedback submitted: {session_id}")
        
        from models import db, Feedback
        feedback = Feedback(
            session_id=session_id,
            **payload['feedback']
        )
        db.session.add(feedback)
        db.session.commit()

def notify_team(message):
    # Implement your notification logic
    print(f"NOTIFICATION: {message}")

if __name__ == '__main__':
    app.run(port=3000)

Next.js API Route

// pages/api/webhooks/ai-interview.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';
import { prisma } from '@/lib/prisma';

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;

function verifySignature(payload: string, signature: string): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

export const config = {
  api: {
    bodyParser: false, // We need raw body for signature verification
  },
};

async function getRawBody(req: NextApiRequest): Promise<string> {
  return new Promise((resolve, reject) => {
    let data = '';
    req.on('data', chunk => { data += chunk; });
    req.on('end', () => resolve(data));
    req.on('error', reject);
  });
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    // Get raw body
    const rawBody = await getRawBody(req);
    
    // Verify signature
    const signature = req.headers['x-webhook-signature'] as string;
    if (!signature || !verifySignature(rawBody, signature)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Parse payload
    const payload = JSON.parse(rawBody);

    // Check idempotency
    const existing = await prisma.webhookEvent.findUnique({
      where: {
        event_session: {
          event: payload.event,
          session_id: payload.session_id,
        },
      },
    });

    if (existing) {
      return res.status(200).json({ message: 'Already processed' });
    }

    // Store event
    await prisma.webhookEvent.create({
      data: {
        event: payload.event,
        session_id: payload.session_id,
        payload,
      },
    });

    // Process webhook
    await processWebhook(payload);

    return res.status(200).json({ message: 'OK' });
    
  } catch (error) {
    console.error('Webhook error:', error);
    return res.status(500).json({ error: 'Internal server error' });
  }
}

async function processWebhook(payload: any) {
  if (payload.event === 'interview.completed') {
    // Fetch transcript
    if (payload.transcript_url) {
      const response = await fetch(payload.transcript_url);
      const transcript = await response.json();

      // Store in database
      await prisma.interview.create({
        data: {
          sessionId: payload.session_id,
          participantEmail: payload.invitee_email,
          transcript,
          durationMinutes: payload.total_minutes,
          completedAt: new Date(payload.timestamp),
        },
      });
    }
  }
}

Production Best Practices

1. Use a Queue

Don't process webhooks synchronously. Use a queue:

const { Queue } = require('bullmq');

const webhookQueue = new Queue('webhooks', {
  connection: {
    host: 'localhost',
    port: 6379,
  },
});

app.post('/webhooks', async (req, res) => {
  // Verify signature...
  
  // Add to queue
  await webhookQueue.add('process', req.body);
  
  // Respond immediately
  res.status(200).send('OK');
});

// Worker process
const { Worker } = require('bullmq');

new Worker('webhooks', async (job) => {
  await processWebhook(job.data);
}, {
  connection: { host: 'localhost', port: 6379 }
});

2. Idempotency with Redis

const Redis = require('ioredis');
const redis = new Redis();

async function isProcessed(eventKey) {
  return await redis.exists(eventKey);
}

async function markProcessed(eventKey) {
  await redis.setex(eventKey, 86400, '1'); // 24h TTL
}

app.post('/webhooks', async (req, res) => {
  const eventKey = `webhook:${req.body.event}:${req.body.session_id}`;
  
  if (await isProcessed(eventKey)) {
    return res.status(200).send('Already processed');
  }
  
  await markProcessed(eventKey);
  // Process...
});

3. Error Handling & Retries

async function processWebhook(payload, retryCount = 0) {
  try {
    // Process webhook...
  } catch (error) {
    if (retryCount < 3) {
      console.log(`Retry ${retryCount + 1}/3`);
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, retryCount)));
      return processWebhook(payload, retryCount + 1);
    }
    
    // Log failure for manual review
    await db.failedWebhooks.create({
      payload,
      error: error.message,
      retries: retryCount,
    });
    
    throw error;
  }
}

4. Monitoring

const prometheus = require('prom-client');

const webhookCounter = new prometheus.Counter({
  name: 'webhooks_received_total',
  help: 'Total webhooks received',
  labelNames: ['event', 'status'],
});

app.post('/webhooks', async (req, res) => {
  try {
    // Process...
    webhookCounter.inc({ event: req.body.event, status: 'success' });
    res.status(200).send('OK');
  } catch (error) {
    webhookCounter.inc({ event: req.body.event, status: 'failure' });
    res.status(500).send('Error');
  }
});

Testing Locally

Use ngrok to test webhooks locally:

# Start your server
npm start

# In another terminal, start ngrok
ngrok http 3000

# Use the HTTPS URL in webhook settings
https://abc123.ngrok.io/webhooks/ai-interview

Additional Resources