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

# Webhooks

> Real-time notifications for events in your InstaView account

## Overview

Webhooks enable your application to receive real-time notifications when events occur in InstaView. Instead of polling the API, webhooks push data to your endpoint immediately when interviews complete, analyses finish, or other events happen.

## How Webhooks Work

<Steps>
  <Step title="Configure Endpoint">
    Register your webhook URL and specify which events to receive
  </Step>

  <Step title="Event Occurs">
    An interview completes, analysis finishes, or other tracked event happens
  </Step>

  <Step title="Receive Notification">
    InstaView sends an HTTP POST request to your endpoint with event details
  </Step>

  <Step title="Process & Respond">
    Your server processes the event and returns a 2xx status code
  </Step>
</Steps>

## Event Types

InstaView supports the following webhook event types:

<Tabs>
  <Tab title="Interview Events">
    | Event                   | Description                                                                                                   |
    | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
    | `interview.started`     | Interview session began                                                                                       |
    | `interview.completed`   | Interview finished with analysis available                                                                    |
    | `interview.failed`      | Technical failure occurred during interview                                                                   |
    | `interview.rescheduled` | A call attempt did not connect (or the candidate asked to move it) and a follow-up attempt has been scheduled |
    | `interview.cancelled`   | Interview reached a terminal state without completing                                                         |
  </Tab>

  <Tab title="Analysis Events">
    | Event                | Description                           |
    | -------------------- | ------------------------------------- |
    | `analysis.completed` | Analysis completed for a call attempt |
    | `analysis.failed`    | Analysis failed for a call attempt    |
  </Tab>

  <Tab title="Sourcing Events">
    | Event                | Description                                               |
    | -------------------- | --------------------------------------------------------- |
    | `sourcing.completed` | Sourcing agent run finished with a scored candidate list  |
    | `sourcing.failed`    | Sourcing agent run terminated with an unrecoverable error |
  </Tab>

  <Tab title="Enrichment Events">
    | Event              | Description                                                                            |
    | ------------------ | -------------------------------------------------------------------------------------- |
    | `enrich.completed` | Enrichment run completed (partial profile-level failures do not prevent this event)    |
    | `enrich.failed`    | Enrichment run terminated with an unrecoverable error before any profile was processed |
  </Tab>

  <Tab title="System Events">
    | Event  | Description                                   |
    | ------ | --------------------------------------------- |
    | `ping` | Test event for verifying webhook connectivity |
  </Tab>
</Tabs>

### Event Type Strings

There is a distinction between the strings used to **register** a webhook and the strings sent in the **webhook payload**:

#### Registration (Screaming Snake Case)

When creating or updating webhooks, use these uppercase strings in the `events` array:

* `"ANALYSIS_COMPLETED"` - Analysis finished successfully
* `"ANALYSIS_FAILED"` - Analysis processing failed
* `"PING"` - Test event for connectivity
* `"INTERVIEW_COMPLETED"` - Interview finished with analysis
* `"INTERVIEW_FAILED"` - Technical failure during interview
* `"INTERVIEW_STARTED"` - Interview session began
* `"INTERVIEW_RESCHEDULED"` - A follow-up call attempt has been scheduled
* `"INTERVIEW_CANCELLED"` - Interview terminated without completing
* `"SOURCING_COMPLETED"` - Sourcing run finished with scored candidates
* `"SOURCING_FAILED"` - Sourcing run terminated with an error
* `"ENRICH_COMPLETED"` - Enrichment run completed
* `"ENRICH_FAILED"` - Enrichment run terminated with an error

#### Payload (Dot Notation)

The `event` field in the JSON payload you receive will use lowercase dot notation (e.g., `"analysis.completed"`).

## Payload Structure

All webhook payloads follow this structure:

```json theme={null}
{
  "event": "interview.completed",
  "timestamp": "2024-01-20T14:30:00.000Z",
  "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    // Event-specific data
  }
}
```

### Interview Events Payload

```json theme={null}
{
  "event": "interview.completed",
  "timestamp": "2024-01-20T14:30:00.000Z",
  "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "interviewId": "interview-uuid",
    "callAttemptId": "call-attempt-uuid",
    "candidateId": "candidate-uuid",
    "jobId": "job-uuid",
    "status": "COMPLETED"
  }
}
```

