> ## Documentation Index
> Fetch the complete documentation index at: https://docs.humanly.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Handling Webhooks

> Step-by-step guide to setting up and handling webhooks

## Overview

Webhooks allow you to receive real-time notifications when events occur in the Gather API. This guide walks you through setting up and handling webhooks.

## Setting Up Webhooks

<Callout type="info">
  Webhooks are configured per organization/team via the Gather API's webhook configuration endpoints (`/qsi/gather/webhooks` — create, list, get, update, delete). See the [Webhooks API reference](/api-reference/webhooks) for events and delivery details.
</Callout>

### Configuration Requirements

1. **Webhook URL**: Your endpoint URL that will receive webhook events
2. **Event Types**: Which events you want to receive
3. **Webhook Secret**: Secret key for signature verification

## Webhook Endpoint Setup

Your webhook endpoint should:

1. Accept POST requests
2. Respond quickly (within 5 seconds)
3. Return appropriate HTTP status codes
4. Verify webhook signatures

### Basic Endpoint Structure

<Tabs>
  <Tab title="JavaScript (Express)">
    ```javascript theme={null}
    const express = require('express');
    const crypto = require('crypto');
    const app = express();

    const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

    // Use raw body for signature verification
    app.use(express.json({
      verify: (req, res, buf) => {
        req.rawBody = buf;
      }
    }));

    function verifySignature({ rawBody, signature }) {
      const computedSignature = crypto
        .createHmac('sha256', WEBHOOK_SECRET)
        .update(rawBody)
        .digest('hex');
      
      return crypto.timingSafeEqual(
        Buffer.from(computedSignature),
        Buffer.from(signature)
      );
    }

    app.post('/webhooks/qualifi', (req, res) => {
      const signature = req.headers['x-qualifi-signature'];
      const event = req.headers['x-qualifi-event'];
      
      if (!verifySignature({ rawBody: req.rawBody, signature })) {
        return res.status(401).send('Invalid signature');
      }
      
      const payload = req.body;
      
      switch (event) {
        case 'candidate_interview.completed':
          handleInterviewCompleted(payload);
          break;
        case 'candidate_interview.status_changed':
          handleStatusChanged(payload);
          break;
        case 'question.audio_generated':
          handleAudioGenerated(payload);
          break;
        // ... other events
      }
      
      res.status(200).send('OK');
    });

    function handleInterviewCompleted(payload) {
      console.log('Interview completed:', payload.candidateInterviewId);
      // Process the completed interview
    }

    function handleStatusChanged(payload) {
      console.log('Status changed:', payload.candidateInterviewId, payload.status);
    }

    app.listen(3000);
    ```
  </Tab>

  <Tab title="Python (Flask)">
    ```python theme={null}
    from flask import Flask, request, jsonify
    import hmac
    import hashlib
    import os

    app = Flask(__name__)
    WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')

    def verify_signature(raw_body: bytes, signature: str) -> bool:
        computed_signature = hmac.new(
            WEBHOOK_SECRET.encode(),
            raw_body,
            hashlib.sha256
        ).hexdigest()
        
        return hmac.compare_digest(computed_signature, signature)

    @app.route('/webhooks/qualifi', methods=['POST'])
    def webhook():
        signature = request.headers.get('X-Qualifi-Signature')
        event = request.headers.get('X-Qualifi-Event')
        
        if not verify_signature(request.get_data(), signature):
            return jsonify({'error': 'Invalid signature'}), 401
        
        payload = request.get_json()
        
        if event == 'candidate_interview.completed':
            handle_interview_completed(payload)
        elif event == 'candidate_interview.status_changed':
            handle_status_changed(payload)
        elif event == 'question.audio_generated':
            handle_audio_generated(payload)
        
        return jsonify({'status': 'OK'}), 200

    def handle_interview_completed(payload):
        print(f"Interview completed: {payload['candidateInterviewId']}")

    def handle_status_changed(payload):
        print(f"Status changed: {payload['candidateInterviewId']} -> {payload['status']}")

    if __name__ == '__main__':
        app.run(port=3000)
    ```
  </Tab>
</Tabs>

## Handling Events

### Interview Completed

When a candidate completes an interview, the request includes `X-Qualifi-Event: candidate_interview.completed` and a flat JSON body:

```json theme={null}
{
  "candidateInterviewId": "uuid",
  "candidateId": "uuid",
  "interviewId": "uuid",
  "completedAt": "2024-01-01T00:00:00.000Z"
}
```

**Action**: Fetch interview results and process them:

```javascript theme={null}
async function handleInterviewCompleted(payload) {
  const results = await fetch(
    `https://api.prod.qualifi.hr/qsi/gather/candidate-interviews/${payload.candidateInterviewId}/results`,
    {
      headers: {
        'x-api-key': apiKey
      }
    }
  );
  
  const interviewResults = await results.json();
  // Process results: save to database, send notifications, etc.
}
```

### Audio Generated

When question audio generation completes (`X-Qualifi-Event: question.audio_generated`):

```json theme={null}
{
  "aiAudioVersionId": "uuid",
  "batchId": "uuid",
  "narratorId": "uuid",
  "audioUrl": "https://...",
  "text": "Question text",
  "createdAt": "2024-01-01T00:00:00.000Z"
}
```

**Action**: Update your system with the audio URL.

### Status Changed

When candidate interview status changes (`X-Qualifi-Event: candidate_interview.status_changed`):

```json theme={null}
{
  "candidateInterviewId": "uuid",
  "candidateId": "uuid",
  "interviewId": "uuid",
  "status": "new_response",
  "previousStatus": null,
  "updatedAt": "2024-01-01T00:00:00.000Z"
}
```

**Action**: Update your system's status tracking. Use `status` for the new value; `previousStatus` may be `null` if the prior status was not captured.

<Callout type="info">
  When status changes to `new_response`, you may receive both `candidate_interview.status_changed` and `candidate_interview.completed` webhooks.
</Callout>

## Best Practices

1. **Verify Signatures**: Always verify webhook signatures to ensure authenticity
2. **Idempotency**: Handle duplicate webhook deliveries gracefully using event IDs or timestamps
3. **Quick Response**: Respond to webhooks quickly (within 5 seconds) to avoid retries
4. **Error Handling**: Return appropriate HTTP status codes (200 for success, 4xx/5xx for errors)
5. **Logging**: Log all webhook events for debugging and auditing
6. **Async Processing**: Process webhook data asynchronously if operations take time

## Retry Logic

The Gather API retries failed webhook deliveries:

1. **First attempt**: Immediate
2. **Second attempt**: After 1 minute
3. **Third attempt**: After 5 minutes

<Callout type="warning">
  If all retry attempts fail, the webhook delivery is marked as failed. Ensure your endpoint is available and handles errors gracefully.
</Callout>

## Testing Webhooks

Use a tool like [ngrok](https://ngrok.com/) to test webhooks locally:

```bash theme={null}
# Start your local server
npm start

# In another terminal, expose it via ngrok
ngrok http 3000

# Use the ngrok URL as the webhook URL in your webhook configuration
```

## Related Resources

<CardGroup cols={2}>
  <Card title="Webhooks API" icon="code" href="/api-reference/webhooks">
    Complete webhook API reference
  </Card>

  <Card title="Error Handling" icon="alert-triangle" href="/guides/error-handling">
    Learn about error handling best practices
  </Card>

  <Card title="Best Practices" icon="check-circle" href="/guides/best-practices">
    Review API best practices
  </Card>
</CardGroup>
