Speech Rec · Integration API
OpenAPI Open app →

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.

1
You POST a recording URL. We reply 202 with a job id, immediately.
2
We fetch the audio, transcribe it, separate the speakers and analyse the call.
3
You collect the result — we POST your callback, or you poll the job id.

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

POST/api/v1/calls

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

FieldDefaultMeaning
urlRequired. Where to fetch the recording.
externalIdYour reference (CDR uniqueid, recording id). Returned on the call.
startedAtnowISO 8601. When the call happened, as opposed to when you sent it.
languageauto‑detectLeave unset. Forcing a language badly degrades calls where the speakers switch between two, which on real client audio is common.
diarizetrueLabel who is speaking.
numSpeakers2Correct 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.
translatefalseKeep the original transcript and add an aligned English translation.
enhancetrueTitle, summary, key points, action items, topics, sentiment.
accuracytrueRepair only the segments the recogniser itself flagged as weak. Originals are kept.
redactinstance defaultDetect and mask PII — card numbers, IBANs, emails, phone numbers. Non-destructive; the original is retained alongside.
vocabComma-separated names and terms to bias recognition toward: products, agents, your company.
tagsUp to 20 strings, for filtering later.
callbackUrlWe POST here when the job finishes.

Retrieve a call

GET/api/v1/calls/{id}

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": [ … ] }
}
GET/api/v1/calls
GET/api/v1/jobs/{id}
GET/api/v1/health

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

POST/api/v1/crm/contacts

"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

GET/api/v1/calls/{id}/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.

PlatformEndpointSignature verification
Twilio/api/v1/webhooks/twilioHMAC-SHA1, via TWILIO_AUTH_TOKEN
Telnyx/api/v1/webhooks/telnyxEd25519, via TELNYX_PUBLIC_KEY
Vonage/api/v1/webhooks/vonageHMAC-SHA256, via VONAGE_SIGNATURE_SECRET
Anything else/api/v1/webhooks/genericAPI 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" } }
StatusCodeMeaning
400missing_recording_urlNo recording URL in the payload.
400invalid_recording_urlNot fetchable, or resolves to a private address.
400invalid_callbackcallbackUrl is not http(s).
401unauthorizedMissing, unknown or revoked API key.
401bad_signatureProvider signature did not verify.
403forbiddenKey lacks the required scope.
404not_foundNo 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.