Pushing recordings over the API
Hand recordings to a standing transcription job from your own system, and read the transcript, analysis and details back.
An API job is configured once in the dashboard — language, speakers, analysis outputs, enrichment, concurrency — and then stays open. Each call supplies only what actually differs: the audio, and what is known about it.
That split is deliberate. An endpoint that took every setting per request would make an integrator repeat a dozen decisions per recording, and would let two recordings in the same stream be analysed differently — which destroys the comparability that made anyone batch them together in the first place.
Every endpoint sits behind an API key carrying the Transcriptions permissions: Read transcriptions for the three listings, Push recordings for the two push endpoints. See API keys.
Endpoints
| Method | Path | What it does |
|---|---|---|
GET | /transcriptions/jobs | The jobs this key can push into — API-sourced jobs only |
GET | /transcriptions/metadata/fields | The detail keys this account uses, including the ones you defined |
POST | /transcriptions/jobs/{jobId}/recordings | Push one recording by URL |
POST | /transcriptions/jobs/{jobId}/recordings/upload | Push one recording as a multipart upload |
GET | /transcriptions/jobs/{jobId}/rows | A job's recordings and their status |
GET | /transcriptions/rows/{rowId} | One recording's transcript, analysis and details |
All paths are relative to https://api.kilevel.com/api/external/v1. The full request and
response schemas are in the
Transcriptions API reference.
A job built from a Drive folder or an upload batch is not listed: its source already decides what belongs in it, and injecting rows would mean its row count no longer describes the thing you pointed at.
Pushing by URL
curl -X POST "https://api.kilevel.com/api/external/v1/transcriptions/jobs/<jobId>/recordings" \
-H "X-API-Key: <your_api_key>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-storage/recordings/call-118273.mp3",
"externalId": "call-118273",
"metadata": { "agentName": "Mara Lindqvist", "conversationId": "CALL-2026-118273" }
}'- The URL is passed straight to the transcription provider and never copied — a pre-signed S3 or GCS link costs one small request instead of moving the file twice.
externalIdmakes retries free: repeating an id already seen returns the recording that exists rather than transcribing, and billing for, the same audio twice.metadatacarries the details for this recording. A key Kilevel does not recognise is still stored and named back to you in the response underunknownMetadataKeys, so a typo shows up on the first call rather than as an empty column days later.- If the job also has a file-name pattern or a spreadsheet, those are applied first and the values in the call win.
Pushing an upload
Post to the same path with /upload, a file part and a payload part carrying the same
JSON:
curl -X POST "https://api.kilevel.com/api/external/v1/transcriptions/jobs/<jobId>/recordings/upload" \
-H "X-API-Key: <your_api_key>" \
-F "[email protected]" \
-F 'payload={"externalId":"call-118273","metadata":{"agentName":"Mara Lindqvist"}}'Multipart rather than base64 inside JSON: base64 inflates an hour of audio by a third and forces the whole thing through memory on both sides. The details travel as a JSON part so a list of tags or a nested value survives without an encoding convention both sides have to agree on.
Reading results back
GET /transcriptions/rows/{rowId} returns the transcript, the utterances, the summary, the
analysis and the attached details:
{
"id": "…",
"status": "COMPLETED",
"durationSeconds": 412,
"detectedLanguage": "de",
"metadata": { "agentName": "Mara Lindqvist", "project": "Retention Q3" },
"linkedAgentId": "…",
"linkedContactId": null,
"transcript": "…",
"analysis": { "Conversation Quality": { "openingScore": 83, "openingScore_reason": "…" } }
}metadata and analysis are separate objects and are never merged. One is what you told
us, the other is what a model concluded, and an integration writing either into its own
records needs to know which it is holding.
Each judgement field in analysis is accompanied by a <field>_reason while
explain every judgement is
on for the job.
Tip
Poll GET /transcriptions/jobs/{jobId}/rows for status rather than the single-row
endpoint — the list leaves out the transcript and analysis, so it stays cheap on both
sides.