Skip to main content

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: study_id, the participant, status, quality, messages, recording links, and screener responses.
1

Register a webhook

Call 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.
2

Receive interview data

When any interview in your account completes, your endpoint receives a POST request with the interview payload described below.
3

Stop receiving data

Call Delete Webhook with the same hook_url to unregister it.
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.

Delivery behavior

PropertyValue
MethodPOST
Content typeapplication/json
TriggerFired once per interview, after the interview is finalized
Timeout30 seconds — respond before then
RetriesNone. A non-2xx response or a timeout is logged and the interview is not re-delivered
Expected responseAny 2xx status. Acknowledge quickly and do heavy processing asynchronously
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.

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.
{
  "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

FieldTypeDescription
idstringUnique identifier of the interview
study_idstringIdentifier of the study this interview belongs to
participantobject | nullWho took the interview. null for anonymous panel interviews
participant.idstringParticipant identifier
participant.emailstring | nullParticipant email, when available
statusstringFinal status of the interview, e.g. completed
qualitystring | nullQuality label: Poor, Fair, Good, or Excellent
end_reasonstring | nullWhy the interview ended
started_atstring (date-time) | nullWhen the interview started
duration_secondsinteger | nullInterview length in seconds
messagesarray | nullTurn-by-turn messages exchanged during the interview
audio_recording_urlstring | nullLink to the audio recording, when available
video_recording_urlstring | nullLink to the video recording, for video-mode studies
screener_responsesarray | nullParticipant’s screener answers as { question, type, answer }
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.

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:
HeaderDescription
X-UI-TimestampUnix timestamp (seconds) when the request was signed
X-UI-Signaturesha256=<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.
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)
1

Verify the signature

Reject any request whose X-UI-Signature doesn’t match your recomputed HMAC.
2

Check the timestamp

Reject requests with an X-UI-Timestamp outside a small window (e.g. 5 minutes) to prevent replay attacks.
3

Use HTTPS

Always register an https:// URL so the payload is encrypted in transit.
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.

Managing webhooks

Create Webhook

Register a hook_url to receive completed interviews for a study.

Delete Webhook

Stop sending completed interviews to a hook_url.