Webhooks

Webhooks allow you to receive real-time notifications when events occur in your AI Interview campaigns. Push transcripts, audio files, and structured data directly to your systems.

Quick Start

Creating a Webhook

  1. Navigate to SettingsIntegrations
  2. Click Create Webhook
  3. Select a project
  4. Enter your HTTPS endpoint URL
  5. Select event types to subscribe to
  6. Save and copy the secret (shown only once!)

Minimal Handler Example

Node.js Example:

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

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

app.post('/webhooks', (req, res) => {
  // 1. Verify signature
  const signature = req.headers['x-webhook-signature'];
  const payload = JSON.stringify(req.body);
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  if (signature !== expectedSignature) {
    return res.status(401).send('Invalid signature');
  }

  // 2. Process the event
  const { event, session_id } = req.body;
  console.log(`Received ${event} for session ${session_id}`);

  // 3. Respond quickly
  res.status(200).send('OK');
});

app.listen(3000);

Python Example:

import hmac
import hashlib
from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = "your-secret-here"

@app.route('/webhooks', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    payload = request.get_data()
    
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    
    if not hmac.compare_digest(signature, expected_signature):
        return 'Invalid signature', 401
    
    data = request.get_json()
    print(f"Received {data['event']} for session {data['session_id']}")
    
    return 'OK', 200

Security Requirements

Critical Security

Always verify webhook signatures to prevent unauthorized requests. Never trust incoming data without verification.

HTTPS Only

All webhook endpoints MUST use HTTPS. HTTP URLs are rejected.

✅ https://api.yourapp.com/webhooks
❌ http://api.yourapp.com/webhooks

Signature Verification

Every webhook includes an X-Webhook-Signature header with an HMAC-SHA256 signature:

function verifySignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)  // Must be raw request body string!
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

Store Secrets Securely

Never commit secrets to version control. Use environment variables:

# .env (gitignored)
WEBHOOK_SECRET=your-64-char-hex-secret-here

Rotate Secrets Regularly

Rotate webhook secrets every 90 days or immediately if compromised:

  1. Go to Settings → Integrations
  2. Click Rotate Secret next to your webhook
  3. Update your environment variable
  4. Test to confirm it works

Event Types

Subscribe to specific events when creating a webhook:

NameTypeDescription
interview.completedeventInterview session successfully finished
session.feedback_submittedeventParticipant submitted post-interview feedback
*wildcardAll events (useful for logging or debugging)

Payload Structure

Common Fields

All webhook payloads include these fields:

interface WebhookPayload {
  event: string;                  // Event type
  project_id: string;             // Project UUID
  script_id: string;              // Script UUID
  campaign_id?: string;           // Campaign UUID (if applicable)
  campaign_name?: string;         // Human-readable campaign name
  session_id: string;             // Session UUID
  invitee_email: string;          // Participant email
  locale: string;                 // Language code (e.g., "en")
  timestamp: string;              // ISO 8601 timestamp
}

interview.completed Event

interface InterviewCompletedPayload extends WebhookPayload {
  event: "interview.completed";
  transcript_url?: string;        // Signed URL (24h expiry)
  audio_url?: string;             // Signed URL (24h expiry)
  duration_ms?: number;           // Interview duration
  total_minutes?: number;         // Billable minutes (rounded up)
  free_minutes_applied?: number;  // Free tier minutes used
  billable_minutes?: number;      // Charged minutes
  amount_cents?: number;          // Total charge in cents
}

Example:

{
  "event": "interview.completed",
  "project_id": "550e8400-e29b-41d4-a716-446655440000",
  "script_id": "660e8400-e29b-41d4-a716-446655440001",
  "campaign_id": "770e8400-e29b-41d4-a716-446655440002",
  "campaign_name": "Q1 2024 Customer Interviews",
  "session_id": "880e8400-e29b-41d4-a716-446655440003",
  "invitee_email": "participant@example.com",
  "transcript_url": "https://...supabase.co/transcripts/session-123.json?token=...",
  "audio_url": "https://...supabase.co/audio/session-123.webm?token=...",
  "duration_ms": 1200000,
  "total_minutes": 20,
  "billable_minutes": 15,
  "amount_cents": 900,
  "locale": "en",
  "timestamp": "2024-10-20T10:30:00Z"
}

session.feedback_submitted Event

