Skip to content

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"
Response shape (abridged)
{
  "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

POST /webhooks/endpoints
WebhookEndpointCreate
{
  "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:

WebhookEndpointCreated (abridged)
{
  "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.

interview.media_processed envelope (abridged)
{
  "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:

python "minimal_webhook_delivery_handler.py --secret "whsec_…" --port 9000 --output-dir ./received
minimal_webhook_delivery_handler.py
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, and 429. Network errors, timeouts and 5xx are retried with exponential backoff up to WEBHOOK_MAX_ATTEMPTS (default 3).
  • De-duplication. Re-enqueuing an event whose delivery already succeeded is a no-op. A repeated /interview/media/processed call cannot double-notify your endpoint.
  • Ordering. Deliveries are dispatched sequentially, so per-endpoint ordering is deterministic.
  • Audit. Every attempt is persisted with attempts, response_status and error, 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

GET /webhooks/deliveries?status=3&limit=50

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
  }
]
POST /webhooks/deliveries/{delivery_id}/redeliver

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.

POST /webhooks/endpoints/{endpoint_id}/test

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.

{
  "endpoint_id": "…",
  "success": true,
  "detail": null,
  "delivery": { "status": 2, "status_name": "SUCCESS", "attempts": 1, "…": "…" }
}