Athos Developer Docs
Backend

Webhooks

Receive and verify the signed call.scored webhook.

When a call reaches its outcome, Athos POSTs a small, signed call.scored event to your registered URL. Your handler verifies the signature, ACKs fast, and then fetches the full score. It fires for practice calls and for an agency's real calls alike — once per call, with the same body.

Registering your endpoint

Give your Athos contact an HTTPS URL and we'll register it — there's no setup probe to respond to. You'll receive a signing secret (whsec_…), shown once; store it as a backend secret (ATHOS_WHSEC). Once it's registered, call.scored events start flowing to that URL.

Until an endpoint is registered and active, no notifications are sent at all — nothing is queued for later delivery. Polling GET /v1/calls is your only signal before that point, so register the endpoint before you rely on webhooks in production.

Testing locally. Deliveries come from the internet, so your handler needs a public HTTPS URL. Expose a local server with a tunnel (ngrok, cloudflared) and register the tunnel URL; ask your Athos contact to swap it for the production URL when you deploy. Your handler is plain HTTP, so you can also unit-test it: sign a body yourself with the secret and the recipe below, then POST it to your handler.

We trust the URL you give us, so double-check it. If a delivery later fails — your endpoint is down, returns a non-2xx, or the URL is wrong — it's recorded in our delivery logs and we'll reach out. (Non-HTTPS URLs are rejected, and redirects on delivery are not followed.)

The request

POST https://your-app.example.com/athos/webhook
Content-Type: application/json
X-Athos-Signature: t=1780682954,v1=3a8f1c…e92
X-Athos-Webhook-Id: wh_2c7e9a04-…

Body — deliberately thin. It tells you which call changed; you fetch the details yourself.

{
  "event": "call.scored",
  "success": true,
  "callId": "athos_call_GZ1DjOkZv_VEPoE-CJ8mp",
  "source": "roleplay",
  "agentId": "athos_agent_bR4kQ2wZ9Lp0nH3xVq1Yt",
  "agencyId": "athos_agency_V1StGXR8Z5jdHi6B-myT",
  "platformAgentId": "rep_8842",
  "platformAgencyId": "acme-7731",
  "occurredAt": "2026-06-05T18:09:14.000Z",
  "requestId": "req_b1f0…"
}
FieldNotes
eventAlways "call.scored" in v1.
successtrue when the call scored. false when it didn't — see When success is false.
callIdPublic athos_call_… id. Look the call up with this.
source"roleplay" for a practice call run through the SDK; "real_call" for a real call ingested from the agency's dialer (see Real-call analysis).
agentId / agencyIdThe Athos ids of the rep who took the call and their agency. Always present.
platformAgentId / platformAgencyIdYour own ids for the same rep and agency, or null if you never registered one — join on agentId / agencyId then. See Two id families.
occurredAtISO 8601 UTC timestamp of the event.
requestIdA delivery support id — quote it to Athos if a delivery looks wrong.

The body never contains a score or a transcript. Fetch those with GET /v1/calls/:callId. The same body is declared as the call.scored webhook in the OpenAPI spec (CallScoredEvent), so a generated client can type it.

When it arrives

call.scored typically arrives within a minute or two of the call ending — ended in the SDK for a practice call. If nothing has arrived 30 minutes after the call ended, read GET /v1/calls/:callId directly: a 200 means the delivery was missed (see retries); a 404 CALL_NOT_FOUND means the call did not score, or scoring is still running.

When success is false

success: false means the attempt happened but Athos has no score for it. Two extra fields come with the event:

FieldNotes
reasonA stable machine code — branch on this.
reasonMessageA short human-readable explanation. Log it; don't parse it.

The reason codes you can receive today:

reasonMeaning
SCORING_INCOMPLETEAthos did not finish processing the call — the rep never spoke, the call never completed, or scoring failed.

This list is append-only. New codes can be added without a breaking change, so branch on the codes you know and log anything unrecognised rather than switching exhaustively over the set.

What that means for your handler:

  • A failed call is not retrievable. GET /v1/calls/:callId answers 404 CALL_NOT_FOUND for it, and it never shows up in GET /v1/calls. This webhook is the only signal that the attempt happened — if you need a record of every attempt, record it when you mint the session token.
  • There is no score to fetch. Don't call GET /v1/calls/:callId on a success: false event.
  • A failed call is not billed, but it does count toward your monthly usage allowance (the one TENANT_QUOTA_EXCEEDED reports), because the call itself ran.
  • platformAgentId / platformAgencyId may be null on a success: false event even for a rep you registered under your own id. agentId / agencyId are always present — join on those.
  • Real calls have their own rules for which attempts produce a delivery at all, and a real call's score can change later without a new delivery — see What differs on a real call.

Verifying the signature

The signature header is t=<unix-seconds>,v1=<hex> where:

