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

# Webhooks

### Specifying a webhook URL

When creating an interview flow you can specify a `webhook_url`. This can be used to receive updates related to interviews.

<CodeGroup>
  ```bash bash theme={null}
  curl --request POST \
       --url https://app.ribbon.ai/be-api/v1/interview-flows \
       --header 'accept: application/json' \
       --header 'authorization: Bearer <your-api-key-here>' \
       --header 'content-type: application/json' \
       --data '
  {
    "webhook_url": "https://example.com",
    "org_name": "Example Org Name",
    "title": "Example Title",
    "questions": [
      "Example Question 1?",
      "Example Question 2?"
    ]
  }
  '
  ```
</CodeGroup>

### Webhook Authentication

You can specify a `webhook_secret_key` to verify that webhook requests are genuinely from Ribbon. When a secret key is configured, Ribbon will include an `X-Ribbon-Signature` header (also accepted as `x-ribbon-signature`) with each webhook request.

The signature is an **HMAC-SHA256** hash of the **raw JSON request body**, using your webhook secret key, encoded as a **64-character hex string**. Ribbon never sends the secret key in the webhook request — you configure it in your interview settings and use the same value on your server to verify signatures.

#### Example

Given this webhook secret key configured in your interview settings:

```
myGoodSecret
```

And this raw request body:

<CodeGroup>
  ```json json theme={null}
  {"interview_flow_id": "f6298c77", "interview_id": "e48f2a8f-e235-4e4c-b5f9-b2114d684bdc", "status": "completed", "interview_flow_name": "Team 1 interview", "event_type": "interview_processed", "interviewee_email_address": "anne@ribbon.ai", "interviewee_first_name": "Anne", "interviewee_last_name": "Anquetin0", "interview_link": "https://staging.ribbon.ai/recruit/interviews/f6298c77/a5bc36d2-c22c-4bae-9d08-0b941738924e"}
  ```
</CodeGroup>

Ribbon sends a request with headers like:

```
Content-Type: application/json
User-Agent: Ribbon-API-Webhook/1.0
x-ribbon-signature: bdae121de5d94dffe936ec3337b0395a4237a6d2433bbd0bc2941883e5667d18
```

To verify, compute HMAC-SHA256 of the exact raw body string using `myGoodSecret` and compare the result to the header value. The signature must match byte-for-byte — use the raw request body as received, not a re-serialized or pretty-printed version of the JSON.

#### Verifying the signature

1. Read the raw request body as a string (before parsing JSON)
2. Compute HMAC-SHA256 using your webhook secret key and the raw body
3. Hex-encode the result and compare it to the `X-Ribbon-Signature` header value using a constant-time comparison

#### Code examples

**Python**

<CodeGroup>
  ```python python theme={null}
  import hmac
  import hashlib

  def verify_webhook_signature(body: str, secret: str, signature: str) -> bool:
      expected = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature)

  body = '{"interview_flow_id": "f6298c77", "interview_id": "e48f2a8f-e235-4e4c-b5f9-b2114d684bdc", "status": "completed", "interview_flow_name": "Team 1 interview", "event_type": "interview_processed", "interviewee_email_address": "anne@ribbon.ai", "interviewee_first_name": "Anne", "interviewee_last_name": "Anquetin0", "interview_link": "https://staging.ribbon.ai/recruit/interviews/f6298c77/a5bc36d2-c22c-4bae-9d08-0b941738924e"}'
  secret = "myGoodSecret"
  signature = "bdae121de5d94dffe936ec3337b0395a4237a6d2433bbd0bc2941883e5667d18"

  verify_webhook_signature(body, secret, signature)  # True
  ```
</CodeGroup>

**TypeScript**

<CodeGroup>
  ```typescript typescript theme={null}
  import crypto from "crypto";

  function verifyWebhookSignature(body: string, secret: string, signature: string): boolean {
    const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  }

  const body = '{"interview_flow_id": "f6298c77", "interview_id": "e48f2a8f-e235-4e4c-b5f9-b2114d684bdc", "status": "completed", "interview_flow_name": "Team 1 interview", "event_type": "interview_processed", "interviewee_email_address": "anne@ribbon.ai", "interviewee_first_name": "Anne", "interviewee_last_name": "Anquetin0", "interview_link": "https://staging.ribbon.ai/recruit/interviews/f6298c77/a5bc36d2-c22c-4bae-9d08-0b941738924e"}';
  const secret = "myGoodSecret";
  const signature = "bdae121de5d94dffe936ec3337b0395a4237a6d2433bbd0bc2941883e5667d18";

  verifyWebhookSignature(body, secret, signature); // true
  ```
