Complete reference for all webhook events sent by ROUND0.
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 objectTriggered when a candidate begins an interview.
invited to in_progress{
"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"
}
}
}
data.interview:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Interview identifier |
token | string | Unique interview token |
status | string | Always "in_progress" for this event |
estimated_duration_seconds | integer | Total estimated time for all questions |
estimated_credits | integer | Credits consumed |
started_at | string (ISO 8601) | When candidate started (now) |
expires_at | string (ISO 8601) | Original expiration date |
created_at | string (ISO 8601) | When interview was created |
data.job:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Job identifier |
title | string | Job title |
department | string | null | Department name |
location | string | null | Job location |
data.candidate:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Candidate identifier |
name | string | Candidate's full name |
email | string | null | Candidate's email |
title | string | null | Title (Mr/Mrs/Ms/Dr) |
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}`);
}
Triggered when a candidate completes an interview.
in_progress to completed{
"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"
}
]
}
}
data.interview (same as interview_started plus):
| Field | Type | Description |
|---|---|---|
completed_at | string (ISO 8601) | When interview finished |
data.responses[]:
| Field | Type | Description |
|---|---|---|
question_id | string (UUID) | Question identifier |
question_text | string | The question that was asked |
question_type | string | behavioral | technical | situational | experience | role_specific |
transcript | string | Full text transcription of response |
audio_url | string | Temporary URL to audio recording (expires in 24h) |
score | integer | null | AI-generated score (0-100) for the response |
feedback | string | null | AI-generated feedback on the response |
duration_seconds | integer | Length of candidate's response |
answered_at | string (ISO 8601) | When response was given |
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');
}
Use the Dashboard to send test events:
interview_started or interview_endedTest events use fake data but follow the exact same structure as real events.
{
"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"
}
]
}
}
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.
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);
}
}
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');
});