### Interview Rescheduled Payload

Sent when a call attempt did not produce a completed interview and a follow-up attempt has been scheduled. This event sits **between** two call attempts, so unlike the other interview events it does not carry a single `callAttemptId` — the two attempts are split explicitly.

<Tabs>
  <Tab title="Automatic retry">
    The candidate's line was busy, so the system scheduled another attempt on its own.

    ```json theme={null}
    {
      "event": "interview.rescheduled",
      "timestamp": "2024-01-20T14:30:00.000Z",
      "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
      "data": {
        "interviewId": "interview-uuid",
        "candidateId": "candidate-uuid",
        "jobId": "job-uuid",
        "status": "RESCHEDULED",
        "reason": "BUSY",
        "previousAttempt": {
          "callAttemptId": "call-attempt-uuid-1",
          "status": "BUSY",
          "endedReason": "customer-busy",
          "durationSeconds": 0,
          "attemptNumber": 1
        },
        "nextAttempt": {
          "callAttemptId": "call-attempt-uuid-2",
          "scheduledAt": "2024-01-20T14:35:00.000Z",
          "attemptNumber": 2
        },
        "retryCount": 1,
        "maxAttempts": 5
      }
    }
    ```
  </Tab>

  <Tab title="Candidate request">
    The candidate answered and asked to be called back at a specific time.

    ```json theme={null}
    {
      "event": "interview.rescheduled",
      "timestamp": "2024-01-20T14:30:00.000Z",
      "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
      "data": {
        "interviewId": "interview-uuid",
        "candidateId": "candidate-uuid",
        "jobId": "job-uuid",
        "status": "RESCHEDULED",
        "reason": "CANDIDATE_REQUEST",
        "previousAttempt": {
          "callAttemptId": "call-attempt-uuid-1",
          "status": "COMPLETED",
          "attemptNumber": 1
        },
        "nextAttempt": {
          "callAttemptId": "call-attempt-uuid-2",
          "scheduledAt": "2024-01-21T09:00:00.000Z",
          "attemptNumber": 2
        },
        "retryCount": 1,
        "candidateRequest": {
          "rawText": "Could you call me back tomorrow morning?",
          "timeResolved": true
        }
      }
    }
    ```
  </Tab>
</Tabs>

**Field notes:**