interface FeedbackSubmittedPayload extends WebhookPayload {
  event: "session.feedback_submitted";
  feedback: {
    id: string;
    overall_rating: number | null;        // 1-5 scale
    ease_of_use_rating: number | null;    // 1-5 scale
    audio_quality_rating: number | null;  // 1-5 scale
    question_clarity_rating: number | null; // 1-5 scale
    comments: string | null;              // Free-text feedback
    technical_issues: string | null;      // Technical problems
    would_recommend: boolean | null;      // Recommendation
    submitted_at: string;                 // ISO 8601 timestamp
  };
}

Retry Policy

If your endpoint returns a non-2xx status or times out, we retry with exponential backoff:

| Attempt | Delay | Total Time | |---------|------------|------------| | 1 | Immediate | 0 min | | 2 | 1 minute | 1 min | | 3 | 2 minutes | 3 min | | 4 | 4 minutes | 7 min | | 5 | 8 minutes | 15 min | | 6 | 16 minutes | 31 min |

After 5 failed attempts, the webhook is marked as failed and retries stop.

Best Practices

  • Respond quickly (< 5 seconds). Queue long-running tasks.
  • Return 2xx for successful processing
  • Return 5xx for temporary failures (triggers retry)
  • Return 4xx for permanent failures (skips retry)

Async Processing

Process webhooks asynchronously to avoid timeouts. Return 200 immediately, then process in background.

Idempotency

Webhooks may be delivered multiple times. Use the session_id to deduplicate:

const processedEvents = new Set();

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

For production, use a database or Redis to track processed events.

Testing

Test from Dashboard

  1. Go to Settings → Integrations
  2. Find your webhook
  3. Click Test
  4. Check your server logs for the test payload

Local Development with ngrok

# Start ngrok tunnel
ngrok http 3000

# Use the HTTPS URL in your webhook configuration
https://abc123.ngrok.io/webhooks

Webhook Testing Tools

Common Use Cases

Send to Slack

app.post('/webhooks', async (req, res) => {
  // Verify signature first...
  
  if (req.body.event === 'interview.completed') {
    await fetch(SLACK_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `Interview completed for ${req.body.invitee_email}`,
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: `*Interview Completed*\n` +
                    `Participant: ${req.body.invitee_email}\n` +
                    `Duration: ${req.body.total_minutes} min\n` +
                    `<${req.body.transcript_url}|View Transcript>`
            }
          }
        ]
      })
    });
  }
  
  res.status(200).send('OK');
});

Store in Database

app.post('/webhooks', async (req, res) => {
  // Verify signature...
  
  if (req.body.event === 'interview.completed') {
    await db.interviews.create({
      session_id: req.body.session_id,
      participant_email: req.body.invitee_email,
      transcript_url: req.body.transcript_url,
      audio_url: req.body.audio_url,
      duration_minutes: req.body.total_minutes,
      completed_at: req.body.timestamp,
    });
  }
  
  res.status(200).send('OK');
});

Trigger Analysis Pipeline

app.post('/webhooks', async (req, res) => {
  // Verify signature...
  
  if (req.body.event === 'interview.completed') {
    // Fetch transcript
    const transcriptResponse = await fetch(req.body.transcript_url);
    const transcript = await transcriptResponse.json();
    
    // Queue for analysis
    await queue.add('analyze-interview', {
      session_id: req.body.session_id,
      transcript: transcript,
    });
  }
  
  res.status(200).send('OK');
});

Troubleshooting

Webhook Not Receiving Events

Check:

  1. Webhook URL is publicly accessible
  2. Firewall allows incoming HTTPS traffic
  3. SSL certificate is valid (not self-signed)
  4. Event types match the events you're testing
  5. Project has active campaigns

Signature Verification Failing

Common causes:

  1. Incorrect secret (did you rotate it?)
  2. Modified request body before verification
  3. Charset/encoding issues
  4. Using re-serialized JSON instead of raw body

Debug:

console.log('Received signature:', signature);
console.log('Expected signature:', expectedSignature);
console.log('Secret (first 10 chars):', secret.substring(0, 10));

Timeouts

Webhooks timeout after 10 seconds. If your processing takes longer:

app.post('/webhooks', async (req, res) => {
  // Verify signature...
  
  // Respond immediately
  res.status(200).send('OK');
  
  // Process asynchronously
  queue.add('process-webhook', req.body);
});

Additional Resources

Support

Having issues?

  1. Check the Troubleshooting section
  2. Test with webhook.site
  3. Email support@interviewrelay.com with:
    • Webhook ID
    • Timestamp of failed delivery
    • Error message (no secrets!)