InterviewRelay

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:

  1. Go to Settings → Webhooks and select your endpoint
  2. Click Rotate Secret
  3. Update your server with the new secret
  4. 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:

NameTypeDescription
id*stringUnique event ID (UUID). Use for idempotency checks.
type*stringEvent type (e.g., interview.completed).
created_at*stringISO 8601 timestamp of when the event was created.
data*objectEvent-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"
  }
}
NameTypeDescription
data.session_id*stringUnique session identifier.
data.campaign_id*stringCampaign this session belongs to.
data.script_id*stringScript used for this interview.
data.participant*objectParticipant details (email, name, external_id).
data.duration_seconds*numberTotal interview duration in seconds.
data.questions_answered*numberNumber of questions the participant answered.
data.questions_total*numberTotal questions in the script.
data.completion_status*stringOne of: completed, timed_out, abandoned.
data.transcript_url*stringURL to fetch the session transcript.
data.recording_urlstringURL 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"
  }
}
NameTypeDescription
data.session_id*stringSession this feedback belongs to.
data.campaign_id*stringCampaign this session belongs to.
data.participant*objectParticipant who submitted the feedback.
data.rating*numberNumeric rating (1–5).
data.commentstringOptional text comment from the participant.
data.submitted_at*stringISO 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#

  1. Check your endpoint URL — Make sure it's correct and uses HTTPS
  2. Check firewall rules — Ensure your server accepts incoming POST requests from external IPs
  3. Check the dashboard — View delivery logs under Settings → Webhooks → Delivery History
  4. Check event subscriptions — Verify you're subscribed to the correct event types

Signature Verification Failing#

  1. Check the secret — Make sure you're using the correct webhook secret (not your API key)
  2. Check the payload — Ensure you're using the raw request body, not a parsed/re-serialized version
  3. Check the timestamp — Verify you're concatenating the timestamp and payload correctly: ${timestamp}.${payload}
  4. Check encoding — Make sure both the secret and payload are UTF-8 encoded

Timeouts#

  1. Return 200 immediately — Don't wait for long-running processing
  2. Use a queue — Offload heavy processing to a background job queue
  3. 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#