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
| Field | Type | Description |
|---|---|---|
org_id | UUID | Your organisation ID |
project_id | UUID | The project the interview belongs to |
interview_id | UUID | The interview template ID |
user_interview_id | UUID | The unique ID for this participant's session |
video_url | string | null | URL to the interview recording |
video_url_expires_at | ISO 8601 | null | Expiry time for video_url; re-fetch by user_interview_id after this time |
completed_at | ISO 8601 | null | Timestamp when the participant finished |
completed_participants | integer | null | Total 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
| Field | Type | Description |
|---|---|---|
org_id | UUID | Your organisation ID |
project_id | UUID | The project the interview belongs to |
interview_id | UUID | The interview template ID |
user_interview_id | UUID | The participant session that hit the target |
video_url | string | null | URL to the interview recording |
video_url_expires_at | ISO 8601 | null | Expiry time for video_url; re-fetch by user_interview_id after this time |
completed_at | ISO 8601 | null | Timestamp when the participant finished |
completed_participants | integer | Total participants completed at the time of this event |
target_participants | integer | The 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 "", 200app.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.