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

> Subscribe to interview, study, and report events at your own HTTPS endpoint.

## Overview

Webhooks send an HTTP `POST` to your `hook_url` when subscribed events occur.
Choose `interview.completed`, `study.completed`, `report.ready`, `study.paused`,
`study.resumed`, and/or `study.stopped` in `event_types`. Omit `study_id` for an account-wide webhook, or include an owned
`study_id` to receive events for one study. The default subscription is
`interview.completed`.

Each subscribed event uses an envelope with `id`, `type`, `study_id`, and `data`.
For `interview.completed`, `data` has the **same public shape** returned by
[Get Interview](/api-reference/public-interviews/get-interview), including the
participant, quality, messages, recording links, and screener responses.

<Steps>
  <Step title="Register a webhook">
    Call [Create Webhook](/api-reference/public-webhooks/create-webhook) with the `hook_url`
    that should receive events, with optional `study_id` and `event_types`. The
    response includes a `signing_secret` — store it securely to verify signatures.
    Send an `Idempotency-Key` to recover the same response after a timeout without
    registering a second webhook. The same-key response can be replayed for 30
    days while the webhook and secret remain current; ordinary reads hide the
    secret.
  </Step>

  <Step title="Receive interview data">
    Your endpoint receives a signed `POST` when a subscribed event occurs. The
    interview payload is described below; study and report events use smaller data objects.
  </Step>

  <Step title="Stop receiving data">
    Delete the webhook by its `id` to stop further deliveries. The URL-based
    delete endpoint remains available for account-wide registrations.
  </Step>
</Steps>

<Note>
  List and get operations return webhook IDs, scopes, event types, and URLs without
  revealing stored signing secrets. Use secret rotation when the original secret
  is lost or needs replacement; the new secret appears only in the rotation response.
</Note>

## Delivery behavior

| Property | Value |
| - | - |
| Method | `POST` |
| Content type | `application/json` |
| Trigger | The subscribed interview, study lifecycle, or report event |
| Timeout | 30 seconds — respond before then |
| Retries | Non-`2xx` responses and network failures retry with exponential backoff, up to 24 attempts |
| Expected response | Any `2xx` status. Acknowledge quickly and do heavy processing asynchronously |

Return `2xx` promptly and deduplicate using the stable event `id`: a timed-out
delivery may have reached your receiver before it is retried. Inspect
`/webhooks/{webhook_id}/deliveries` for status, timing, and coarse failure
reasons. The history does not store payloads.

## Payload

Subscribed events use `{id, type, study_id, data}`. For `interview.completed`, `data`
is the completed interview in the same public shape as `GET /interviews/{id}`.
The participant is nested under `data.participant`.

```json theme={null}
{
  "id": "a1b2c3d4-0000-0000-0000-000000000000",
  "type": "interview.completed",
  "study_id": "11111111-2222-3333-4444-555555555555",
  "data": {
  "id": "a1b2c3d4-0000-0000-0000-000000000000",
  "study_id": "11111111-2222-3333-4444-555555555555",
  "participant": {
    "id": "66666666-7777-8888-9999-000000000000",
    "email": "jordan@example.com"
  },
  "participant_source": "byop",
  "status": "completed",
  "quality": "Good",
  "ended_because": "participant_left",
  "started_at": "2026-06-30T14:02:11Z",
  "duration_seconds": 577,
  "messages": [
    { "id": "turn_0", "role": "moderator", "message": "Thanks for joining..." },
    { "id": "turn_1", "role": "participant", "message": "Happy to help..." }
  ],
  "total_message_segments": 2,
  "next_message_offset": null,
  "audio_recording_url": "https://.../recording.mp3",
  "video_recording_url": null,
  "screener_responses": [
    { "question": "What is your role?", "type": "single_select", "answer": "Product Manager" }
  ]
  }
}
```

`study.completed` is emitted when its active target cycle reaches its target. Each
newly persisted report has its own `report.ready` event ID.

```json theme={null}
{"id":"report-id","type":"report.ready","study_id":"study-id","data":{"report_id":"report-id"}}
```

The `study.completed` envelope has the same top-level fields, with a cycle ID
as `id` and `data.completed_at` instead of `data.report_id`. A test ping uses
`{"type":"webhook.test","id":"webhook-id"}` and contains no participant or
study data. Test pings use the same signing headers and appear in delivery history.

Public API pause, resume, and stop calls emit `study.paused`, `study.resumed`,
and `study.stopped` after the transition succeeds. Their `data` contains only
`study_id` and the resulting `fielding_status`. Each transition receives a new
event ID; deduplicate redeliveries by that ID. These lifecycle events currently
cover the public API routes, not dashboard transitions.

### Fields

| Field | Type | Description |
| - | - | - |
| `id` | string | Stable event identifier; the interview ID for `interview.completed` |
| `type` | string | Subscribed event type |
| `study_id` | string | Identifier of the study this event belongs to |
| `data` | object | Public event data; the following interview fields are inside `data` |
| `data.id` | string | Unique identifier of the interview |
| `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 |
| `participant_source` | string | `byop`, `panel`, `external_panel`, `link`, or `unknown`. `participant` is null for anonymous sources |
| `status` | string | Final status of the interview, e.g. `completed` |
| `quality` | string \| null | Quality label: `Poor`, `Fair`, `Good`, or `Excellent` |
| `ended_because` | string \| null | Normalized reason: `completed`, `participant_left`, `time_limit`, `technical_error`, or `screened_out`; null when unknown |
| `started_at` | string (date-time) \| null | When the interview started |
| `duration_seconds` | integer \| null | Interview length in seconds |
| `messages` | array \| null | Spoken turns with `id`, `role` (`moderator` or `participant`), and `message`; system prompts and tool calls are excluded |
| `total_message_segments` | integer | Number of spoken turns in the webhook payload |
| `next_message_offset` | integer \| null | Always null in completion webhooks, which include the full spoken transcript |
| `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, call the secret-rotation endpoint and update your
  receiver with the new secret. Legacy webhooks created before signing was introduced are delivered
  **without** signature headers.
</Warning>

## Managing webhooks

Use `GET /api/public/v1/webhooks/` to find webhook IDs and
`GET /api/public/v1/webhooks/{webhook_id}` to inspect one registration. Neither
response includes its stored signing secret. Use
`POST /api/public/v1/webhooks/{webhook_id}/rotate-secret` to replace a lost or
compromised secret; update the receiver with the new one immediately.

Use `POST /api/public/v1/webhooks/{webhook_id}/test` to send a signed, data-free
test event. Check `GET /api/public/v1/webhooks/{webhook_id}/deliveries` for recent
attempts (`page` and `page_size` are bounded). Delete by ID with
`DELETE /api/public/v1/webhooks/{webhook_id}`. Repeating the delete returns 404.
The older `DELETE /api/public/v1/webhooks/` operation accepts a JSON body and is deprecated. New integrations should use the ID route.

<CardGroup cols={2}>
  <Card title="Create Webhook" icon="webhook" href="/api-reference/public-webhooks/create-webhook">
    Register a URL, optional study scope, and event subscriptions.
  </Card>

  <Card title="Delete Webhook" icon="trash" href="/api-reference/public-webhooks/delete-webhook-by-id">
    Stop sending completed interviews to a registered webhook ID.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.