AXO.Voice · integration guide
Sending calls in, getting results out
Everything an engineer needs to connect a phone system or CRM to the AI Call Analyzer. There are four ways to send us a call and one way to receive the analysis. No SDK to install – it is HTTPS both directions.
Part 1 · Sending calls in
Pick whichever fits. Most customers start with the spreadsheet import to prove value, then move to an API once it is worth automating. Mixing them is fine – a call is a call however it arrived.
1 · Upload in the browser
Calls → Add calls. Drag in one audio file or many. Nothing to build; good for a pilot, a spot check, or a call someone wants looked at today.
2 · Spreadsheet import
Calls → Add calls → Import spreadsheet. A CSV or XLSX with one row per call and a link to the recording. Two columns matter beyond the audio: SessionID (your identifier for the call) and Client ID (your identifier for the account it belongs to). Both are returned on every webhook, so your side reconciles on your own keys rather than ours.
3 · API – send us the audio
Post the file itself. The body is the raw audio – not multipart, not base64 – and the metadata rides on headers. Up to 100 MB per call.
POST https://voice.axoapp.ai/api/call-intel/ingest
Authorization: Bearer ci_live_…
Content-Type: audio/wav
<raw audio bytes>
Optional headers
X-External-Id your call id (idempotency key – see below)
X-Call-Direction inbound | outbound
X-From-Number +15551234567 (E.164)
X-To-Number +15559876543 (E.164)4 · API – send us a link
If the recording already lives somewhere we can reach, hand us the URL instead of the bytes. We do not fetch it during your request – the call is registered and queued, and a worker downloads it. A slow or briefly unavailable recording store therefore cannot time out your request, and the download gets retried for free.
POST https://voice.axoapp.ai/api/call-intel/ingest/url
Authorization: Bearer ci_live_…
Content-Type: application/json
{
"url": "https://recordings.example.com/abc123.wav",
"external_id": "SESSION-84213",
"direction": "outbound",
"from_number": "+15551234567",
"to_number": "+15559876543"
}The URL may point at the media file directly or at a player page whose audio element references one; we resolve the second case and re-validate what we find.
Keys, and sending the same call twice
- An administrator creates ingest keys at Admin → Integrations → Call ingest API. Keys begin ci_live_, are scoped to one organization, are shown once, and can be revoked at any time.
- X-External-Id (or external_id) is your idempotency key. Send the same call twice with the same id and you get one record, not two – which makes a retry after a network blip safe rather than a duplicate to clean up later.
- Audio arrives with its channel layout unknown. We probe the container and, for a two-channel file, prove the channels really are separated before trusting them for speaker attribution – a mono recording is never presented as if it were split.
Part 2 · Getting results out
One webhook, fired as soon as a call finishes analysis. Configure it at Admin → Integrations: the endpoint URL, a generated signing secret shown once, and an optional field-name map.
The request
POST https://your-endpoint.example.com/axo
Content-Type: application/json
X-Axo-Event: call.analyzed
X-Axo-Signature: t=1786500000,v1=9f2c…Endpoints must be https and must resolve to a public host. Requests time out after 10 seconds – return quickly and do your own work afterwards.
The payload
Your identifiers lead deliberately: reconciliation on your side should be a join on your key, not a lookup of ours.
{
"event": "call.analyzed",
"external_call_id": "SESSION-84213",
"client_id": "CUST-2201",
"call": {
"id": "4f6f0c2e-…",
"call_number": 1127,
"started_at": "2026-07-24T21:18:00Z",
"analyzed_at": "2026-08-05T14:03:11Z",
"duration_ms": 116000,
"direction": "outbound",
"recruiter": "Roy",
"other_party": "Marcus",
"call_kind": null
},
"answerer": {
"type": "human",
"confidence": 0.95,
"time_to_human_ms": null
},
"disposition": {
"code": "not_interested",
"label": "Not Interested",
"category": "contacted_negative",
"outcome_class": "negative",
"source": "ai_suggested",
"ai_confidence": 0.82
}
}- disposition.source tells you who decided: ai_suggested with an ai_confidence, or a human-confirmed value with no confidence attached. A reviewer confirming a call fires a fresh delivery.
- answerer.type is what picked up – human, voicemail, ivr, no_answer – with the classifier’s confidence beside it.
- Fields we cannot determine are sent as null. They are never omitted and never guessed, so your parser can rely on the shape.
Verifying the signature
X-Axo-Signature carries a timestamp and an HMAC-SHA256 over `${t}.${rawBody}`, keyed with your webhook secret. This is the same scheme Stripe uses – deliberately, because most teams have already written this function once.
const [tPart, v1Part] = header.split(',');
const t = tPart.split('=')[1];
const v1 = v1Part.split('=')[1];
const expected = crypto
.createHmac('sha256', process.env.AXO_WEBHOOK_SECRET)
.update(`${t}.${rawBody}`) // the RAW body, before JSON.parse
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
return res.status(400).end();
}Sign the raw bytes, not a re-serialized object – key order and whitespace both change the MAC. Reject timestamps older than your tolerance (five minutes is typical) to close off replay.
Renaming our fields to match yours
If your system calls it dispo, we will send dispo. The Integrations page has a map from any key in the payload above to a name of your choosing, applied per endpoint. It changes how keys are spelled, never what they mean and never what the dashboard shows – anything structural belongs in an adapter on your side, where you can version it.
Retries, and what a failure does
- Any non-2xx or a timeout is retried, backing off, up to 8 attempts before we stop and keep the error on the delivery record.
- Retries send a byte-identical body. The payload is built once and frozen at enqueue, so a team chasing a signature mismatch is never also chasing a body that changed between attempts.
- Every attempt is logged with its response code, and any delivery can be replayed by hand from the dashboard.
- A failing webhook never fails an analysis. The call is analyzed, stored and visible in the app regardless of whether your endpoint was reachable.
- A ping event can be sent on demand, so you can prove your receiver and your signature check work before a real call depends on them.
Part 3 · If you do not want to build anything
Reporting is included and covers what most teams would otherwise rebuild: outcome mix by category, answer rate by hour and day, outcomes by recruiter, call timing, and the full detail of any single call with its transcript and audio. Upload calls, read the dashboards, write no code. The webhook is there for when you want the same facts inside your own systems.
Related
- Custom domain setup – putting the workspace on your own hostname with your own branding.
Questions, or an integration that is not behaving? hello@axoapp.ai. Administrators: keys and webhooks live at Admin → Integrations.