| Field                           | Notes                                                                                                                                                                                                                                                           |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reason`                        | One of `CANDIDATE_REQUEST`, `NO_ANSWER`, `BUSY`, `VOICEMAIL`, `TECHNICAL_FAILURE`, `API_REQUEST`, `ADMIN`. Branch on this — it is a stable vocabulary.                                                                                                          |
| `API_REQUEST` vs `ADMIN`        | `API_REQUEST` means **you** moved the attempt via `PATCH /interviews/{id}/call-attempts/{attemptId}`. `ADMIN` means InstaView staff moved it from the internal admin panel — worth surfacing differently, since it indicates someone intervened on your behalf. |
| `previousAttempt.endedReason`   | The raw underlying telephony reason. Diagnostic only; it is not a stable contract and may be absent.                                                                                                                                                            |
| `maxAttempts`                   | Present only on automatic retries, where a retry ceiling governs. A candidate-requested reschedule does not spend the retry budget, so the field is omitted.                                                                                                    |
| `candidateRequest`              | Present only when `reason` is `CANDIDATE_REQUEST`.                                                                                                                                                                                                              |
| `candidateRequest.timeResolved` | `false` means the candidate's requested time could not be interpreted and a default slot was used instead — the candidate is **not** expecting a call at `scheduledAt`. Worth surfacing to a recruiter.                                                         |

### Interview Cancelled Payload

Sent when an interview reaches a terminal state without completing — either cancelled explicitly, or abandoned after exhausting its retry budget.

```json theme={null}
{
  "event": "interview.cancelled",
  "timestamp": "2024-01-20T14:30:00.000Z",
  "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "interviewId": "interview-uuid",
    "candidateId": "candidate-uuid",
    "jobId": "job-uuid",
    "status": "CANCELLED",
    "reason": "MAX_ATTEMPTS_EXCEEDED",
    "reasonDetail": "Retry limit of 5 reached (last failure: customer-busy)",
    "lastCallAttemptId": "call-attempt-uuid-5",
    "attemptsMade": 5,
    "cancelledBy": "SYSTEM"
  }
}
```

`reason` is one of:

| Reason                     | Meaning                                                                                                                 |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `MAX_ATTEMPTS_EXCEEDED`    | Every permitted call attempt was made without reaching the candidate.                                                   |
| `CANDIDATE_NOT_INTERESTED` | The candidate declined during the call. `reasonDetail` carries the finer-grained outcome (e.g. `hard_no`, `polite_no`). |
| `USER_REQUEST`             | Cancelled by a user from the InstaView dashboard. `cancelledBy` is their user ID.                                       |
| `ADMIN`                    | Cancelled by InstaView staff from the internal admin panel, with the reason in `reasonDetail`.                          |

`lastCallAttemptId` is absent when the interview was cancelled before any call was placed.

<Note>
  There is no `API_REQUEST` cancellation reason. The public API has no
  interview-level cancel endpoint — `DELETE /interviews/{id}` removes the
  interview outright and cancels its pending webhook deliveries, so no
  `interview.cancelled` event is emitted for it. If you need a record of the
  cancellation, capture it before issuing the delete.
</Note>

### Rescheduling a call attempt yourself

`PATCH /interviews/{id}/call-attempts/{attemptId}` emits `interview.rescheduled` with `reason: "API_REQUEST"`. Because this endpoint **moves an existing attempt** rather than ending one and creating another, the same `callAttemptId` appears in both `previousAttempt` and `nextAttempt`, and `previousAttempt.status` is `SCHEDULED` — no call took place. This is the only reason value where that holds; for every other reason the two IDs differ.

### Interview Event Ordering

A given interview emits `interview.started` once **per call attempt**, so multiple starts are normal. Exactly one terminal event is emitted per interview — either `interview.completed`, `interview.cancelled`, or `interview.failed`.

Two representative sequences:

```
# Busy on the first try, answered on the second
interview.started → interview.rescheduled (BUSY) → interview.started
                  → interview.completed → analysis.completed

# Never reached
interview.started → interview.rescheduled (NO_ANSWER) → interview.started
                  → interview.rescheduled (NO_ANSWER) → … → interview.cancelled (MAX_ATTEMPTS_EXCEEDED)
