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 automatically.
Quick Start#
1. Create a Webhook#
Navigate to Settings β Webhooks in the InterviewRelay Dashboard and click Add Endpoint, or create one via the API (POST /api/webhooks).
- 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();
// Capture the raw request body β the signature is computed over the exact
// bytes InterviewRelay sends, so never verify against a re-serialized copy.
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
const WEBHOOK_SECRET = process.env.INTERVIEWRELAY_WEBHOOK_SECRET;
app.post('/api/webhooks/interviewrelay', (req, res) => {
// 1. Verify the signature
const signature = req.headers['x-webhook-signature'];
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.rawBody)
.digest('hex');
const valid = signature &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Process the event
const event = req.body;
console.log(`Received event: ${event.event}`, event.session_id);
switch (event.event) {
case 'interview.completed':
// Handle completed interview
break;
case 'session.feedback_submitted':
// Handle feedback submission
break;
case 'test.webhook':
// Sent by the "Send Test Event" button in the dashboard
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
// 3. 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 os
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 the signature over the raw request body
signature = request.headers.get('X-Webhook-Signature', '')
payload = request.get_data() # raw bytes, not re-serialized JSON
expected = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({'error': 'Invalid signature'}), 401
# 2. Process the event
event = request.get_json()
print(f"Received event: {event['event']}")
if event['event'] == 'interview.completed':
# Handle completed interview
pass
elif event['event'] == 'session.feedback_submitted':
# Handle feedback submission
pass
# 3. 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 accept 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 these headers:
| Header | Description |
|---|---|
| X-Webhook-Signature | HMAC-SHA256 hex digest of the raw request body, keyed with your endpoint's secret |
| X-Webhook-Event | The event type (e.g., interview.completed) β convenient for routing, but always trust the verified body |
| User-Agent | Always AI-Interview-Webhook/1.0 |
The signature is computed as:
HMAC-SHA256(secret, rawRequestBody) β hex digest
There is no separate timestamp header β the signed payload itself contains a timestamp field (ISO 8601). After verifying the signature, you can reject events whose timestamp is older than you're comfortable with (e.g., 30 minutes; note that legitimate retries can arrive up to ~30 minutes after the original event).
Always Verify Signatures
Never process a webhook payload without verifying its signature, and always compute the HMAC over the raw request body β a parsed-and-re-serialized body may differ byte-for-byte and fail verification.
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 and copy the new secret (shown once)
- Update your server with the new secret immediately
The new secret takes effect right away β there is no dual-signing window. Deliveries that fail during the switchover are retried automatically, and retries are signed with the current secret, so they will succeed once your server is updated.
Event Types#
Subscribe to specific events or use the wildcard * to receive all events.
| Event | Description |
|---|---|
| interview.completed | Fired when an interview session finishes |
| session.feedback_submitted | Fired when a participant submits post-interview feedback |
| test.webhook | Sent when you click Send Test Event in the dashboard (anonymized sample data) |
| * | 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#
Payloads are flat JSON objects (no envelope). Every payload contains at least these fields:
| Name | Type | Description |
|---|---|---|
event* | string | Event type (e.g., interview.completed). |
project_id* | string | Project the webhook belongs to. |
script_id* | string | Script used for this interview. |
campaign_id | string | Campaign this session belongs to. |
campaign_name | string | Human-readable campaign name. |
session_id* | string | Unique session identifier. |
invitee_email* | string | Email address of the participant (empty for anonymous share-link participants). |
locale* | string | Interview locale (e.g., en, nl). |
timestamp* | string | ISO 8601 timestamp of when the event was created. |
interview.completed Payload#
{
"event": "interview.completed",
"project_id": "af7010af-175d-4604-af3f-d857e560fa41",
"script_id": "6dc32cd2-4b99-4004-930e-0b1ea4d0260c",
"campaign_id": "104f56b8-cc11-45a1-b5dc-26bdf082d54a",
"campaign_name": "Churn interviews Q3",
"session_id": "0d9c7f2e-31a9-4c5b-8a44-2f7f2f0b8c11",
"invitee_email": "participant@example.com",
"transcript_url": "https://β¦/artifacts/β¦?token=β¦",
"audio_url": "https://β¦/artifacts/β¦?token=β¦",
"duration_ms": 342000,
"total_minutes": 6,
"free_minutes_applied": 0,
"billable_minutes": 6,
"amount_credits": 60,
"billing_plan_id": "1052cbd9-2439-47cc-8835-c7db4a936865",
"pricing_snapshot": { "price_per_minute_credits": 10 },
"locale": "en",
"timestamp": "2026-07-02T14:30:00.000Z"
}
| Name | Type | Description |
|---|---|---|
transcript_url | string | Signed URL to the transcript JSON. Valid for 24 hours β download promptly or fetch later via the API. |
audio_url | string | Signed URL to the audio recording (most recent file). Valid for 24 hours. |
duration_ms | number | Total interview duration in milliseconds. |
total_minutes | number | Total billed interview minutes. |
free_minutes_applied | number | Free plan minutes applied to this session. |
billable_minutes | number | Minutes charged against your credit balance. |
amount_credits | number | Credits deducted for this session. |
billing_plan_id | string | Billing plan active when the session was billed. |
pricing_snapshot | object | Pricing details captured at billing time. |
session.feedback_submitted Payload#
{
"event": "session.feedback_submitted",
"project_id": "af7010af-175d-4604-af3f-d857e560fa41",
"script_id": "6dc32cd2-4b99-4004-930e-0b1ea4d0260c",
"campaign_id": "104f56b8-cc11-45a1-b5dc-26bdf082d54a",
"campaign_name": "Churn interviews Q3",
"session_id": "0d9c7f2e-31a9-4c5b-8a44-2f7f2f0b8c11",
"invitee_email": "participant@example.com",
"locale": "en",
"timestamp": "2026-07-02T14:34:55.000Z",
"feedback": {
"id": "f0a1b2c3-d4e5-6789-abcd-ef0123456789",
"overall_rating": 4,
"ease_of_use_rating": 5,
"audio_quality_rating": 4,
"question_clarity_rating": 5,
"comments": "The AI interviewer was very natural and easy to talk to.",
"technical_issues": null,
"would_recommend": true,
"submitted_at": "2026-07-02T14:34:55.000Z"
}
}
| Name | Type | Description |
|---|---|---|
feedback.id* | string | Unique feedback identifier. |
feedback.overall_rating* | number | null | Overall rating (1β5). |
feedback.ease_of_use_rating* | number | null | Ease-of-use rating (1β5). |
feedback.audio_quality_rating* | number | null | Audio quality rating (1β5). |
feedback.question_clarity_rating* | number | null | Question clarity rating (1β5). |
feedback.comments* | string | null | Optional text comment from the participant. |
feedback.technical_issues* | string | null | Optional description of technical issues. |
feedback.would_recommend* | boolean | null | Whether the participant would recommend the experience. |
feedback.submitted_at* | string | ISO 8601 timestamp of when feedback was submitted. |
Retry Policy#
Events are queued and delivered by a background worker. If your endpoint returns a non-2xx status code or times out, delivery is retried with exponential backoff:
| Attempt | Delay after previous failure | Total time elapsed (approx.) | |---|---|---| | 1 | β (first delivery) | 0 minutes | | 2 | 2 minutes | 2 minutes | | 3 | 4 minutes | 6 minutes | | 4 | 8 minutes | 14 minutes | | 5 (final) | 16 minutes | 30 minutes |
After 5 failed attempts, the event is marked as failed and will not be retried.
Respond Quickly
Your endpoint must return a 2xx status code within 10 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 (e.g., when a retry crosses a slow response). Payloads do not carry a separate event ID β deduplicate on the combination of event + session_id (for feedback events you can also use feedback.id).
const processedEvents = new Set(); // Use a database in production
app.post('/api/webhooks/interviewrelay', (req, res) => {
const dedupeKey = `${req.body.event}:${req.body.session_id}`;
if (processedEvents.has(dedupeKey)) {
// Already processed β skip but return 200
return res.status(200).json({ received: true, duplicate: true });
}
processedEvents.add(dedupeKey);
// Process the event...
res.status(200).json({ received: true });
});
Production Tip
In production, store processed keys in your database (e.g., a webhook_events table) with a unique constraint. 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 test.webhook payload (anonymized data, signed with your real secret) to your endpoint. This is the easiest way to verify your handler and signature verification work 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
- curl β Manually send test payloads to your own handler:
BODY='{"event":"interview.completed","project_id":"p1","script_id":"s1","session_id":"sess1","invitee_email":"test@example.com","locale":"en","timestamp":"2026-07-02T00:00:00.000Z"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$INTERVIEWRELAY_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -X POST https://yourapp.com/api/webhooks/interviewrelay \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIG" \
-H "X-Webhook-Event: interview.completed" \
-d "$BODY"
Common Use Cases#
Send a Slack Notification#
const { WebClient } = require('@slack/web-api');
const slack = new WebClient(process.env.SLACK_TOKEN);
async function handleInterviewCompleted(event) {
await slack.chat.postMessage({
channel: '#interviews',
text: `ποΈ Interview completed!\n*Participant:* ${event.invitee_email}\n*Campaign:* ${event.campaign_name}\n*Duration:* ${Math.round((event.duration_ms || 0) / 60000)} min`,
});
}
Save to Database#
const { Pool } = require('pg');
const pool = new Pool();
async function handleInterviewCompleted(event) {
await pool.query(
`INSERT INTO completed_interviews
(session_id, campaign_id, participant_email, duration_ms, credits, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (session_id) DO NOTHING`,
[
event.session_id,
event.campaign_id,
event.invitee_email,
event.duration_ms,
event.amount_credits,
]
);
}
Trigger an Analysis Pipeline#
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
const sqs = new SQSClient({ region: 'us-east-1' });
async function handleInterviewCompleted(event) {
await sqs.send(
new SendMessageCommand({
QueueUrl: process.env.ANALYSIS_QUEUE_URL,
MessageBody: JSON.stringify({
session_id: event.session_id,
transcript_url: event.transcript_url, // signed URL, valid 24h β download promptly
audio_url: event.audio_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
- Send a test event β Use Send Test Event in Settings β Webhooks and check the result
- 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)
- Use the raw body β Compute the HMAC over the raw request bytes, not a parsed/re-serialized version
- Check the header name β The signature is in
X-Webhook-Signature(hex-encoded) - Check encoding β Make sure the secret is used as a UTF-8 string key
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; InterviewRelay waits at most 10 seconds for your response
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