Webhooks#
Webhooks let you receive real-time HTTP callbacks when events occur in your InterviewRelay account — such as when an interview is completed or feedback is submitted. Instead of polling the API, your server is notified instantly.
Quick Start#
1. Create a Webhook#
Navigate to Settings → Webhooks in the InterviewRelay Dashboard and click Add Endpoint.
- URL: Your HTTPS endpoint (e.g.,
https://yourapp.com/api/webhooks/interviewrelay) - Events: Select the events you want to listen to (or choose All Events)
- Secret: A signing secret is automatically generated — copy it immediately
Copy Your Secret
The webhook signing secret is only displayed once. Store it in a secure location (e.g., environment variable, secret manager). You'll need it to verify incoming webhook payloads.
2. Build a Minimal Handler#
Node.js (Express)#
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.INTERVIEWRELAY_WEBHOOK_SECRET;
app.post('/api/webhooks/interviewrelay', (req, res) => {
// 1. Verify signature
const signature = req.headers['x-interviewrelay-signature'];
const timestamp = req.headers['x-interviewrelay-timestamp'];
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${payload}`)
.digest('hex');
if (signature !== expected) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Check for replay attacks (optional but recommended)
const age = Date.now() - parseInt(timestamp, 10);
if (age > 5 * 60 * 1000) {
return res.status(401).json({ error: 'Timestamp too old' });
}
// 3. Process the event
const event = req.body;
console.log(`Received event: ${event.type}`, event.data);
switch (event.type) {
case 'interview.completed':
// Handle completed interview
break;
case 'session.feedback_submitted':
// Handle feedback submission
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// 4. Return 200 quickly
res.status(200).json({ received: true });
});
app.listen(3000, () => console.log('Webhook server running on port 3000'));
Python (Flask)#
import hmac
import hashlib
import time
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get('INTERVIEWRELAY_WEBHOOK_SECRET')
@app.route('/api/webhooks/interviewrelay', methods=['POST'])
def handle_webhook():
# 1. Verify signature
signature = request.headers.get('X-InterviewRelay-Signature')
timestamp = request.headers.get('X-InterviewRelay-Timestamp')
payload = request.get_data(as_text=True)
expected = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
f"{timestamp}.{payload}".encode('utf-8'),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({'error': 'Invalid signature'}), 401
# 2. Check for replay attacks
age = time.time() * 1000 - int(timestamp)
if age > 5 * 60 * 1000:
return jsonify({'error': 'Timestamp too old'}), 401
# 3. Process the event
event = request.get_json()
print(f"Received event: {event['type']}")
if event['type'] == 'interview.completed':
# Handle completed interview
pass
elif event['type'] == 'session.feedback_submitted':
# Handle feedback submission
pass
# 4. Return 200 quickly
return jsonify({'received': True}), 200
if __name__ == '__main__':
app.run(port=3000)
Security Requirements#
HTTPS Only#
All webhook endpoints must use HTTPS. InterviewRelay will not deliver events to insecure HTTP URLs. During local development, use a tunneling tool like ngrok to expose your local server over HTTPS.
Signature Verification#
Every webhook request includes two headers used for verification:
| Header | Description |
|---|---|
| X-InterviewRelay-Signature | HMAC-SHA256 hex digest of the payload |
| X-InterviewRelay-Timestamp | Unix timestamp (milliseconds) when the event was sent |
The signature is computed as:
HMAC-SHA256(secret, "${timestamp}.${JSON.stringify(body)}")
Always Verify Signatures
Never process a webhook payload without verifying its signature. Skipping verification makes your endpoint vulnerable to spoofed requests.
Storing Secrets#
- Store your webhook secret in an environment variable or a secret manager (e.g., AWS Secrets Manager, HashiCorp Vault)
- Never hard-code secrets in source code
- Never commit secrets to version control
Rotating Secrets#
To rotate your webhook secret:
- Go to Settings → Webhooks and select your endpoint
- Click Rotate Secret
- Update your server with the new secret
- During rotation, InterviewRelay will temporarily send duplicate requests signed with both old and new secrets
Event Types#
Subscribe to specific events or use the wildcard * to receive all events.
| Event | Description |
|---|---|
| interview.completed | Fired when an interview session finishes (participant completes all questions or the session times out) |
| session.feedback_submitted | Fired when a participant submits post-interview feedback |
| * | Wildcard — subscribes to all current and future event types |
New Events
We regularly add new event types. If you subscribe to *, your handler should gracefully ignore unknown event types.
Payload Structure#
Common Fields#
Every webhook payload contains these top-level fields:
| Name | Type | Description |
|---|---|---|
id* | string | Unique event ID (UUID). Use for idempotency checks. |
type* | string | Event type (e.g., interview.completed). |
created_at* | string | ISO 8601 timestamp of when the event was created. |
data* | object | Event-specific payload data. |
interview.completed Payload#
{
"id": "evt_abc123def456",
"type": "interview.completed",
"created_at": "2024-12-01T14:30:00.000Z",
"data": {
"session_id": "sess_789xyz",
"campaign_id": "camp_456abc",
"script_id": "scr_012def",
"participant": {
"email": "participant@example.com",
"name": "Jane Doe",
"external_id": "user_42"
},
"duration_seconds": 342,
"questions_answered": 8,
"questions_total": 10,
"completion_status": "completed",
"transcript_url": "https://api.interviewrelay.com/v1/sessions/sess_789xyz/transcript",
"recording_url": "https://api.interviewrelay.com/v1/sessions/sess_789xyz/recording"
}
}
| Name | Type | Description |
|---|---|---|
data.session_id* | string | Unique session identifier. |
data.campaign_id* | string | Campaign this session belongs to. |
data.script_id* | string | Script used for this interview. |
data.participant* | object | Participant details (email, name, external_id). |
data.duration_seconds* | number | Total interview duration in seconds. |
data.questions_answered* | number | Number of questions the participant answered. |
data.questions_total* | number | Total questions in the script. |
data.completion_status* | string | One of: completed, timed_out, abandoned. |
data.transcript_url* | string | URL to fetch the session transcript. |
data.recording_url | string | URL to fetch the audio recording (if enabled). |
session.feedback_submitted Payload#
{
"id": "evt_feedback_789",
"type": "session.feedback_submitted",
"created_at": "2024-12-01T14:35:00.000Z",
"data": {
"session_id": "sess_789xyz",
"campaign_id": "camp_456abc",
"participant": {
"email": "participant@example.com",
"name": "Jane Doe"
},
"rating": 4,
"comment": "The AI interviewer was very natural and easy to talk to.",
"submitted_at": "2024-12-01T14:34:55.000Z"
}
}
| Name | Type | Description |
|---|---|---|
data.session_id* | string | Session this feedback belongs to. |
data.campaign_id* | string | Campaign this session belongs to. |
data.participant* | object | Participant who submitted the feedback. |
data.rating* | number | Numeric rating (1–5). |
data.comment | string | Optional text comment from the participant. |
data.submitted_at* | string | ISO 8601 timestamp of when feedback was submitted. |
Retry Policy#
If your endpoint returns a non-2xx status code or times out, InterviewRelay retries with exponential backoff:
| Attempt | Delay | Total Time Elapsed | |---|---|---| | 1 | Immediate | 0 minutes | | 2 | 1 minute | 1 minute | | 3 | 5 minutes | 6 minutes | | 4 | 30 minutes | 36 minutes | | 5 | 2 hours | ~2.5 hours | | 6 | 8 hours | ~10.5 hours | | 7 (final) | 24 hours | ~34.5 hours |
After 7 failed attempts, the event is marked as failed and will not be retried. You can manually redeliver failed events from the dashboard.
Respond Quickly
Your endpoint must return a 2xx status code within 30 seconds. If you need to perform long-running processing, accept the webhook immediately and process it asynchronously (e.g., via a job queue).
Idempotency#
Webhooks may be delivered more than once. Always use the id field in the payload to deduplicate events.
const processedEvents = new Set(); // Use a database in production
app.post('/api/webhooks/interviewrelay', (req, res) => {
const eventId = req.body.id;
if (processedEvents.has(eventId)) {
// Already processed — skip but return 200
return res.status(200).json({ received: true, duplicate: true });
}
processedEvents.add(eventId);
// Process the event...
res.status(200).json({ received: true });
});
Production Tip
In production, store processed event IDs in your database (e.g., a webhook_events table) with a unique constraint on the event ID. This ensures idempotency across server restarts and multiple instances.
Testing#
Dashboard Test Events#
From the webhook settings page, click Send Test Event to send a sample payload to your endpoint. This is the easiest way to verify your handler works correctly.
Local Development with ngrok#
Use ngrok to expose your local server:
# Start your local server
node server.js
# In another terminal, expose it via ngrok
ngrok http 3000
Copy the HTTPS forwarding URL (e.g., https://abc123.ngrok.io) and use it as your webhook URL in the dashboard.
Testing Tools#
- webhook.site — Inspect incoming payloads without writing code
- RequestBin — Another payload inspection tool
- curl — Manually send test payloads:
curl -X POST https://yourapp.com/api/webhooks/interviewrelay \
-H "Content-Type: application/json" \
-H "X-InterviewRelay-Signature: test_signature" \
-H "X-InterviewRelay-Timestamp: $(date +%s000)" \
-d '{"id":"evt_test_123","type":"interview.completed","created_at":"2024-12-01T00:00:00Z","data":{}}'
Common Use Cases#
Send a Slack Notification#
const { WebClient } = require('@slack/web-api');
const slack = new WebClient(process.env.SLACK_TOKEN);
async function handleInterviewCompleted(data) {
await slack.chat.postMessage({
channel: '#interviews',
text: `🎙️ Interview completed!\n*Participant:* ${data.participant.name}\n*Duration:* ${Math.round(data.duration_seconds / 60)} min\n*Questions:* ${data.questions_answered}/${data.questions_total}`,
});
}
Save to Database#
const { Pool } = require('pg');
const pool = new Pool();
async function handleInterviewCompleted(data) {
await pool.query(
`INSERT INTO completed_interviews
(session_id, campaign_id, participant_email, duration, completion_status, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (session_id) DO NOTHING`,
[
data.session_id,
data.campaign_id,
data.participant.email,
data.duration_seconds,
data.completion_status,
]
);
}
Trigger an Analysis Pipeline#
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
const sqs = new SQSClient({ region: 'us-east-1' });
async function handleInterviewCompleted(data) {
await sqs.send(
new SendMessageCommand({
QueueUrl: process.env.ANALYSIS_QUEUE_URL,
MessageBody: JSON.stringify({
session_id: data.session_id,
transcript_url: data.transcript_url,
recording_url: data.recording_url,
}),
})
);
}
Troubleshooting#
Not Receiving Webhooks#
- Check your endpoint URL — Make sure it's correct and uses HTTPS
- Check firewall rules — Ensure your server accepts incoming POST requests from external IPs
- Check the dashboard — View delivery logs under Settings → Webhooks → Delivery History
- Check event subscriptions — Verify you're subscribed to the correct event types
Signature Verification Failing#
- Check the secret — Make sure you're using the correct webhook secret (not your API key)
- Check the payload — Ensure you're using the raw request body, not a parsed/re-serialized version
- Check the timestamp — Verify you're concatenating the timestamp and payload correctly:
${timestamp}.${payload} - Check encoding — Make sure both the secret and payload are UTF-8 encoded
Timeouts#
- Return 200 immediately — Don't wait for long-running processing
- Use a queue — Offload heavy processing to a background job queue
- Increase timeout — If your cloud provider has a short function timeout (e.g., AWS Lambda default is 3 seconds), increase it to at least 30 seconds
Additional Resources#
- API Reference — Full API documentation
- Authentication — API key management and security
- SDK Integration — Embed interviews in your website
- Webhook Handler Example — Complete server-side webhook handler example