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

# Webhooks

> Receive completed-interview data at your own endpoint as soon as an interview finishes.

## Overview

Webhooks let you receive interview data in real time. After you register a webhook,
User Intuition sends an HTTP `POST` request to your `hook_url` every time **any**
interview in your account is **completed** — no polling required. A webhook is
**account-wide**: it covers every study, so you register it once, not per study.

Each request contains the completed interview in the **same public shape** returned
by [Get Interview](/api-reference/interviews/get-interview): `study_id`, the
`participant`, `status`, `quality`, `messages`, recording links, and screener
responses.

<Steps>
  <Step title="Register a webhook">
    Call [Create Webhook](/api-reference/webhooks/create-webhook) with the `hook_url`
    that should receive completed interviews. There is no `study_id` — the webhook
    covers your whole account. The response includes a `signing_secret` (shown
    **once**) — store it securely to verify signatures.
  </Step>

  <Step title="Receive interview data">
    When any interview in your account completes, your endpoint receives a `POST`
    request with the interview payload described below.
  </Step>

  <Step title="Stop receiving data">
    Call [Delete Webhook](/api-reference/webhooks/delete-webhook) with the same
    `hook_url` to unregister it.
  </Step>
</Steps>

<Note>
  Webhooks are account-wide: one registration receives completed interviews for
  every study in your account. You can register more than one `hook_url`, and each
  receives a copy of every completed interview. Use the payload's `study_id` to tell
  which study an interview belongs to.
</Note>

## Delivery behavior

| Property          | Value                                                                                           |
| ----------------- | ----------------------------------------------------------------------------------------------- |
| Method            | `POST`                                                                                          |
| Content type      | `application/json`                                                                              |
| Trigger           | Fired once per interview, after the interview is finalized                                      |
| Timeout           | 30 seconds — respond before then                                                                |
| Retries           | **None.** A non-`2xx` response or a timeout is logged and the interview is **not** re-delivered |
| Expected response | Any `2xx` status. Acknowledge quickly and do heavy processing asynchronously                    |

<Warning>
  Because there are no automatic retries, your endpoint should return a `2xx`
  response as fast as possible and queue any slow work for background processing.
  If your endpoint is down when an interview completes, that delivery is lost.
</Warning>

## Payload

The request body is the completed interview, using the same public field names as
the rest of the API. The participant who took the interview is nested under
`participant`.

```json theme={null}
{
  "id": "a1b2c3d4-0000-0000-0000-000000000000",
  "study_id": "11111111-2222-3333-4444-555555555555",
  "participant": {
    "id": "66666666-7777-8888-9999-000000000000",
    "email": "jordan@example.com"
  },
  "status": "completed",
  "quality": "Good",
  "end_reason": "customer-ended-call",
  "started_at": "2026-06-30T14:02:11Z",
  "duration_seconds": 577,
  "messages": [
    { "role": "assistant", "content": "Thanks for joining..." },
    { "role": "user", "content": "Happy to help..." }
  ],
  "audio_recording_url": "https://.../recording.mp3",
  "video_recording_url": null,
  "screener_responses": [
    { "question": "What is your role?", "type": "single_select", "answer": "Product Manager" }
  ]
}
```

### Fields

| Field                 | Type                       | Description                                                    |
| --------------------- | -------------------------- | -------------------------------------------------------------- |
| `id`                  | string                     | Unique identifier of the interview                             |
| `study_id`            | string                     | Identifier of the study this interview belongs to              |
| `participant`         | object \| null             | Who took the interview. `null` for anonymous panel interviews  |
| `participant.id`      | string                     | Participant identifier                                         |
| `participant.email`   | string \| null             | Participant email, when available                              |
| `status`              | string                     | Final status of the interview, e.g. `completed`                |
| `quality`             | string \| null             | Quality label: `Poor`, `Fair`, `Good`, or `Excellent`          |
| `end_reason`          | string \| null             | Why the interview ended                                        |
| `started_at`          | string (date-time) \| null | When the interview started                                     |
| `duration_seconds`    | integer \| null            | Interview length in seconds                                    |
| `messages`            | array \| null              | Turn-by-turn messages exchanged during the interview           |
| `audio_recording_url` | string \| null             | Link to the audio recording, when available                    |
| `video_recording_url` | string \| null             | Link to the video recording, for video-mode studies            |
| `screener_responses`  | array \| null              | Participant's screener answers as `{ question, type, answer }` |

<Note>
  Anonymous panel interviews have no identifiable participant, so `participant` is
  `null` for them. Internal columns (transcripts of the raw call, internal IDs, and
  the like) are never included — the webhook delivers exactly the public interview
  shape.
</Note>

## Authentication

Every delivery is **signed** so you can verify it genuinely came from User Intuition.
When you register a webhook, the response includes a `signing_secret` (prefixed
`whsec_`, shown **once**). Each request carries two headers:

| Header           | Description                                                                                       |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| `X-UI-Timestamp` | Unix timestamp (seconds) when the request was signed                                              |
| `X-UI-Signature` | `sha256=<hex>` — HMAC-SHA256 of `"<X-UI-Timestamp>.<raw body>"`, keyed with your `signing_secret` |

### Verifying a signature

Recompute the HMAC over `"<timestamp>.<raw request body>"` using your stored
`signing_secret` and compare it to `X-UI-Signature` in constant time. Use the
**raw request body bytes** — do not re-serialize the parsed JSON, or the signature
won't match.

```python theme={null}
import hmac, hashlib, time

def verify(request_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    # Reject stale timestamps to prevent replay (5-minute window).
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "sha256=" + hmac.new(
        secret.encode(), f"{timestamp}.".encode() + request_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

<Steps>
  <Step title="Verify the signature">
    Reject any request whose `X-UI-Signature` doesn't match your recomputed HMAC.
  </Step>

  <Step title="Check the timestamp">
    Reject requests with an `X-UI-Timestamp` outside a small window (e.g. 5 minutes)
    to prevent replay attacks.
  </Step>

  <Step title="Use HTTPS">
    Always register an `https://` URL so the payload is encrypted in transit.
  </Step>
</Steps>

<Warning>
  Store the `signing_secret` securely — it is shown only once, at creation. If you
  lose it or need to rotate it, delete the webhook and register it again to get a
  new secret. Legacy webhooks created before signing was introduced are delivered
  **without** signature headers.
</Warning>

## Managing webhooks

<CardGroup cols={2}>
  <Card title="Create Webhook" icon="webhook" href="/api-reference/webhooks/create-webhook">
    Register a `hook_url` to receive completed interviews for a study.
  </Card>

  <Card title="Delete Webhook" icon="trash" href="/api-reference/webhooks/delete-webhook">
    Stop sending completed interviews to a `hook_url`.
  </Card>
</CardGroup>
