round0
Webhooks

Webhook Events

Complete reference of all webhook events and their payloads

Webhook Events

Complete reference for all webhook events sent by ROUND0.

Event Structure

All webhook events share a common structure:

{
  "id": "evt_550e8400e29b41d4a716446655440000",
  "type": "interview_started | interview_ended",
  "created": 1640000000,
  "data": {
    "interview": { /* ... */ },
    "job": { /* ... */ },
    "candidate": { /* ... */ }
  }
}

Common fields:

  • id: Unique event identifier (string, format: evt_ + UUID without hyphens)
  • type: Event type (see below)
  • created: Unix timestamp in seconds (integer)
  • data: Event-specific data object

interview_started

Triggered when a candidate begins an interview.

When It's Sent

  • Candidate clicks the interview link for the first time
  • Interview status changes from invited to in_progress
  • Credits are consumed at this point

Payload

{
  "id": "evt_550e8400e29b41d4a716446655440000",
  "type": "interview_started",
  "created": 1640000000,
  "data": {
    "interview": {
      "id": "660f9511-f30c-52e5-b827-557766551111",
      "token": "abc123def456...",
      "status": "in_progress",
      "estimated_duration_seconds": 900,
      "estimated_credits": 1,
      "started_at": "2024-01-16T14:30:00Z",
      "expires_at": "2024-01-22T23:59:59Z",
      "created_at": "2024-01-15T10:30:00Z"
    },
    "job": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "title": "Senior Frontend Developer",
      "department": "Engineering",
      "location": "Remote"
    },
    "candidate": {
      "id": "770g0622-g41d-63f6-c938-668877662222",
      "name": "Jane Smith",
      "email": "jane@example.com",
      "title": "Ms"
    }
  }
}

Fields

data.interview:

FieldTypeDescription
idstring (UUID)Interview identifier
tokenstringUnique interview token
statusstringAlways "in_progress" for this event
estimated_duration_secondsintegerTotal estimated time for all questions
estimated_creditsintegerCredits consumed
started_atstring (ISO 8601)When candidate started (now)
expires_atstring (ISO 8601)Original expiration date
created_atstring (ISO 8601)When interview was created

data.job:

FieldTypeDescription
idstring (UUID)Job identifier
titlestringJob title
departmentstring | nullDepartment name
locationstring | nullJob location

data.candidate:

FieldTypeDescription
idstring (UUID)Candidate identifier
namestringCandidate's full name
emailstring | nullCandidate's email
titlestring | nullTitle (Mr/Mrs/Ms/Dr)

Use Cases

  • Send notification to hiring manager
  • Update ATS status to "Screening in Progress"
  • Start a timer for follow-up if interview isn't completed
  • Log analytics event

Example Handler

async function handleInterviewStarted(data) {
  const { interview, job, candidate } = data;

  // Notify hiring manager
  await sendEmail({
    to: 'hiring-manager@company.com',
    subject: `${candidate.name} started screening for ${job.title}`,
    body: `
      Candidate: ${candidate.name}
      Job: ${job.title}
      Started: ${interview.started_at}
      Estimated duration: ${Math.round(interview.estimated_duration_seconds / 60)} minutes
    `
  });

  // Update ATS
  await ats.updateCandidate({
    email: candidate.email,
    status: 'screening_in_progress',
    screening_started_at: interview.started_at
  });

  // Log event
  console.log(`Interview ${interview.id} started by ${candidate.name}`);
}

interview_ended

Triggered when a candidate completes an interview.

When It's Sent

  • Candidate answers the final question
  • Interview status changes from in_progress to completed
  • All transcripts and results are ready

Payload

{
  "id": "evt_660f9511f30c52e5b827557766551111",
  "type": "interview_ended",
  "created": 1640001520,
  "data": {
    "interview": {
      "id": "660f9511-f30c-52e5-b827-557766551111",
      "token": "abc123def456...",
      "status": "completed",
      "estimated_duration_seconds": 900,
      "estimated_credits": 1,
      "started_at": "2024-01-16T14:30:00Z",
      "completed_at": "2024-01-16T14:52:00Z",
      "expires_at": "2024-01-22T23:59:59Z",
      "created_at": "2024-01-15T10:30:00Z"
    },
    "job": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "title": "Senior Frontend Developer",
      "department": "Engineering",
      "location": "Remote"
    },
    "candidate": {
      "id": "770g0622-g41d-63f6-c938-668877662222",
      "name": "Jane Smith",
      "email": "jane@example.com",
      "title": "Ms"
    },
    "responses": [
      {
        "question_id": "880h1733-h52e-74g7-d049-779988773333",
        "question_text": "Describe your experience with React hooks.",
        "question_type": "technical",
        "transcript": "I've been using React hooks extensively for the past three years...",
        "audio_url": "https://storage.round0.io/audio/abc123.mp3",
        "score": 85,
        "feedback": "Strong understanding of React hooks with practical examples. Demonstrated real-world experience with useState, useEffect, useReducer, and custom hooks.",
        "duration_seconds": 145,
        "answered_at": "2024-01-16T14:33:25Z"
      },
      {
        "question_id": "990i2844-i63f-85h8-e150-880099884444",
        "question_text": "Tell me about a challenging bug you fixed recently.",
        "question_type": "experience",
        "transcript": "Last month, we had an intermittent race condition...",
        "audio_url": "https://storage.round0.io/audio/def456.mp3",
        "score": 92,
        "feedback": "Excellent problem-solving approach. Clearly explained the debugging process and showed systematic thinking.",
        "duration_seconds": 167,
        "answered_at": "2024-01-16T14:38:12Z"
      }
    ]
  }
}

