Insights Company

Webhooks

Receive real-time event notifications from Persona.

Persona sends a signed HTTP POST to your server when key interview milestones occur. Configure your endpoint and secret via your account manager.

To get started with webhooks, reach out to a member of the Persona team. We'll need your webhook URL to get you set up.

Events

Fired when a participant finishes an interview. Use user_interview_id to fetch the full transcript and a fresh recording link from the Interviews endpoint.

Payload

{
  "event": "interview_completed",
  "created_at": "2026-07-10T12:00:00Z",
  "data": {
    "org_id": "80a39f70-...",
    "project_id": "abc123-...",
    "interview_id": "f3a1b2c4-...",
    "user_interview_id": "e5d6c7b8-...",
    "video_url": "https://storage.persona.ai/recordings/e5d6c7b8-....mp4",
    "video_url_expires_at": "2026-07-11T12:00:00Z",
    "completed_at": "2026-07-10T12:00:00Z",
    "completed_participants": 42
  }
}

Data fields

FieldTypeDescription
org_idUUIDYour organisation ID
project_idUUIDThe project the interview belongs to
interview_idUUIDThe interview template ID
user_interview_idUUIDThe unique ID for this participant's session
video_urlstring | nullURL to the interview recording
video_url_expires_atISO 8601 | nullExpiry time for video_url; re-fetch by user_interview_id after this time
completed_atISO 8601 | nullTimestamp when the participant finished
completed_participantsinteger | nullTotal participants completed at the time of this event

See Interviews for the full transcript and metadata.

Fired when the number of completed participants reaches the project's target. Useful for triggering downstream analysis, exports, or team notifications.

Payload

{
  "event": "interview_target_reached",
  "created_at": "2026-07-10T12:00:00Z",
  "data": {
    "org_id": "80a39f70-...",
    "project_id": "abc123-...",
    "interview_id": "f3a1b2c4-...",
    "user_interview_id": "e5d6c7b8-...",
    "video_url": "https://storage.persona.ai/recordings/e5d6c7b8-....mp4",
    "video_url_expires_at": "2026-07-11T12:00:00Z",
    "completed_at": "2026-07-10T12:00:00Z",
    "completed_participants": 50,
    "target_participants": 50
  }
}

Data fields

FieldTypeDescription
org_idUUIDYour organisation ID
project_idUUIDThe project the interview belongs to
interview_idUUIDThe interview template ID
user_interview_idUUIDThe participant session that hit the target
video_urlstring | nullURL to the interview recording
video_url_expires_atISO 8601 | nullExpiry time for video_url; re-fetch by user_interview_id after this time
completed_atISO 8601 | nullTimestamp when the participant finished
completed_participantsintegerTotal participants completed at the time of this event
target_participantsintegerThe target that was reached

See Interviews for filtering and pagination options.

Signature verification

Every request includes an X-Persona-Signature header:

X-Persona-Signature: t=1720000000,v1=abc123...

t is a Unix timestamp. v1 is HMAC-SHA256(secret, "{t}.{raw_body}").

Always verify the signature before processing a webhook. Also reject requests where the timestamp is more than 5 minutes old to prevent replay attacks.

import hashlib
import hmac
import time

def verify_webhook(body: str, signature_header: str, secret: str) -> bool:
    t, v1 = signature_header.split(",")
    timestamp = t.split("=")[1]

    if abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1.split("=")[1])
import { createHmac } from "crypto";

function verifyWebhook(body: string, signatureHeader: string, secret: string): boolean {
  const [t, v1] = signatureHeader.split(",");
  const timestamp = t.split("=")[1];

  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
  return expected === v1.split("=")[1];
}

Pass body as the raw request body string — before any JSON parsing — otherwise the signature won't match.

Handling events

from flask import request, abort
import json

SECRET = "your_webhook_secret"

@app.route("/webhooks/persona", methods=["POST"])
def webhook():
    body = request.get_data(as_text=True)

    if not verify_webhook(body, request.headers.get("X-Persona-Signature", ""), SECRET):
        abort(401)

    payload = json.loads(body)

    if payload["event"] == "interview_completed":
        interview_id = payload["data"]["interview_id"]
        # fetch transcript or enqueue for async processing

    return "", 200
app.post("/webhooks/persona", express.text({ type: "*/*" }), (req, res) => {
  if (!verifyWebhook(req.body, req.headers["x-persona-signature"] as string, SECRET)) {
    return res.status(401).end();
  }

  const payload = JSON.parse(req.body);

  if (payload.event === "interview_completed") {
    const { interview_id } = payload.data;
    // fetch transcript or enqueue for async processing
  }

  res.status(200).end();
});

Retry behaviour

If your endpoint returns a non-2xx status or times out, Persona retries up to 3 times with exponential backoff (1s → 2s → 4s).

Respond within 5 seconds to avoid a timeout. For slow operations, return 200 OK immediately and process asynchronously.

Reliability

Webhook delivery is best-effort. For critical workflows, periodically reconcile using the Interview Records endpoint to catch any missed events.

On this page