> ## 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.

# Webhooks

> Receive real-time notifications for interview events

## Overview

Webhooks allow you to receive real-time notifications when events occur in the Gather API. Configure webhooks to be notified when interviews are completed, statuses change, or audio generation finishes.

## Webhook Events

The following events are available:

* `question.audio_generated` - Question audio generation completed
* `candidate_interview.completed` - Candidate completed interview
* `candidate_interview.status_changed` - Status changed
* `interview.created` - Interview created
* `interview.updated` - Interview updated

## Webhook Delivery Format

Each webhook is delivered as an HTTP `POST` to your configured URL.

### Request Headers

| Header                | Description                                        |
| --------------------- | -------------------------------------------------- |
| `Content-Type`        | `application/json`                                 |
| `X-Qualifi-Event`     | Event type (e.g., `candidate_interview.completed`) |
| `X-Qualifi-Signature` | HMAC-SHA256 hex digest of the raw JSON body        |

### Request Body

The request body is a **flat JSON object** containing event-specific fields. The event type is **not** included in the body — read it from the `X-Qualifi-Event` header.

Example for `candidate_interview.completed`:

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

## Webhook Configuration

<Callout type="info">
  Webhooks can be configured per organization/team via the Gather API's webhook configuration endpoints (`/qsi/gather/webhooks` — create, list, get, update, delete). Use the [Webhook Delivery Logs](#webhook-delivery-logs) endpoint to retrieve delivery history.
</Callout>

<Callout type="warn">
  Deleting a configuration that has delivery history is rejected with a `400` to prevent accidental loss of that history. To delete the configuration **and** its deliveries, send `DELETE /qsi/gather/webhooks/:id?deleteDeliveries=true`.
</Callout>

### Configuration Options

* **Multiple URLs**: Supports multiple webhook URLs per organization
* **Event Filtering**: Configure which events to receive
* **Retry Logic**: Automatic retries with exponential backoff (3 attempts)
* **Signature Verification**: HMAC-SHA256 signature for security

## Webhook Delivery Logs

Retrieve a paginated history of webhook delivery attempts for the authenticated organization, sorted newest-first. Useful for debugging failed deliveries and reconciling expected vs. received events.

<Callout type="info">
  Webhook secrets are never returned in delivery logs.
</Callout>

### Endpoint

`GET /qsi/gather/webhook-deliveries`

### Query Parameters

<ParamField query="teamId" type="string">Override team scope (must be allowed for the API key).</ParamField>
<ParamField query="webhookConfigurationId" type="string">Filter to a single webhook configuration.</ParamField>
<ParamField query="eventType" type="string">Filter by event type, e.g. `candidate_interview.completed`.</ParamField>
<ParamField query="status" type="string">One of `pending`, `processing`, `completed`, `failed`.</ParamField>
<ParamField query="candidateInterviewId" type="string">Filter to deliveries whose payload contains this candidate interview ID. Useful for tracing the events emitted for a specific interview.</ParamField>
<ParamField query="startDate" type="string">Inclusive lower bound on `createdAt` (ISO 8601).</ParamField>
<ParamField query="endDate" type="string">Inclusive upper bound on `createdAt` (ISO 8601).</ParamField>
<ParamField query="page" type="number">Page number, 1-indexed. Default `1`.</ParamField>
<ParamField query="pageSize" type="number">Records per page, max `200`. Default `50`.</ParamField>

### Response

```json theme={null}
{
  "data": {
    "webhookDeliveries": [
      {
        "id": "uuid",
        "webhookConfigurationId": "uuid",
        "eventType": "candidate_interview.completed",
        "status": "completed",
        "attempts": 1,
        "payload": { "candidateInterviewId": "uuid", "candidateId": "uuid" },
        "response": { "status": 200, "statusText": "OK", "headers": {}, "data": {} },
        "errorResponse": null,
        "createdAt": "2026-05-28T12:00:00.000Z",
        "updatedAt": "2026-05-28T12:00:00.000Z"
      }
    ],
    "pagination": { "page": 1, "pageSize": 50, "total": 1234, "totalPages": 25 }
  },
  "meta": { "requestId": "...", "timestamp": "..." }
}
```

### Status State Machine

| Status       | Meaning                                                                  |
| ------------ | ------------------------------------------------------------------------ |
| `pending`    | Queued, not yet attempted                                                |
| `processing` | In flight (first or retry attempt)                                       |
| `completed`  | Delivered successfully (downstream returned 2xx)                         |
| `failed`     | Exhausted retries; `errorResponse` populated with downstream status/body |

## Webhook Signature Verification

All webhooks include an `X-Qualifi-Signature` header containing the HMAC-SHA256 hex digest of the **raw JSON request body** (the same bytes sent in the POST).

### Verification Process

1. Read the raw request body as a string (before parsing JSON, if possible)
2. Compute HMAC-SHA256 using your webhook secret and the raw body string
3. Compare the computed hex digest with the `X-Qualifi-Signature` header value

<Callout type="warning">
  Signature verification must use the exact raw JSON string from the request body. Re-serializing a parsed JSON object can change key order or whitespace and cause verification to fail.
</Callout>

### Example Verification

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

    function verifyWebhookSignature({ rawBody, signature, secret }) {
      const computedSignature = crypto
        .createHmac('sha256', secret)
        .update(rawBody)
        .digest('hex');
      
      return crypto.timingSafeEqual(
        Buffer.from(computedSignature),
        Buffer.from(signature)
      );
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import hmac
    import hashlib

    def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
        computed_signature = hmac.new(
            secret.encode(),
            raw_body,
            hashlib.sha256
        ).hexdigest()
        
        return hmac.compare_digest(computed_signature, signature)
    ```
  </Tab>
</Tabs>

## Event Details

### question.audio\_generated

Triggered when question audio generation completes.

**Header:** `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"
}
```

### candidate\_interview\.completed

Triggered when a candidate completes an interview. May fire when status changes to `new_response` or when the interview completion worker processes the interview.

**Header:** `X-Qualifi-Event: candidate_interview.completed`

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

### candidate\_interview\.status\_changed

Triggered when candidate interview status changes.

**Header:** `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"
}
```

| Field                  | Type             | Description                                     |
| ---------------------- | ---------------- | ----------------------------------------------- |
| `candidateInterviewId` | `string`         | Candidate interview ID                          |
| `candidateId`          | `string`         | Candidate ID                                    |
| `interviewId`          | `string`         | Interview ID                                    |
| `status`               | `string`         | New status after the change                     |
| `previousStatus`       | `string \| null` | Previous status; may be `null` if not available |
| `updatedAt`            | `string`         | ISO 8601 timestamp when the status was updated  |

## Retry Logic

Webhooks use exponential backoff for failed 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. Check your webhook endpoint availability and error handling.
</Callout>

## Best Practices

1. **Verify Signatures**: Always verify webhook signatures to ensure authenticity
2. **Idempotency**: Handle duplicate webhook deliveries gracefully
3. **Quick Response**: Respond to webhooks quickly (within 5 seconds)
4. **Error Handling**: Return appropriate HTTP status codes
5. **Logging**: Log all webhook events for debugging

## Related Resources

<CardGroup cols={2}>
  <Card title="Handling Webhooks" icon="book" href="/guides/handling-webhooks">
    Step-by-step guide for handling webhooks
  </Card>

  <Card title="Candidate Interviews" icon="user-check" href="/api-reference/candidate-interviews">
    Learn about candidate interview events
  </Card>
</CardGroup>