</CodeGroup>

#### Security best practices

* **Always verify signatures** in production to ensure webhooks are from Ribbon
* **Use a strong, random secret key** (at least 32 characters recommended)
* **Use constant-time comparison** functions (like `hmac.compare_digest` in Python or `crypto.timingSafeEqual` in TypeScript) to prevent timing attacks
* **Keep your secret key secure** and never expose it in client-side code
* **Verify the raw request body** — parsing and re-stringifying JSON will produce a different signature

### Static IP allowlisting

By default, webhook requests may originate from dynamic IP addresses. If your server requires IP allowlisting, contact your Ribbon account team to enable **static IP webhook delivery** for your organization.

When static IP delivery is enabled, all webhook requests to your endpoint will originate from a fixed egress IP address that you can add to your firewall allowlist. Ribbon still recommends verifying the `X-Ribbon-Signature` header on every request — signature verification is the most reliable way to authenticate webhooks.

### Webhook payload

The webhook payload returns the `interview_flow_id`, `interview_id`, `interview_link` and `status`.

⚠️ `interviewee_email_address`, `interviewee_first_name` and `interviewee_last_name` will be null unless the candidate information were provided when creating the interview session (`POST interviews`) or if the candidate went through the full platform flow where candidate information is requested.

We currently support three webhook event types: `interview_processed`, `video_processed`, and `candidate_status_updated`

### Webhook event types

`interview_processed` This event occurs after an interview has been completed and the interview has been successfully processed and analyzed by Ribbon. If video was collected during the interview the **video will not be available yet**.

#### Payload example

<CodeGroup>
  ```bash bash theme={null}
  {
    "event_type": "interview_processed",  
    "interview_flow_id": "d1a104e7",
    "interview_id": "7ada85b2-b8a6-4e4a-84ed-f1c25fa63843",
    "interview_link": "https://app.ribbon.ai/recruit/interviews/d1a104e7/9ae73ba9-1e8c-4d73-8454-478d7674ea97",
    "interview_flow_name": "Manager position role interview",
    "interviewee_email_address": "[email protected]",
    "interviewee_first_name": "John",
    "interviewee_last_name": "Doe",
    "status": "completed",
  }
  ```
</CodeGroup>

`video_processed` This event only occurs if you are collecting video during the interview (`is_video_enabled` is `true`). The event is sent after video processing is complete and when the `video_url` is available.

#### Payload example

<CodeGroup>
  ```bash bash theme={null}
  {
    "event_type": "video_processed",  
    "interview_flow_id": "d1a104e7",
    "interview_id": "7ada85b2-b8a6-4e4a-84ed-f1c25fa63843",
    "interview_link": "https://app.ribbon.ai/recruit/interviews/d1a104e7/9ae73ba9-1e8c-4d73-8454-478d7674ea97",
    "interview_flow_name": "Manager position role interview",
    "interviewee_email_address": "[email protected]",
    "interviewee_first_name": "John",
    "interviewee_last_name": "Doe",
    "status": "completed",
  }
  ```
</CodeGroup>

`candidate_status_updated`

This event occurs anytime a candidate’s status changes within the candidate pipeline (for example: shortlisted → accepted). The event includes both the previous status and the new status so you can track transitions precisely.

#### Payload example

<CodeGroup>
  ```bash bash theme={null}
  {
    "event_type": "candidate_status_updated",
    "interview_flow_id": "7b896044",
    "interview_flow_name": "Backend Engineer",
    "interviewee_email": "[email protected]",
    "new_status": "accepted",
    "previous_status": "shortlisted"
  }
  ```
</CodeGroup>

### Webhook event ordering

You will explicitly always receive the events in this order:

* `interview_processed`
* `video_processed`

***

[Supported Voices](/docs/supported-voices)