```

<Info>
  Before these events existed, retries and cancellations were silent — an
  integrator saw repeated `interview.started` events with no explanation for the
  gaps, and an interview that exhausted its attempts never produced a terminal
  event at all. If your handler infers state from event ordering, subscribe to
  both new events.
</Info>

### Analysis Events Payload

<Tabs>
  <Tab title="Screening">
    ```json theme={null}
    {
      "event": "analysis.completed",
      "timestamp": "2024-01-20T14:30:00.000Z",
      "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
      "data": {
        "interviewId": "interview-uuid",
        "callAttemptId": "call-attempt-uuid",
        "candidateId": "candidate-uuid",
        "jobId": "job-uuid",
        "status": "COMPLETED",
        "analysis": {
          "id": "analysis-uuid",
          "general": {
            "overallRating": 85,
            "companyFitRating": "HIGH",
            "education": "Master's degree in Computer Science",
            "experience": "5 years of experience in software development",
            "strongPoints": [
                "Excellent communication skills",
                "Strong technical expertise in React and Node.js",
                "Proven track record of leading teams"
            ],
            "weakPoints": [
                "Limited experience with microservices",
                "Needs improvement in system design"
            ],
            "evaluation": [
                "Strong candidate with relevant experience",
                "Good cultural fit for the team",
                "Recommended for next round"
            ],
            "status": "COMPLETED",
            "createdAt": "2024-01-20T14:30:00.000Z",
            "updatedAt": "2024-01-20T14:30:00.000Z"
          },
          "specific": {}
        },
        "analysisPdfBase64": "JVBERi0xLjQKMSAwIG9iai...",
        "callAttempts": [
            {
                "id": "call-attempt-uuid",
                "direction": "OUTBOUND",
                "status": "COMPLETED",
                "recordingUrl": "https://api.instaview.sk/public/recordings/uuid.mp3",
                "duration": 120,
                "calledAt": "2024-01-20T14:30:00.000Z"
            }
        ]
      }
    }
    ```
  </Tab>

  <Tab title="Language Test">
    ```json theme={null}
    {
      "event": "analysis.completed",
      "timestamp": "2024-01-20T14:30:00.000Z",
      "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
      "data": {
        "interviewId": "interview-uuid",
        "callAttemptId": "call-attempt-uuid",
        "candidateId": "candidate-uuid",
        "jobId": "job-uuid",
        "status": "COMPLETED",
        "analysis": {
          "id": "analysis-uuid",
          "general": {
            "overallRating": 95,
            "status": "COMPLETED",
            "createdAt": "2024-01-20T14:30:00.000Z",
            "updatedAt": "2024-01-20T14:30:00.000Z"
          },
          "specific": {
              "pronunciationScores": {
                  "accuracy": 92,
                  "fluency": 88,
                  "prosody": 90
              },
              "pronunciationAssessment": {
                  "estimated_pronunciation_level": "C1",
                  "reasoning": "Clear pronunciation with minor accent."
              },
              "grammar": {
                  "level": "C1",
                  "meets_tested_level": true,
                  "summary": "Strong command of grammar structures."
              },
              "vocabulary": {
                  "level": "C1",
                  "meets_tested_level": true,
                  "summary": "Wide vocabulary range used appropriately."
              },
              "overallScore": {
                  "level": "C1",
                  "confidence": 0.95,
                  "summary": "Excellent overall language proficiency."
              },
              "combinedCefrEvaluation": {
                  "final_level": "C1",
                  "confidence": 0.95,
                  "summary": "Candidate demonstrates C1 level proficiency."
              }
          }
        },
        "analysisPdfBase64": "JVBERi0xLjQKMSAwIG9iai...",
        "callAttempts": [
            {
                "id": "call-attempt-uuid",
                "direction": "OUTBOUND",
                "status": "COMPLETED",
                "recordingUrl": "https://api.instaview.sk/public/recordings/uuid.mp3",
                "duration": 150,
                "calledAt": "2024-01-20T14:30:00.000Z"
            }
        ]
      }
    }
    ```
  </Tab>

  <Tab title="Outreach">
    ```json theme={null}
    {
      "event": "analysis.completed",
      "timestamp": "2024-01-20T14:30:00.000Z",
      "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
      "data": {
        "interviewId": "interview-uuid",
        "callAttemptId": "call-attempt-uuid",
        "candidateId": "candidate-uuid",
        "jobId": "job-uuid",
        "status": "COMPLETED",
        "analysis": {
          "id": "analysis-uuid",
          "general": {
            "status": "COMPLETED",
            "createdAt": "2024-01-20T14:30:00.000Z",
            "updatedAt": "2024-01-20T14:30:00.000Z"
          },
          "specific": {
              "interestType": "interested_now",
              "specificFeedback": "Candidate is very interested in the position and available immediately.",
              "preferredNextStep": "technical_interview"
          }
        },
        "analysisPdfBase64": "JVBERi0xLjQKMSAwIG9iai...",
        "callAttempts": [
            {
                "id": "call-attempt-uuid",
                "direction": "OUTBOUND",
                "status": "COMPLETED",
                "recordingUrl": "https://api.instaview.sk/public/recordings/uuid.mp3",
                "duration": 45,
                "calledAt": "2024-01-20T14:30:00.000Z"
            }
        ]
      }
    }
    ```
  </Tab>
</Tabs>

### Failed Event Payloads

Failed events include an error message:

```json theme={null}
{
  "event": "interview.failed",
  "timestamp": "2024-01-20T14:30:00.000Z",
  "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "interviewId": "interview-uuid",
    "callAttemptId": "call-attempt-uuid",
    "candidateId": "candidate-uuid",
    "jobId": "job-uuid",
    "status": "FAILED",
    "errorMessage": "Connection timeout during call"
  }
}
```

### Ping Event Payload

```json theme={null}
{
  "event": "ping",
  "timestamp": "2024-01-20T14:30:00.000Z",
  "deliveryId": "550e8400-e29b-41d4-a716-446655440000",
  "data": {
    "message": "Test ping from InstaView webhook system"
  }
}
```

<Info>
  Most webhook payloads contain minimal data (IDs and status), but the
  `analysis.completed` event includes the full analysis results and the
  `analysisPdfBase64` Base64-encoded PDF report (when generation succeeds). For
  other details like transcripts, use the [Interview
  API](/api-reference/interviews/get-interview) to fetch the complete resource.
</Info>

## Per-Company Webhooks

For ATS integrations managing multiple companies, you can register webhooks per company by specifying the `companyId` parameter when creating a webhook. This enables:

* **Company-specific endpoints**: Each company can have its own webhook URL (e.g., different subdomains)
* **Selective event delivery**: Webhooks with `companyId` only receive events for that specific company
* **Global webhooks**: Webhooks without `companyId` receive events for all companies accessible by the API key

### Example: Per-Company Setup

```javascript theme={null}
// Webhook for Company A
await createWebhook({
  url: "https://company-a.yourats.com/webhooks/instaview",
  companyId: "company-a-uuid",
  events: ["INTERVIEW_COMPLETED", "ANALYSIS_COMPLETED"],
});

