Webhooks¶
Rather than polling /interview/details/{id} after every candidate, register a
webhook endpoint and Allps will POST the finished result to you. This is the
recommended integration pattern.
┌────────────────┐ ┌──────────────┐ ┌─────────────┐
│ YOUR APP │ │ Allps │ │ CANDIDATE │
└───────┬────────┘ └──────┬───────┘ └──────┬──────┘
│ │ │
│ 1. Register │ │
│ POST /webhooks/ │ │
│ endpoints │ │
│ { url, name } │ │
├───────────────────────►│ │
│ │ │
│◄───────────────────────┤ │
│ 201 { id, secret, … } │ │
│ │ │
│ Store secret in a │ │
│ secret manager. │ │
│ │ │
│ │ │
│ 2. Send interview link (out-of-band) │
│ │ │
├────────────────────────────────────────────────►
│ │ │
│ │ 3. Candidate takes │
│ │ the interview │
│ │◄─────────────────────►│
│ │ │
│ │ • Finalise interview │
│ │ • Generate eval │
│ │ • Archive media │
│ │ │
│ │ │
│ 4. Delivery │ │
│ │ │
│ │ POST /your-url │
│ │ event = │
│ │ interview.media_ │
│ │ processed │
│◄───────────────────────┤ │
│ │ │
│ • Verify signature │ │
│ • Dedupe by event_id │ │
│ • Return 200 quickly │ │
│ │ │
├───────────────────────►│ │
│ 200 OK │ │
│ │ │
1. Discover the contract¶
The catalogue endpoint returns a JSON description of every event and interview kind, plus a fully-formed sample payload for each.
curl https://sandbox.api.tara.allps.ai/webhooks/catalog \
-H "x-application-secret-key: sk_int_your_key_here"
{
"schema_version": "1.0",
"events": [
{ "event": "interview.media_processed", "description": "…" },
{ "event": "webhook.test", "description": "…" }
],
"interview_types": {
"job_interview": 0,
"screening_interview": 1,
"video_introduction": 2
},
"interview_kinds": ["job_interview", "screening_interview", "video_introduction"],
"signature_header": "X-Webhook-Signature",
"payload_contracts": [ /* one per interview type */ ],
"sample_payloads": { /* full examples */ }
}
2. Register an endpoint¶
{
"url": "https://your-app.example.com/hooks/sonny",
"name": "Production pipeline",
"interview_type": null,
"event_types": null,
"is_active": true
}
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string (≤ 2048) | yes | Absolute http(s) URL. Must be a well-formed URL. |
name |
string (≤ 120) | no | Label to tell endpoints apart. Default "Default webhook". |
description |
string | null | no | Free-form note. |
interview_type |
integer | null | no | Restrict delivery to one interview type (0/1/2). |
event_types |
string[] | null | no | Restrict to a subset of events. Currently: ["interview.media_processed"]. |
is_active |
boolean | no | Inactive endpoints are skipped. Default true. |
The 201 response contains the one-time secret:
{
"id": "…",
"url": "https://your-app.example.com/hooks/sonny",
"name": "Production pipeline",
"is_active": true,
"secret_masked": "whsec_…",
"secret": "whsec_<32-char-token>"
}
The secret is shown exactly once
Please store it in your end. If you lose it, you can it rotate via
POST /webhooks/endpoints/{id}/rotate-secret.
Endpoint management¶
| Method | Path | Purpose |
|---|---|---|
GET |
/webhooks/endpoints |
List your endpoints. Query: is_active, limit, offset. |
GET |
/webhooks/endpoints/{id} |
Fetch one endpoint. |
PATCH |
/webhooks/endpoints/{id} |
Partial update. Omitted fields are left untouched. |
DELETE |
/webhooks/endpoints/{id} |
Delete. Delivery history is retained for auditing. Returns 204. |
POST |
/webhooks/endpoints/{id}/rotate-secret |
Issue a new secret. The previous one stops working immediately. |
POST |
/webhooks/endpoints/{id}/test |
Deliver a synthetic webhook.test event inline and return the result. |
3. When events fire¶
interview.media_processed is emitted as the final step of the internal
/interview/media/processed flow, after the interview has been finalised,
archived and synced upstream.
4. The payload contract¶
The envelope is identical for every interview type. Only evaluation (and,
for screening, communication_analysis) differs.
{
"schema_version": "1.0",
"event": "interview.media_processed",
"event_id": "interview.media_processed:<interview-id>",
"occurred_at": "2025-06-01T12:00:00+00:00",
"interview": {
"id": "…",
"kind": "job_interview",
"interview_type": 0,
"language_code": 1,
"state": 2,
"ending_reason": 1,
"archival_status": 1,
"duration_secs": 600,
"created_at": "2025-06-01T11:00:00+00:00",
"user_id": "…",
"job_info_id": "…",
"has_video": true
},
"media": {
"audio_url": "https://…/_recording.mp3",
"video_url": "https://…/_video_recording.webm"
},
"transcript": [ /* TranscriptEntry[] */ ],
"evaluation": { /* varies by kind — see below */ },
"communication_analysis": null,
"metrics": {
"attempted_questions_count": 3,
"transcript_turns": 24
}
}
4.1 evaluation per interview kind¶
{
"kind": "job_interview",
"overall_summary": {
"overall_assessment": {
"suitability": "Meets expectations",
"strengths": [ "…" ],
"weaknesses": [ "…" ],
"recommendations": [ "…" ]
}
},
"primary_tech_focus": "Distributed Systems",
"experience_level": "senior",
"questions": [
{
"question": "…",
"question_type": "technical",
"transcript": [ { "interviewer": "…" }, { "user": "…" } ],
"evaluation": { "rating": "GOOD", "improvement_points": [] },
"video_timeline": { "begin": 10, "end": 40, "formatted": "00:10 - 00:40" }
}
],
"summary": { "total_questions": 3, "evaluated_questions": 3 }
}
{
"kind": "screening_interview",
"overall_summary": null,
"primary_tech_focus": null,
"experience_level": null,
"questions": [ /* every question, original order */ ],
"qualifying_questions": [ /* graded subset */ ],
"information_questions": [ /* recorded subset */ ],
"summary": {
"total_questions": 2,
"qualifying_questions": 1,
"information_questions": 1,
"evaluated_questions": 1
}
}
Each entry additionally carries category, is_evaluated and
eval_criteria. The top-level communication_analysis object is populated
for screening interviews only.
evaluation is null. The event is a pure media hand-off: use the
transcript and media blocks only.
Same envelope, different contents
Every payload has the same top-level keys, so your receiver can validate a
single schema and branch only on interview.kind. Missing data is
signalled by null, never by an absent key.
5. Verifying deliveries¶
Every request is signed with HMAC-SHA256 over the string "{timestamp}.{body}".
| Header | Value |
|---|---|
X-Webhook-Signature |
sha256=<hex-digest> |
X-Webhook-Timestamp |
Unix seconds when the signature was generated |
X-Webhook-Delivery-Id |
The delivery.id — quote it in support requests |
X-Webhook-Event |
The event name, e.g. interview.media_processed |
Here is a minimal, complete local receiver for testing the webhook delievries. Run it with your signing secret to see exactly what Allps sends:
import argparse
import json
import os
import sys
import time
from collections.abc import Mapping
from hashlib import sha256
import hmac
import aiofiles
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from loguru import logger
# ---------------------------------------------------------------------------
# HMAC verification
# ---------------------------------------------------------------------------
SIGNATURE_HEADER = "x-webhook-signature"
TIMESTAMP_HEADER = "x-webhook-timestamp"
MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60
class WebhookVerificationError(Exception):
"""Raised when a delivery cannot be verified."""
def verify_webhook_signature(
secret: str,
raw_body: bytes,
headers: Mapping[str, str],
*,
max_skew_seconds: int = MAX_TIMESTAMP_SKEW_SECONDS,
) -> None:
"""HMAC-SHA256 over '{timestamp}.{body}' to verify the webhook"""
server_signature = headers.get(SIGNATURE_HEADER)
server_timestamp = headers.get(TIMESTAMP_HEADER)
if not server_signature:
raise WebhookVerificationError(f"Missing {SIGNATURE_HEADER} header")
if not server_timestamp:
raise WebhookVerificationError(f"Missing {TIMESTAMP_HEADER} header")
if max_skew_seconds is not None:
try:
age = abs(int(time.time()) - int(server_timestamp))
except ValueError:
raise WebhookVerificationError(f"Invalid {TIMESTAMP_HEADER}: {server_timestamp!r}")
if age > max_skew_seconds:
raise WebhookVerificationError(f"Timestamp too old ({age}s)")
signature_bytes = server_timestamp.encode("utf-8") + b"." + raw_body
expected_signature = "sha256=" + hmac.new(secret.encode("utf-8"), signature_bytes, sha256).hexdigest()
if not hmac.compare_digest(expected_signature, server_signature):
raise WebhookVerificationError("Signature mismatch")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Local webhook receiver for verifying Sonny outbound deliveries.",
)
parser.add_argument(
"--secret",
required=True,
help=(
"Signing secret returned by POST /webhooks/endpoints at registration time. "
"It is shown only once — store it, then pass it here."
),
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="Interface to bind (default: 0.0.0.0). Use 127.0.0.1 to bound to loclahost.",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port to bind (default: 8000).",
)
parser.add_argument(
"--output-dir",
default=os.path.expanduser("~"),
help="Directory to save received payloads (default: ~).",
)
parser.add_argument(
"--max-skew",
type=int,
default=MAX_TIMESTAMP_SKEW_SECONDS,
help=("Reject deliveries older than this many seconds (default: 300). Pass 0 to disable this check."),
)
return parser.parse_args()
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
def build_app(secret: str, output_dir: str, max_skew: int | None) -> FastAPI:
app = FastAPI(title="Sonny webhook tester")
@app.post("/webhook-test")
async def receive(req: Request):
raw_body = await req.body()
data = json.loads(raw_body)
int_type = data["interview"]["kind"]
int_id = data["interview"]["id"]
filename = os.path.join(output_dir, f"webhook_delivery_{int_type}_{int_id}.json.txt")
try:
verify_webhook_signature(secret, raw_body, req.headers, max_skew_seconds=max_skew) # ty: ignore[invalid-argument-type]
except WebhookVerificationError as exc:
logger.error(f"Webhook rejected: {exc}")
raise HTTPException(status_code=401, detail=str(exc))
async with aiofiles.open(filename, mode="w") as f:
await f.write("HEADERS:\n" + str(req.headers) + "\n\nBODY:\n" + json.dumps(data, indent=4))
logger.info(f"Signature verified. Payload saved to {filename}")
return {"status": "ok", "event": req.headers.get("x-webhook-event")}
return app
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
args = parse_args()
secret = args.secret.strip()
if not secret:
print("Error: --secret must not be empty.", file=sys.stderr)
sys.exit(2)
os.makedirs(args.output_dir, exist_ok=True)
max_skew = None if args.max_skew == 0 else args.max_skew
logger.info(f"Starting webhook receiver with secret prefix '{secret[:10]}…'")
logger.info(f"Saving payloads to: {args.output_dir}")
app = build_app(secret=secret, output_dir=args.output_dir, max_skew=max_skew)
uvicorn.run(app, host=args.host, port=args.port)
"""
Sample Usage:
# Bind to all interfaces so a remote instance can reach you
python test_wh_delivery.py --secret "whsec_..." --host 0.0.0.0 --port 9000
# Drop payloads somewhere tidier
python test_wh_delivery.py --secret "whsec_..." --output-dir ./received
# Disable the replay window (useful when replaying a stored delivery by hand)
python test_wh_delivery.py --secret "whsec_..." --max-skew 0
"""
Sign the raw bytes
Do not re-serialise the JSON before verifying — the sender signs the exact bytes it transmits. Read the raw request body and pass it directly to the verification function.
Idempotency key
Use event_id from the body as a de-duplication key. The value is
deterministic per (event, interview_id), so a redelivery cannot create
duplicate records on your side.
6. Delivery semantics¶
- Retries. 4xx responses are permanent, except
408,409,425, and429. Network errors, timeouts and 5xx are retried with exponential backoff up toWEBHOOK_MAX_ATTEMPTS(default 3). - De-duplication. Re-enqueuing an event whose delivery already succeeded is
a no-op. A repeated
/interview/media/processedcall cannot double-notify your endpoint. - Ordering. Deliveries are dispatched sequentially, so per-endpoint ordering is deterministic.
- Audit. Every attempt is persisted with
attempts,response_statusanderror, so a failure can be diagnosed and replayed byte-for-byte.
Best practice: respond fast, process later
Your receiver should verify the signature, enqueue the payload for
asynchronous processing, and return 200 within a few hundred
milliseconds. If you do heavy work inline you risk a timeout, which may
trigger a retry against a payload you've already started processing.
7. Inspecting and replaying deliveries¶
Query filters: endpoint_id, interview_id, status
(0=pending, 1=delivering, 2=success, 3=failed), limit, offset.
[
{
"id": "…",
"endpoint_id": "…",
"interview_id": "…",
"event": "interview.media_processed",
"event_id": "interview.media_processed:…",
"target_url": "https://your-app.example.com/hooks/sonny",
"status": 3,
"status_name": "FAILED",
"attempts": 3,
"max_attempts": 3,
"response_status": 500,
"error": "HTTP 500: …",
"created_at": "2025-06-01T12:05:00Z",
"delivered_at": null
}
]
Replays a stored delivery using the exact original payload. A new delivery row is created so the replay is itself auditable and does not overwrite the original history.
Delivers a synthetic webhook.test event inline and returns the attempt
result. Use this to validate your receiver before you send a real candidate
through the pipeline.