round0
Webhooks

Webhook Overview

Introduction to ROUND0 webhooks and event notifications

Webhooks Overview

Webhooks allow you to receive real-time HTTP notifications when interviews start and complete, enabling event-driven workflows and integrations.

Prerequisites

To use webhooks, you need:

  1. ROUND0 account with verified email
  2. Connectivity Package subscription
  3. HTTPS endpoint to receive webhook events
Webhook functionality requires an active Connectivity Package subscription. If your subscription is cancelled, webhook deliveries will stop.

How Webhooks Work

  1. You configure a webhook URL in ROUND0 settings
  2. When an interview event occurs (started or completed), ROUND0 sends an HTTP POST request to your URL
  3. Your server processes the event and responds with 200 OK
  4. ROUND0 marks the delivery as successful

If delivery fails, ROUND0 automatically retries with exponential backoff.

Webhook Events

ROUND0 sends webhooks for two events:

interview_started

Sent when a candidate begins an interview (status changes from "invited" to "in_progress").

Use cases:

  • Notify hiring managers that a candidate has started
  • Update ATS status to "Screening in Progress"
  • Start a timer for follow-up reminders

interview_ended

Sent when a candidate completes an interview (status changes to "completed").

Use cases:

  • Fetch interview results via API
  • Sync transcripts to your ATS
  • Notify hiring team to review candidate
  • Trigger next steps in hiring workflow

Setting Up Webhooks

1. Create an Endpoint

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
});
Important: Your endpoint must respond within 10 seconds. For long-running tasks, return 200 OK first, then process asynchronously.

2. Configure in ROUND0

  1. Navigate to SettingsWebhooks
  2. Enter your webhook URL (must be HTTPS)
  3. Click Create Webhook
  4. Copy the webhook secret immediately - it's shown only once
  5. Store the secret securely

3. Verify Setup

Test your webhook:

  1. In SettingsWebhooks, click Send Test Event
  2. Check your server logs for the test event
  3. Verify signature validation works
  4. Check that your endpoint responded with 200 OK

Webhook Payload

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 data

Security

Webhooks are secured using HMAC-SHA256 signatures, similar to Stripe's webhook security.

Webhook Signature

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 sent
  • v1: HMAC-SHA256 signature of the payload

Verifying Signatures

Always 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');
  }
});
Critical: Always verify signatures. Without verification, attackers could send fake events to your endpoint.

Timestamp Tolerance

Signatures include a timestamp to prevent replay attacks. Reject requests if:

  • Timestamp is older than 5 minutes
  • Timestamp is in the future

Rotating Secrets

If your secret is compromised:

  1. Navigate to SettingsWebhooks
  2. Click Rotate Secret
  3. Update your server with the new secret
  4. Old secret is immediately invalidated

Retry Logic

If webhook delivery fails, ROUND0 automatically retries:

Retry Schedule

AttemptDelayCumulative Time
1Immediate0s
230 seconds30s
32 minutes2m 30s
48 minutes10m 30s
532 minutes42m 30s

Total: 5 attempts over ~43 minutes

After 5 failed attempts, the delivery is marked as permanently failed.

What Counts as Failure

  • No response within 10 seconds (timeout)
  • HTTP status code other than 2xx (200-299)
  • Network errors (DNS failure, connection refused, etc.)
  • TLS/SSL errors

What Counts as Success

  • HTTP status code 2xx (200, 201, 204, etc.)
  • Response received within 10 seconds
The response body is ignored. You can return an empty 200 OK or include a JSON response - both are treated the same.

Idempotency

Webhooks may be delivered more than once (due to retries or network issues). Make your event handlers idempotent.

Using Event IDs

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);
}

Database Tracking

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);
}

Monitoring Webhooks

View Delivery History

  1. Navigate to SettingsWebhooksDeliveries
  2. View all delivery attempts with:
    • Event type
    • Status (success, failed, retrying)
    • Timestamp
    • Response code
    • Error message (if failed)
    • Retry count

Filter and Debug

  • Filter by status: Success, Failed, Retrying
  • Filter by event type
  • View request payload
  • View response received
  • See retry history

Troubleshooting Failed Deliveries

If deliveries are failing:

  1. Check that your endpoint is accessible via HTTPS
  2. Verify it responds within 10 seconds
  3. Check server logs for errors
  4. Test with the "Send Test Event" button
  5. Verify SSL certificate is valid

Best Practices

Endpoint Design

  1. Return 200 OK quickly: Respond within 10 seconds
  2. Process asynchronously: Queue long-running tasks
  3. Use raw body for signature verification: Don't parse JSON before verifying
  4. Handle duplicates: Implement idempotency
  5. Log everything: Useful for debugging

Error Handling

  1. Validate payload: Check event type and data structure
  2. Graceful degradation: Don't crash on unexpected events
  3. Log failures: Track which events failed to process
  4. Alert on repeated failures: Monitor your system health

Security

  1. Always verify signatures: Never trust unverified requests
  2. Use HTTPS: Required for webhook URLs
  3. Protect webhook secret: Store in environment variables
  4. Rotate secrets periodically: Good security practice
  5. Validate timestamp: Prevent replay attacks

Performance

  1. Queue heavy processing: Return 200 OK first
  2. Batch operations: If receiving many events
  3. Monitor timeout rates: If seeing timeouts, optimize response time
  4. Use database for idempotency: Don't rely on in-memory sets

Common Patterns

ATS Integration

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 }
  });
}

Slack Notifications

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}`
            }
          ]
        }
      ]
    })
  });
}

Next Steps