// Webhook for Company B
await createWebhook({
  url: "https://company-b.yourats.com/webhooks/instaview",
  companyId: "company-b-uuid",
  events: ["INTERVIEW_COMPLETED", "ANALYSIS_COMPLETED"],
});

// Global webhook (receives all events)
await createWebhook({
  url: "https://admin.yourats.com/webhooks/instaview",
  // No companyId - receives events for all companies
  events: ["INTERVIEW_COMPLETED", "ANALYSIS_COMPLETED"],
});
```

### Filtering by Company

When listing webhooks, you can filter by company:

```javascript theme={null}
// List all webhooks (includes both company-specific and global webhooks)
const allWebhooks = await fetch("/webhooks", { headers });

// List webhooks for a specific company (excludes global webhooks)
// Only returns webhooks that have the specified companyId
const companyWebhooks = await fetch("/webhooks?companyId=company-uuid", {
  headers,
});
```

**Note**: When filtering by `companyId`, only company-specific webhooks are returned. Global webhooks (those without a `companyId`) are excluded from filtered results. To see all webhooks including global ones, omit the `companyId` parameter.

## Required Scopes

| Operation             | Required Scope   |
| --------------------- | ---------------- |
| List webhooks         | `read:webhooks`  |
| Get webhook by ID     | `read:webhooks`  |
| Create webhook        | `write:webhooks` |
| Update webhook        | `write:webhooks` |
| Delete webhook        | `write:webhooks` |
| Test webhook          | `write:webhooks` |
| Reset circuit breaker | `write:webhooks` |

## Security

### Signature Verification

Every webhook request includes an HMAC-SHA256 signature in the `X-Webhook-Signature` header. **Always verify this signature** to ensure requests are from InstaView.

The signature format is: `sha256=<hex-encoded-signature>`

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require("crypto");

  function verifyWebhookSignature(payload, signature, secret) {
    if (typeof signature !== "string" || !signature.startsWith("sha256=")) {
      return false;
    }
    const expectedSignature =
      "sha256=" +
      crypto
        .createHmac("sha256", Buffer.from(secret, "hex"))
        .update(payload, "utf8")
        .digest("hex");

    const received = Buffer.from(signature, "utf8");
    const expected = Buffer.from(expectedSignature, "utf8");
    if (received.length !== expected.length) return false;
    return crypto.timingSafeEqual(received, expected);
  }

  // Express.js example
  app.post(
    "/webhooks/instaview",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const signature = req.headers["x-webhook-signature"];
      const payload = req.body.toString();

      if (
        !verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)
      ) {
        return res.status(401).send("Invalid signature");
      }

      const event = JSON.parse(payload);
      // Process the event...

      res.status(200).send("OK");
    },
  );
  ```

  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
      expected = 'sha256=' + hmac.new(
          bytes.fromhex(secret),
          payload,
          hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(signature, expected)

  # Flask example
  @app.route('/webhooks/instaview', methods=['POST'])
  def handle_webhook():
      signature = request.headers.get('X-Webhook-Signature')
      payload = request.get_data()

      if not verify_webhook_signature(payload, signature, os.environ['WEBHOOK_SECRET']):
          return 'Invalid signature', 401

      event = request.get_json()
      # Process the event...

      return 'OK', 200
  ```

  ```php PHP theme={null}
  <?php

  function verifyWebhookSignature($payload, $signature, $secret) {
      $expected = 'sha256=' . hash_hmac('sha256', $payload, hex2bin($secret));
      return hash_equals($expected, $signature);
  }

  // Handle webhook
  $payload = file_get_contents('php://input');
  $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

  if (!verifyWebhookSignature($payload, $signature, getenv('WEBHOOK_SECRET'))) {
      http_response_code(401);
      exit('Invalid signature');
  }

  $event = json_decode($payload, true);
  // Process the event...

  http_response_code(200);
  echo 'OK';
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "io"
      "net/http"
      "os"
  )

  func verifyWebhookSignature(payload []byte, signature, secret string) bool {
      secretBytes, err := hex.DecodeString(secret)
      if err != nil {
          return false
      }
      h := hmac.New(sha256.New, secretBytes)
      h.Write(payload)
      expected := "sha256=" + hex.EncodeToString(h.Sum(nil))
      return hmac.Equal([]byte(signature), []byte(expected))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      signature := r.Header.Get("X-Webhook-Signature")
      payload, err := io.ReadAll(r.Body)
      if err != nil {
          http.Error(w, "Failed to read body", http.StatusBadRequest)
          return
      }

      if !verifyWebhookSignature(payload, signature, os.Getenv("WEBHOOK_SECRET")) {
          http.Error(w, "Invalid signature", http.StatusUnauthorized)
          return
      }

      // Process the event...
      w.WriteHeader(http.StatusOK)
  }
  ```
</CodeGroup>

### Request Headers

Every webhook request includes these headers:

| Header                  | Description                                    |
| ----------------------- | ---------------------------------------------- |
| `Content-Type`          | Always `application/json`                      |
| `X-Webhook-Signature`   | HMAC-SHA256 signature for payload verification |
| `X-Webhook-Delivery-Id` | Unique ID for this delivery (for idempotency)  |
| `X-Webhook-Event`       | Event type (e.g., `interview.completed`)       |
| `User-Agent`            | `InstaView-Webhook/1.0`                        |

Plus any custom headers you configured when creating the webhook.

### Secret Management

<Warning>
  **Store your signing secret securely!** The signing secret is only returned
  once when you create a webhook configuration. If lost, you must delete and
  recreate the webhook to get a new secret.
</Warning>

Best practices for secret management:

* Store secrets in environment variables or a secrets manager
* Never commit secrets to version control
* Rotate secrets periodically by creating new webhooks
* Use different secrets for development and production

## Retry Logic

If your endpoint fails to respond with a 2xx status code, InstaView automatically retries delivery with exponential backoff:

<Steps>
  <Step title="Immediate Attempt">
    First delivery attempt immediately when event occurs
  </Step>

  <Step title="Retry 1">After a short delay if first attempt fails</Step>
  <Step title="Retry 2">After a longer delay if second attempt fails</Step>
  <Step title="Retry 3">Final attempt with maximum delay</Step>
</Steps>

After all retries are exhausted, the delivery is marked as failed.

### Circuit Breaker

To protect both your system and ours, InstaView implements a circuit breaker pattern:

* If a webhook consistently fails, it will be automatically disabled
* You can monitor the `consecutiveFailures` count in the webhook configuration
* When disabled, the `circuitOpenedAt` timestamp indicates when the circuit opened
* Use the **Reset Circuit Breaker** endpoint to re-enable the webhook after fixing issues

## Endpoint Requirements

Your webhook endpoint must:

<AccordionGroup>
  <Accordion title="Respond Quickly" icon="clock">
    Return a 2xx response within 30 seconds. For long-running tasks, acknowledge
    receipt immediately and process asynchronously.

    ```javascript theme={null}
    app.post('/webhooks/instaview', async (req, res) => {
      // Acknowledge immediately
      res.status(200).send('OK');
      
      // Process asynchronously
      processWebhookAsync(req.body).catch(console.error);
    });
    ```
  </Accordion>

  <Accordion title="Be Idempotent" icon="repeat">
    Handle duplicate deliveries gracefully using the `deliveryId`:

    ```javascript theme={null}
    // Note: Use Redis or database storage in production for multi-instance deployments
    const processedDeliveries = new Set();

    app.post('/webhooks/instaview', (req, res) => {
      const { deliveryId } = req.body;
      
      if (processedDeliveries.has(deliveryId)) {
        return res.status(200).send('Already processed');
      }
      
      processedDeliveries.add(deliveryId);
      // Process the event...
      
      res.status(200).send('OK');
    });
    ```
  </Accordion>

  <Accordion title="Use HTTPS" icon="lock">
    Production webhook URLs must use HTTPS. HTTP is only allowed for localhost
    during development.
  </Accordion>

  <Accordion title="Return 2xx for Success" icon="check">
    Any 2xx status code indicates successful receipt. Retry behavior depends on the status code:

    | Status           | Meaning                                                  |
    | ---------------- | -------------------------------------------------------- |
    | 200, 201, 202    | Success - no retry                                       |
    | 429              | Rate limit - will retry                                  |
    | 4xx (except 429) | Client error - no retry (permanent errors like 401, 404) |
    | 5xx              | Server error - will retry                                |
  </Accordion>
</AccordionGroup>

## Managing Webhooks

### Create a Webhook

```javascript theme={null}
const response = await fetch("https://api.instaview.sk/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://api.yourcompany.com/webhooks/instaview",
    name: "Production Webhook",
    description: "Receives interview completion notifications",
    events: ["INTERVIEW_COMPLETED", "ANALYSIS_COMPLETED"],
    companyId: "company-uuid", // Optional: Associate with specific company
    headers: [{ name: "Authorization", value: "Bearer your-internal-token" }],
  }),
});

