Integration API
Post a call recording, get back a transcript with speaker labels, a summary, action items and a compliance score. Built for telephony platforms — Asterisk, FreePBX, 3CX, Twilio, Telnyx, Vonage, or anything that can make an HTTP request.
How it works
Transcribing a ten-minute call takes minutes, and no telephony platform will hold a webhook open that long. Submission is therefore asynchronous.
202 with a job id, immediately.Your PBX is never kept waiting, and a slow or failed transcription never blocks a call from being recorded.
Authentication
Every endpoint takes an API key. Keys carry scopes (calls:write to
submit, calls:read to retrieve), are individually revocable, and are stored
only as a SHA-256 hash — the plaintext is shown once, at creation, and cannot be
recovered.
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxx
Or, where a bearer header is awkward: X-API-Key: sk_live_…
Submit a recording
The only required field is a URL we can fetch the recording from. It is validated
synchronously, so a bad URL is an immediate 400 rather than a job that
fails quietly ten minutes later.
curl -X POST https://your-instance/api/v1/calls \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pbx-recording-25637910" \
-d '{
"url": "https://pbx.example.com/recordings/25637910.wav",
"externalId": "25637910",
"startedAt": "2026-08-06T09:14:22Z",
"from": "+905445671707",
"to": "+441234567890",
"callbackUrl": "https://your-crm.example.com/hooks/transcribed",
"tags": ["sales", "inbound"]
}'
202 Accepted
{ "id": "9166d610-…", "status": "queued", "attempts": 0, "createdAt": "…" }
Options
| Field | Default | Meaning |
|---|---|---|
url | — | Required. Where to fetch the recording. |
externalId | — | Your reference (CDR uniqueid, recording id). Returned on the call. |
startedAt | now | ISO 8601. When the call happened, as opposed to when you sent it. |
language | auto‑detect | Leave unset. Forcing a language badly degrades calls where the speakers switch between two, which on real client audio is common. |
diarize | true | Label who is speaking. |
numSpeakers | 2 | Correct for a standard two-party call. Auto-detect (0) is unreliable on 8 kHz telephony — weak embeddings split one voice into several. Raise it for conference audio. |
translate | false | Keep the original transcript and add an aligned English translation. |
enhance | true | Title, summary, key points, action items, topics, sentiment. |
accuracy | true | Repair only the segments the recogniser itself flagged as weak. Originals are kept. |
redact | instance default | Detect and mask PII — card numbers, IBANs, emails, phone numbers. Non-destructive; the original is retained alongside. |
vocab | — | Comma-separated names and terms to bias recognition toward: products, agents, your company. |
tags | — | Up to 20 strings, for filtering later. |
callbackUrl | — | We POST here when the job finishes. |
Retrieve a call
Accepts either the call id or the job id, so you can poll with whatever the submit
returned. While the job is still running you get 202; once finished, the
call. Add ?segments=1 for the utterance-level transcript with timings and
speaker labels.
{
"id": "9166d610-…",
"status": "completed",
"externalId": "25637910",
"language": "tr",
"durationSec": 100.74,
"speakers": 2,
"title": "Account verification and withdrawal request",
"summary": "The caller asked to verify their account before …",
"keyPoints": ["…"],
"actionItems": ["…"],
"topics": ["…"],
"sentiment": "neutral",
"agent": "Sarah",
"transcript": "…",
"confidence": 0.87,
"lowConfidence": false,
"flagged": true,
"escalated": false,
"qa": { "score": 44, "overall": "…", "criteria": [ … ] }
}
List calls, check a job on its own, and a health check that needs no key.
The commercial read
Every call is also read for money: whether funding was discussed, how much, when,
what the customer wanted to trade with it, and whether anybody booked a follow-up.
It arrives on the call payload as sales.
"sales": {
"intent": { "score": 90, "band": "hot" },
"deposit": { "discussed": true, "stance": "committed",
"amount": 10000, "currency": "EUR", "timeframe": "Friday" },
"withdrawalDiscussed": false,
"followUpAgreed": false,
"instruments": [{ "symbol": "XAUUSD", "name": "Gold", "class": "metals" }],
"objections": ["price"],
"source": "llm"
}
Instruments are canonical. "gold", "XAUUSD", "spot gold" and "bullion" all resolve to one symbol with an asset class, so aggregating what a desk is actually pitching is a group-by rather than a text-matching exercise.
The score is arithmetic. intent is computed from the
extracted observations with a fixed weight each — stance, whether a figure was named,
its size, a stated timeframe, a booked follow-up, objections, withdrawal — not asked
of a model. It does not drift when the model behind the extraction changes, and
signals returns the working.
Null is not zero. sales is null when this
layer did not run on a call, and that is a different fact from a call where funding
never came up. "source": "scan" means only the deterministic instrument
pass ran — no intent score, because inventing a zero would make a call nobody read
indistinguishable from one where the customer refused.
Account context
"They discussed a deposit" is a transcript feature. "They committed to ten thousand on Tuesday, the account is still at five hundred on Friday, and nobody booked a callback" is a list somebody works through on Monday. The difference is the account, which has to come from you.
This is a push, not a pull. Nothing here stores your CRM credentials or maps your schema — send contacts on whatever cadence suits, one at a time or up to 1,000 per request. A row that fails does not reject the batch; the response names it.
curl -X POST https://your-host/api/v1/crm/contacts \
-H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \
-d '{"contacts":[{
"id": "CRM-4471",
"phones": ["+44 7700 900123"],
"currency": "EUR",
"balance": 500,
"lifetimeDeposits": 500,
"lastDepositAt": "2026-01-05T00:00:00Z",
"openPositions": [{"symbol": "GOLD"}, {"symbol": "XAGUSD"}],
"asOf": "2026-08-20T02:00:00Z"
}]}'
asOf matters more than it looks. Every funding signal compares the
snapshot against the date of the call, so a nightly export that does not set it will
have last night's balance read as today's. A snapshot older than the call proves
nothing either way, and the signals say so rather than guessing.
Calls are attached to an account by contactId on submission where you
can supply it, and otherwise by matching the phone number on the last nine digits — so
"+44 7700 900123" and "07700900123" are the same account. The response says which
happened; a phone match is a guess and is labelled one.
Needs the crm:write scope. Keys issued before this
endpoint existed do not carry it — issue a new key for the CRM integration, which is
the right shape anyway: your phone system and your CRM should not share credentials.
No developer? Two lighter paths. Export a CSV from your CRM and drop
it in under Admin → Account context — flexible headers (id, name, phone, currency,
balance, deposits, positions…), one id column required. Or wire it with no code in
Zapier / Make: trigger on a new-or-updated CRM record, add a Webhooks →
POST step to /api/v1/crm/contacts with your Bearer key and the fields mapped
to the JSON above. CSV is the five-minute start; the webhook keeps it live.
Signals
What one call means next to its account.
{
"matched": true, "matchedBy": "phone", "fresh": true,
"signals": [
{ "code": "committed_unfunded", "severity": "high",
"headline": "Committed but never funded",
"detail": "Discussed ten thousand. No deposit in the 3 day(s) since. Balance 500 EUR." },
{ "code": "concentration", "severity": "medium",
"headline": "More metals into a metals-heavy account",
"detail": "Gold came up on the call. 2 of 3 open positions are already metals." }
]
}
Computed on every request and never stored on the call. Each signal compares what was said then against what the account looks like now, so one written at transcription time would still be reporting "never funded" a year after the money landed.
matched: false means no contact could be attached. That is not the same
as a clean account, and is worth surfacing differently.
Codes: committed_unfunded, interested_unfunded,
funded, funding_unknown, withdrawal_risk,
no_follow_up, concentration, new_asset_class,
upgrade, reengaged, pressure.
Callbacks
Supply callbackUrl and we POST to it when the job settles, so you never
have to poll:
{ "event": "call.completed", "jobId": "…", "callId": "…", "at": "…" }
{ "event": "call.failed", "jobId": "…", "callId": null, "error": "…", "at": "…" }
Fired once the job reaches a terminal state — a failure that will
be retried does not fire one. Return any 2xx. We do not retry the callback itself, so
treat GET /api/v1/calls/{id} as the source of truth if your endpoint might
be down.
Idempotency
Telephony platforms retry webhooks aggressively and transcription costs money, so a
repeated submission must never create a second job. Send an
Idempotency-Key header; a repeat returns the original job with
"duplicate": true and a 200 instead of a 202.
Without one we derive a key from the provider's own event id where the payload has one, so provider webhooks are safe by default.
Provider webhooks
If your platform posts its own webhook shape, point it at the matching alias and we translate it — no glue code on your side.
| Platform | Endpoint | Signature verification |
|---|---|---|
| Twilio | /api/v1/webhooks/twilio | HMAC-SHA1, via TWILIO_AUTH_TOKEN |
| Telnyx | /api/v1/webhooks/telnyx | Ed25519, via TELNYX_PUBLIC_KEY |
| Vonage | /api/v1/webhooks/vonage | HMAC-SHA256, via VONAGE_SIGNATURE_SECRET |
| Anything else | /api/v1/webhooks/generic | API key only |
The generic endpoint accepts several common spellings of the recording field —
url, recordingUrl, recording_url,
RecordingUrl — so an existing webhook often works unmodified.
Asterisk / FreePBX
A self-hosted PBX has no outbound webhook for finished recordings, so the integration is a small agent on the PBX that watches the CDR table and posts each new recording. No dialplan changes.
FreePBX writes one row per call into asteriskcdrdb.cdr, with the
filename in recordingfile and the audio under
/var/spool/asterisk/monitor/YYYY/MM/DD/.
#!/usr/bin/env bash
# /usr/local/bin/speechrec-ship.sh — run every 5 minutes from cron.
# Ships recordings finished since the last run, then records the watermark.
set -euo pipefail
API="https://your-instance/api/v1/calls"
KEY="sk_live_…"
STATE=/var/lib/speechrec/last_run
SINCE=$(cat "$STATE" 2>/dev/null || date -d '1 hour ago' '+%Y-%m-%d %H:%M:%S')
NOW=$(date '+%Y-%m-%d %H:%M:%S')
mysql -N -B asteriskcdrdb -e "
SELECT uniqueid, calldate, src, dst, recordingfile
FROM cdr
WHERE recordingfile != '' AND calldate > '$SINCE'" |
while IFS=$'\t' read -r id calldate src dst rec; do
ymd=$(date -d "$calldate" +%Y/%m/%d)
curl -sS -X POST "$API" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pbx-$id" \
-d "$(jq -n --arg u "https://pbx.example.com/monitor/$ymd/$rec" \
--arg e "$id" --arg s "$calldate" --arg f "$src" --arg t "$dst" \
'{url:$u, externalId:$e, startedAt:$s, from:$f, to:$t, numSpeakers:2}')"
done
mkdir -p "$(dirname "$STATE")" && echo "$NOW" > "$STATE"
The recording must be fetchable by us. Either
expose /var/spool/asterisk/monitor read-only behind HTTPS and a long random
path, or ask us to enable direct upload so the agent POSTs the audio itself and nothing
on the PBX faces the internet. The second is the right answer on most networks.
The Idempotency-Key derived from the CDR uniqueid means a
cron overlap, a retry, or a re-run over the same window costs nothing — the duplicate is
recognised and discarded.
Errors
Errors are JSON with a stable machine-readable code:
{ "error": { "code": "missing_recording_url", "message": "no recording URL found in the payload" } }
| Status | Code | Meaning |
|---|---|---|
| 400 | missing_recording_url | No recording URL in the payload. |
| 400 | invalid_recording_url | Not fetchable, or resolves to a private address. |
| 400 | invalid_callback | callbackUrl is not http(s). |
| 401 | unauthorized | Missing, unknown or revoked API key. |
| 401 | bad_signature | Provider signature did not verify. |
| 403 | forbidden | Key lacks the required scope. |
| 404 | not_found | No such call or job. |
Recording URLs are resolved and checked before fetching: anything pointing at a private or link-local address is rejected, and a redirect from a public host to a private one is refused rather than followed.
Machine-readable spec
The full OpenAPI 3.1 document is served live from the instance, so it always matches
the deployed build: /api/v1/openapi.json.
Import it into Postman, Insomnia, or a client generator.