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 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:

  1. Go to Settings β†’ Webhooks and select your endpoint
  2. Click Rotate Secret and copy the new secret (shown once)
  3. 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:

NameTypeDescription
event*stringEvent type (e.g., interview.completed).
project_id*stringProject the webhook belongs to.
script_id*stringScript used for this interview.
campaign_idstringCampaign this session belongs to.
campaign_namestringHuman-readable campaign name.
session_id*stringUnique session identifier.
invitee_email*stringEmail address of the participant (empty for anonymous share-link participants).
locale*stringInterview locale (e.g., en, nl).
timestamp*stringISO 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"
}
NameTypeDescription
transcript_urlstringSigned URL to the transcript JSON. Valid for 24 hours β€” download promptly or fetch later via the API.
audio_urlstringSigned URL to the audio recording (most recent file). Valid for 24 hours.
duration_msnumberTotal interview duration in milliseconds.
total_minutesnumberTotal billed interview minutes.
free_minutes_appliednumberFree plan minutes applied to this session.
billable_minutesnumberMinutes charged against your credit balance.
amount_creditsnumberCredits deducted for this session.
billing_plan_idstringBilling plan active when the session was billed.
pricing_snapshotobjectPricing 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"
  }
}
NameTypeDescription
feedback.id*stringUnique feedback identifier.
feedback.overall_rating*number | nullOverall rating (1–5).
feedback.ease_of_use_rating*number | nullEase-of-use rating (1–5).
feedback.audio_quality_rating*number | nullAudio quality rating (1–5).
feedback.question_clarity_rating*number | nullQuestion clarity rating (1–5).
feedback.comments*string | nullOptional text comment from the participant.
feedback.technical_issues*string | nullOptional description of technical issues.
feedback.would_recommend*boolean | nullWhether the participant would recommend the experience.
feedback.submitted_at*stringISO 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#

  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. Send a test event β€” Use Send Test Event in Settings β†’ Webhooks and check the result
  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. Use the raw body β€” Compute the HMAC over the raw request bytes, not a parsed/re-serialized version
  3. Check the header name β€” The signature is in X-Webhook-Signature (hex-encoded)
  4. Check encoding β€” Make sure the secret is used as a UTF-8 string key

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; InterviewRelay waits at most 10 seconds for your response

Additional Resources#