const { data } = await response.json();

// IMPORTANT: Store this secret securely - it's only shown once!
console.log("Signing secret:", data.signingSecret);
```

<Info>
  **Per-Company Webhooks**: For ATS integrations managing multiple companies,
  you can specify `companyId` when creating a webhook. This allows you to
  register separate webhooks per company (e.g., different subdomains). If
  omitted, the webhook will receive events for all companies accessible by the
  API key (global webhook).
</Info>

### List Webhooks

```javascript theme={null}
// List all webhooks (includes both company-specific and global webhooks)
const response = await fetch("https://api.instaview.sk/webhooks", {
  headers: { Authorization: `Bearer ${apiKey}` },
});

// Filter by company ID (returns only company-specific webhooks, excludes global ones)
const responseFiltered = await fetch(
  "https://api.instaview.sk/webhooks?companyId=company-uuid",
  {
    headers: { Authorization: `Bearer ${apiKey}` },
  },
);

const { data } = await response.json();
console.log(`Found ${data.total} webhooks`);

for (const webhook of data.data) {
  console.log(`${webhook.name}: ${webhook.url}`);
  console.log(`  Company: ${webhook.companyId || "All companies"}`);
  console.log(`  Events: ${webhook.events.join(", ")}`); // These will be labels like "analysis.completed"
  console.log(`  Active: ${webhook.isActive}`);
  console.log(`  Failures: ${webhook.consecutiveFailures}`);
}
```

### Update a Webhook

```javascript theme={null}
await fetch(`https://api.instaview.sk/webhooks/${webhookId}`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://api.yourcompany.com/webhooks/instaview-v2",
    events: [
      "INTERVIEW_COMPLETED",
      "INTERVIEW_FAILED",
      "ANALYSIS_COMPLETED",
      "ANALYSIS_FAILED",
    ],
    isActive: true,
  }),
});
```

### Delete a Webhook

```javascript theme={null}
await fetch(`https://api.instaview.sk/webhooks/${webhookId}`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${apiKey}` },
});
```

### Test a Webhook

Send a test ping to verify connectivity:

```javascript theme={null}
const response = await fetch(
  `https://api.instaview.sk/webhooks/${webhookId}/test`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}` },
  },
);