v1 = HMAC_SHA256( your whsec_ secret , "<t>.<raw request body>" )

Three rules that matter:

  1. Verify over the raw body bytes — exactly what was sent, before any JSON parse/re-serialize. If you parse first and re-stringify, the bytes change and the signature won't match.
  2. Reject stale timestamps — if t is more than 5 minutes from now, reject it (replay protection).
  3. Compare in constant time.
import crypto from 'node:crypto';
import express from 'express';

function verifyAthosSignature(rawBody: string, header: string | undefined, secret: string): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const { t, v1 } = parts;
  // Both values are attacker-controlled — check their shape before doing anything with them.
  // (`timingSafeEqual` throws on a length mismatch, and a non-hex string decodes to zero bytes.)
  if (!t || !v1 || !/^\d+$/.test(t) || !/^[0-9a-f]{64}$/i.test(v1)) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5-min replay window
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
}

// Mount with a RAW body parser so the bytes are untouched.
app.post('/athos/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verifyAthosSignature(raw, req.header('X-Athos-Signature'), process.env.ATHOS_WHSEC!)) {
    return res.sendStatus(401);
  }

  res.sendStatus(200); // ACK fast — do the work afterwards

  const evt = JSON.parse(raw);
  if (!alreadyProcessed(req.header('X-Athos-Webhook-Id'))) {
    if (evt.success) void fetchAndStoreScore(evt.callId);
    else if (evt.reason === 'SCORING_INCOMPLETE') markAttemptUnscored(evt.callId, evt.reasonMessage);
    // The reason list is append-only — log an unrecognised code, don't throw.
    else logUnknownFailureReason(evt.callId, evt.reason, evt.reasonMessage);
  }
});
import hmac, hashlib, re, time, os
from flask import Flask, request

def verify_athos_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    # Both values are attacker-controlled — check their shape before doing anything with them.
    # (`int()` raises on anything but ASCII digits — `str.isdigit()` and `\d` also accept Unicode
    # digits such as "²", so match [0-9] explicitly. `compare_digest` raises on non-ASCII input.)
    if not t or not v1 or not re.fullmatch(r"[0-9]+", t) or not re.fullmatch(r"[0-9a-fA-F]{64}", v1):
        return False
    if abs(time.time() - int(t)) > 300:           # 5-min replay window
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1.lower())

@app.post("/athos/webhook")
def athos_webhook():
    raw = request.get_data()                       # RAW bytes — do not use request.json first
    if not verify_athos_signature(raw, request.headers.get("X-Athos-Signature", ""), os.environ["ATHOS_WHSEC"]):
        return "", 401

    evt = request.get_json()
    if not already_processed(request.headers.get("X-Athos-Webhook-Id")):
        if evt["success"]:
            enqueue_fetch_score(evt["callId"])     # do heavy work async
        elif evt.get("reason") == "SCORING_INCOMPLETE":
            mark_attempt_unscored(evt["callId"], evt.get("reasonMessage"))
        else:
            # The reason list is append-only — log an unrecognised code, don't raise.
            log_unknown_failure_reason(evt["callId"], evt.get("reason"), evt.get("reasonMessage"))
    return "", 200                                 # ACK fast

Responding, retries, and idempotency

  • Respond 2xx within 10 seconds. After 10 seconds we abort the request and record a failed delivery. Any non-2xx is treated as a failed delivery too. ACK first, then do the work asynchronously.
  • Retries are minimal. If the first attempt fails, Athos retries once after ~5 minutes, then stops. There is no long exponential tail.
  • Deduplicate on X-Athos-Webhook-Id. It's stable across the retry, so the same event can arrive twice with the same id. Treat that id as your idempotency key.
  • Polling is the backstop. If both delivery attempts fail, the event won't be redelivered later — reconcile by polling GET /v1/calls on a schedule, with an overlapping window: see Reconciling by polling.

Operational notes

  • Rotating the signing secret is immediate. An endpoint holds one secret and there is no overlap window: from the moment Athos rotates it, every delivery — including a retry that was already pending — is signed with the new secret, and the old one stops verifying. Agree a deploy window with your Athos contact and switch ATHOS_WHSEC at that moment.
  • Recordings of real calls are kept for 30 days — see Real-call analysis.

A note on frameworks

The one thing to get right everywhere is raw body access:

  • Express: express.raw({ type: 'application/json' }) on the webhook route only. If you mount app.use(express.json()) globally before this route, the body is already consumed and req.body.toString() is "[object Object]" — register the webhook route first, or exclude its path from the global parser.
  • Next.js (App Router): read await req.text() in the route handler and verify that string.
  • Flask/FastAPI: use request.get_data() / the raw Request body, not the parsed JSON.

If signatures never match, a re-serialized body is almost always the cause.

On this page