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

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
  }
}

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

Push only, today. The webhook is the integration surface – there is no public polling endpoint to pull analyses from on a schedule, and the in-app reports are session-authenticated rather than API-accessible. Plan on receiving. If a pull model is a hard requirement on your side, tell us before you build: it is a reasonable thing to add and we would rather scope it than have you discover the gap halfway through.

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

Questions, or an integration that is not behaving? hello@axoapp.ai. Administrators: keys and webhooks live at Admin → Integrations.