Webhooks allow you to receive real-time HTTP notifications when interviews start and complete, enabling event-driven workflows and integrations.
To use webhooks, you need:
If delivery fails, ROUND0 automatically retries with exponential backoff.
ROUND0 sends webhooks for two events:
Sent when a candidate begins an interview (status changes from "invited" to "in_progress").
Use cases:
Sent when a candidate completes an interview (status changes to "completed").
Use cases:
First, create an HTTPS endpoint that can receive POST requests:
// Example: Express.js endpoint
app.post('/webhooks/round0', express.json(), async (req, res) => {
// Verify webhook signature (see Security section)
const isValid = verifySignature(req);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const event = req.body;
// Handle event
if (event.type === 'interview_started') {
await handleInterviewStarted(event.data);
} else if (event.type === 'interview_ended') {
await handleInterviewEnded(event.data);
}
// Respond quickly (within 10 seconds)
res.status(200).send('OK');
// Process event asynchronously if needed
// Don't do heavy processing before responding
});
Test your webhook:
All webhook events follow this structure:
{
"id": "evt_550e8400e29b41d4a716446655440000",
"type": "interview_started",
"created": 1640000000,
"data": {
"interview": { /* interview object */ },
"job": { /* job object */ },
"candidate": { /* candidate object */ }
}
}
Top-level fields:
id: Unique event ID (for idempotency)type: Event type (interview_started or interview_ended)created: Unix timestamp (seconds)data: Event-specific dataWebhooks are secured using HMAC-SHA256 signatures, similar to Stripe's webhook security.
Every webhook request includes a signature in the X-Webhook-Signature header:
X-Webhook-Signature: t=1640000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Format: t=<timestamp>,v1=<signature>
t: Unix timestamp when request was sentv1: HMAC-SHA256 signature of the payloadAlways verify webhook signatures to ensure requests are from ROUND0:
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
// Parse signature header
const parts = signature.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
const sig = parts.find(p => p.startsWith('v1=')).split('=')[1];
// Check timestamp tolerance (5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Timestamp outside tolerance');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// Compare signatures (constant-time comparison)
return crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expectedSig, 'hex')
);
}
// In your endpoint
app.post('/webhooks/round0', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = req.body.toString('utf8'); // Raw body string
try {
const isValid = verifyWebhookSignature(
payload,
signature,
process.env.ROUNDZERO_WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
// Process event...
res.status(200).send('OK');
} catch (error) {
console.error('Webhook verification failed:', error);
res.status(400).send('Bad request');
}
});
Signatures include a timestamp to prevent replay attacks. Reject requests if:
If your secret is compromised:
If webhook delivery fails, ROUND0 automatically retries:
| Attempt | Delay | Cumulative Time |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 30 seconds | 30s |
| 3 | 2 minutes | 2m 30s |
| 4 | 8 minutes | 10m 30s |
| 5 | 32 minutes | 42m 30s |
Total: 5 attempts over ~43 minutes
After 5 failed attempts, the delivery is marked as permanently failed.
Webhooks may be delivered more than once (due to retries or network issues). Make your event handlers idempotent.
Each event has a unique id field:
const processedEvents = new Set(); // Or use database
async function handleWebhook(event) {
// Check if already processed
if (processedEvents.has(event.id)) {
console.log('Event already processed:', event.id);
return;
}
// Process event
await processEvent(event);
// Mark as processed
processedEvents.add(event.id);
}
For production systems, use a database:
CREATE TABLE webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(50),
processed_at TIMESTAMP,
payload JSONB
);
async function handleWebhook(event) {
// Try to insert event
try {
await db.query(
'INSERT INTO webhook_events (event_id, event_type, processed_at, payload) VALUES ($1, $2, NOW(), $3)',
[event.id, event.type, JSON.stringify(event)]
);
} catch (error) {
if (error.code === '23505') { // Duplicate key
console.log('Event already processed:', event.id);
return;
}
throw error;
}
// Process event (only runs if insert succeeded)
await processEvent(event);
}
If deliveries are failing:
Sync interview status to your ATS:
async function handleInterviewEnded(event) {
const { interview, candidate } = event.data;
// Fetch full results via API
const results = 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());
// Update ATS
await ats.updateCandidate({
email: candidate.email,
screening_status: 'completed',
screening_completed_at: interview.completed_at,
screening_notes: formatResults(results)
});
// Notify hiring manager
await notifications.send({
to: 'hiring-manager@company.com',
template: 'screening_completed',
data: { candidate, job: event.data.job }
});
}
Post to Slack when interviews complete:
async function handleInterviewEnded(event) {
const { interview, candidate, job } = event.data;
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `Interview completed: ${candidate.name} for ${job.title}`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${candidate.name}* completed screening for *${job.title}*`
}
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'View Results' },
url: `https://rz-app-omega.vercel.app/interviews/${interview.id}`
}
]
}
]
})
});
}