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
- Navigate to Settings → Integrations
- Click Create Webhook
- Select a project
- Enter your HTTPS endpoint URL
- Select event types to subscribe to
- 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:
- Go to Settings → Integrations
- Click Rotate Secret next to your webhook
- Update your environment variable
- Test to confirm it works
Event Types
Subscribe to specific events when creating a webhook:
| Name | Type | Description |
|---|---|---|
interview.completed | event | Interview session successfully finished |
session.feedback_submitted | event | Participant submitted post-interview feedback |
* | wildcard | All 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
- Go to Settings → Integrations
- Find your webhook
- Click Test
- 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
- webhook.site - Inspect incoming webhooks
- RequestBin - Capture and inspect requests
- ngrok - Tunnel to localhost
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:
- Webhook URL is publicly accessible
- Firewall allows incoming HTTPS traffic
- SSL certificate is valid (not self-signed)
- Event types match the events you're testing
- Project has active campaigns
Signature Verification Failing
Common causes:
- Incorrect secret (did you rotate it?)
- Modified request body before verification
- Charset/encoding issues
- 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
- Example Webhook Handlers - Complete working examples
- API Authentication - API security best practices
- HMAC Authentication - Understanding HMAC
Support
Having issues?
- Check the Troubleshooting section
- Test with webhook.site
- Email support@interviewrelay.com with:
- Webhook ID
- Timestamp of failed delivery
- Error message (no secrets!)