const { data } = await response.json();

if (data.success) {
  console.log("Webhook test successful!");
  console.log(`Response time: ${data.durationMs}ms`);
} else {
  console.error("Webhook test failed:", data.message);
}
```

<Info>
  To test a webhook using the test endpoint above, it must be subscribed to the
  `ping` event.
</Info>

### Test Interview Events with Test Interviews

For more comprehensive testing of interview-related webhook events (`interview.completed`, `analysis.completed`), you can create test interviews using the `isTest` flag:

```javascript theme={null}
// Create a test interview that immediately completes
const testInterview = await fetch(
  "https://api.instaview.sk/interviews",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      candidateId: "candidate-uuid",
      agentId: "agent-uuid",
      isTest: true, // Creates test interview
    }),
  },
);

// This will trigger:
// 1. interview.completed webhook
// 2. analysis.completed webhook
// Both events will be sent to your webhook endpoint
```

Test interviews are useful for:

* Testing your webhook handler's processing logic for interview completion
* Verifying that analysis data is correctly parsed and stored
* End-to-end testing of your integration without waiting for real interviews
* Development and debugging without consuming billing minutes

See the [Create Interview](/api-reference/interviews/create-interview) documentation for more details on test mode.

### Reset Circuit Breaker

If your webhook was disabled due to consecutive failures:

```javascript theme={null}
await fetch(`https://api.instaview.sk/webhooks/${webhookId}/reset-circuit`, {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}` },
});
```

## Custom Headers

You can configure custom headers to authenticate with your endpoint:

```javascript theme={null}
{
  "url": "https://api.yourcompany.com/webhooks/instaview",
  "headers": [
    { "name": "Authorization", "value": "Bearer your-internal-token" },
    { "name": "X-Custom-Header", "value": "custom-value" }
  ],
  "events": ["INTERVIEW_COMPLETED", "ANALYSIS_COMPLETED"]
}
```

<Info>
  **Security**: Sensitive headers (Authorization, X-API-Key, X-Secret, API-Key)
  are automatically encrypted at rest. When listing webhooks, these header
  values are masked for security.
</Info>

## Best Practices

<CardGroup cols={2}>
  <Card title="Verify Signatures" icon="shield-check">
    Always verify the `X-Webhook-Signature` header to ensure requests are
    authentic
  </Card>

  <Card title="Respond Fast" icon="bolt">
    Return 200 immediately and process asynchronously for long-running tasks
  </Card>

  <Card title="Handle Duplicates" icon="clone">
    Use `deliveryId` for idempotency - you may receive the same event multiple
    times
  </Card>

  <Card title="Monitor Health" icon="heart-pulse">
    Check `consecutiveFailures` and `circuitOpenedAt` to catch issues early
  </Card>
</CardGroup>

## Troubleshooting

<AccordionGroup>
  <Accordion title="Webhook not receiving events" icon="circle-xmark">
    **Possible causes:** - Webhook is not subscribed to the event type - Webhook
    is disabled (`isActive: false`) - Circuit breaker is open (check
    `circuitOpenedAt`) **Solutions:** - Verify event subscriptions in webhook
    configuration - Check if webhook is active - Reset circuit breaker if needed
  </Accordion>

  <Accordion title="Signature verification failing" icon="key">
    **Possible causes:** - Using wrong signing secret - Modifying payload before
    verification - Encoding issues **Solutions:** - Verify you're using the
    original signing secret - Verify the raw request body, not parsed JSON -
    Ensure UTF-8 encoding
  </Accordion>

  <Accordion title="Webhook disabled automatically" icon="plug-circle-xmark">
    **Cause:** Too many consecutive failures triggered the circuit breaker
    **Solutions:** 1. Fix the underlying issue (endpoint availability,
    authentication, etc.) 2. Use the test endpoint to verify connectivity 3. Reset
    the circuit breaker
  </Accordion>

  <Accordion title="Missing events" icon="ghost">
    **Possible causes:** - Event occurred before webhook was configured - Event
    type not in subscription list **Note:** Webhooks only deliver events that
    occur after configuration. For historical data, use the API to poll for past
    events.
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="Webhook Resource" icon="webhook" href="/guides/resources/webhooks">
    Detailed webhook configuration reference
  </Card>

  <Card title="API Reference" icon="code" href="/api-reference/webhooks/list-webhooks">
    Complete webhook API documentation
  </Card>

  <Card title="Interviews" icon="microphone" href="/guides/resources/interviews">
    Learn about interview events and data
  </Card>

  <Card title="Error Handling" icon="triangle-exclamation" href="/guides/error-handling">
    Handle webhook delivery errors
  </Card>
</CardGroup>
