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…"
}| Field | Notes |
|---|---|
event | Always "call.scored" in v1. |
success | true when the call scored. false when it didn't — see When success is false. |
callId | Public 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 / agencyId | The Athos ids of the rep who took the call and their agency. Always present. |
platformAgentId / platformAgencyId | Your own ids for the same rep and agency, or null if you never registered one — join on agentId / agencyId then. See Two id families. |
occurredAt | ISO 8601 UTC timestamp of the event. |
requestId | A 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:
| Field | Notes |
|---|---|
reason | A stable machine code — branch on this. |
reasonMessage | A short human-readable explanation. Log it; don't parse it. |
The reason codes you can receive today:
reason | Meaning |
|---|---|
SCORING_INCOMPLETE | Athos 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/:callIdanswers404 CALL_NOT_FOUNDfor it, and it never shows up inGET /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/:callIdon asuccess: falseevent. - A failed call is not billed, but it does count toward your monthly usage allowance (the one
TENANT_QUOTA_EXCEEDEDreports), because the call itself ran. platformAgentId/platformAgencyIdmay benullon asuccess: falseevent even for a rep you registered under your own id.agentId/agencyIdare 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:
- 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.
- Reject stale timestamps — if
tis more than 5 minutes from now, reject it (replay protection). - 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 fastResponding, retries, and idempotency
- Respond
2xxwithin 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/callson 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_WHSECat 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 mountapp.use(express.json())globally before this route, the body is already consumed andreq.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 rawRequestbody, not the parsed JSON.
If signatures never match, a re-serialized body is almost always the cause.