Fields

data.interview (same as interview_started plus):

FieldTypeDescription
completed_atstring (ISO 8601)When interview finished

data.responses[]:

FieldTypeDescription
question_idstring (UUID)Question identifier
question_textstringThe question that was asked
question_typestringbehavioral | technical | situational | experience | role_specific
transcriptstringFull text transcription of response
audio_urlstringTemporary URL to audio recording (expires in 24h)
scoreinteger | nullAI-generated score (0-100) for the response
feedbackstring | nullAI-generated feedback on the response
duration_secondsintegerLength of candidate's response
answered_atstring (ISO 8601)When response was given
Audio URLs expire after 24 hours. If you need long-term access to audio recordings, download them immediately upon receiving the webhook.

Use Cases

  • Fetch full results via API (including audio)
  • Sync transcripts to ATS
  • Notify hiring team to review candidate
  • Trigger automated analysis or scoring
  • Schedule follow-up interviews

Example Handler

async function handleInterviewEnded(data) {
  const { interview, job, candidate, responses } = data;

  // Fetch full results with audio URLs
  const fullResults = await fetch(
    `https://rz-app-omega.vercel.app/api/v1/interviews/${interview.id}`,
    {
      headers: { 'Authorization': `Bearer ${process.env.ROUNDZERO_API_KEY}` }
    }
  ).then(r => r.json());

  // Create summary
  const summary = {
    candidate: candidate.name,
    job: job.title,
    completed_at: interview.completed_at,
    total_duration: responses.reduce((sum, r) => sum + r.duration_seconds, 0),
    response_count: responses.length
  };

  // Sync to ATS
  await ats.updateCandidate({
    email: candidate.email,
    status: 'screening_completed',
    screening_completed_at: interview.completed_at,
    screening_notes: formatTranscripts(responses),
    screening_summary: JSON.stringify(summary)
  });

  // Download and store audio files
  for (const response of fullResults.responses) {
    const audio = await fetch(response.audio_url).then(r => r.arrayBuffer());
    await storage.save(
      `interviews/${interview.id}/question_${response.question_id}.mp3`,
      audio
    );
  }

  // Notify hiring team
  await slack.send({
    channel: '#hiring',
    text: `${candidate.name} completed screening for ${job.title}`,
    link: `https://rz-app-omega.vercel.app/interviews/${interview.id}`
  });

  console.log(`Interview ${interview.id} completed`);
}

function formatTranscripts(responses) {
  return responses.map((r, i) =>
    `Question ${i + 1}: ${r.question_text}\n\nAnswer: ${r.transcript}\n\n---\n`
  ).join('\n');
}

Testing Events

Send Test Event

Use the Dashboard to send test events:

  1. Navigate to SettingsWebhooks
  2. Click Send Test Event
  3. Select event type: interview_started or interview_ended
  4. Click Send

Test events use fake data but follow the exact same structure as real events.

Test Payload Example

{
  "id": "evt_test_123456789",
  "type": "interview_ended",
  "created": 1640000000,
  "data": {
    "interview": {
      "id": "test-interview-id",
      "token": "test-token",
      "status": "completed",
      "estimated_duration_seconds": 900,
      "estimated_credits": 1,
      "started_at": "2024-01-16T14:30:00Z",
      "completed_at": "2024-01-16T14:45:00Z",
      "expires_at": "2024-01-22T23:59:59Z",
      "created_at": "2024-01-15T10:30:00Z"
    },
    "job": {
      "id": "test-job-id",
      "title": "Test Job Title",
      "department": "Engineering",
      "location": "Remote"
    },
    "candidate": {
      "id": "test-candidate-id",
      "name": "Test Candidate",
      "email": "test@example.com",
      "title": null
    },
    "responses": [
      {
        "question_id": "test-question-1",
        "question_text": "Test question?",
        "question_type": "technical",
        "transcript": "This is a test response.",
        "duration_seconds": 120,
        "answered_at": "2024-01-16T14:33:00Z"
      }
    ]
  }
}

Event Ordering

No Guaranteed Order

While events typically arrive in order (interview_started before interview_ended), you should not rely on ordering.

Best practice: Use event IDs and timestamps to determine sequence.

Handling Out-of-Order Events

async function handleEvent(event) {
  // Store event with timestamp
  await db.events.create({
    id: event.id,
    type: event.type,
    created_at: new Date(event.created * 1000),
    data: event.data
  });

  // Process based on type, not assumption of order
  if (event.type === 'interview_ended') {
    // Fetch current state from API to ensure we have latest data
    const interview = await fetchInterview(event.data.interview.id);
    await processCompletedInterview(interview);
  }
}

Filtering Events

Currently, all configured events are sent to your webhook URL. Event filtering is not supported.

To handle specific events:

app.post('/webhooks/round0', async (req, res) => {
  const event = req.body;

  // Only process interview_ended events
  if (event.type !== 'interview_ended') {
    return res.status(200).send('OK'); // Still return 200
  }

  await handleInterviewEnded(event.data);
  res.status(200).send('OK');
});

Next Steps