Skip to content

API

Base address: https://console.gapi.uz. Every request carries Authorization: Bearer <token>. Tokens are created in the cabinet on the "API tokens" tab and belong to a project.

Responses are JSON. An error looks like this:

json
{"error": "insufficient_balance", "hint": "balance 3.20 g (0.00 g held by jobs in flight), this job needs 4.71 g; the beta balance is topped up to 240 g at midnight Asia/Tashkent"}

Jobs

POST /v1/jobs: upload a recording

multipart/form-data:

FieldWhat it is
fileAudio: mp3, wav, ogg/opus, m4a, flac, webm. Up to 200 MiB
settingsJSON with settings for this request on top of the project's, optional
vocabularyA JSON array of terms for this request in place of the project vocabulary, optional
webhook_urlWhere to send the completion notice, optional

Response 202: {"id", "state": "queued", "class": "batch", "estimated_seconds", "created_at", "result_url"}. result_url is absolute — store it and call it as it is. class is batch for an ordinary upload and interactive for POST /v1/transcribe.

The list of formats is not exhaustive: anything ffmpeg reads is accepted, a raw AAC stream included. A file that does not decode gets 422 audio_unreadable.

A recording does not have to be uploaded: if it already sits behind a URL, send the URL and we fetch it ourselves. That suits telephony integrations, where the recording is in storage anyway.

bash
curl -X POST https://console.gapi.uz/v1/jobs \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"url": "https://example.com/call.mp3", "settings": {"emotion": false}}'

A third way is the file as the request body, without multipart, naming it in the X-File-Name header. Settings then come from the project.

POST /v1/transcribe takes the same three forms.

GET /v1/jobs/{id}: state

state: queued, running, done, failed, cancelled. While running there is progress_seconds. A failure has error.

GET /v1/jobs/{id}/events: events

text/event-stream. Not merely a progress bar: utterances arrive here as they are finished, so the text can be shown while the conversation is still going, without waiting for the end of the recording.

EventWhat it carries
segmentsAn array of finished utterances in order, in the same shape as in the result
stateThe job changed state: data is the same job GET /v1/jobs/{id} returns
doneThe job ended; the stream closes. data is {"state", "job"}, plus result_url when it finished
event: segments
data: [{"index": 0, "start": 0.42, "end": 3.9, "speaker": 1, "text": "Assalomu alaykum…"}]

A client that connects late is first sent everything already finished and only then the new, so the beginning cannot be missed.

The last batch before done sends the utterances again in their final form: while the recording is still being worked on, their punctuation is provisional. So collect utterances by index, replacing an earlier one — by the end of the stream you then hold exactly the text the result has.

Every twenty seconds a : keepalive comment line goes out so that proxies leave the connection alone. If the stream breaks, nothing is lost: subscribe again, or take the result whole.

GET /v1/jobs/{id}/result: result

Described under Result format. A result is kept for 30 days.

GET /v1/jobs: list

json
{"jobs": [{"id": "41f79b78-…", "state": "done", "class": "batch", "estimated_seconds": 283,
           "audio_seconds": 282.7, "created_at": "…", "finished_at": "…"}]}

The project's 50 most recent jobs, newest first; ?limit= raises that to 200. A number above the maximum is clamped rather than dropped: limit=500 returns the same 200. There is no paging through it: this is a look at the recent, not an export of the archive. Deleted jobs are not listed.

DELETE /v1/jobs/{id}: cancel or delete

One call does whichever of the two still makes sense.

A job queued or running is cancelled. The state becomes cancelled, nothing is charged, and a job.cancelled webhook goes out. The response is the job itself. Cancelling stops the work; it does not forget it, so the job stays in the list.

A job that has already ended is deleted. The response is {"id", "state", "deleted": true}, and from then on it is gone: not in the list, not at GET /v1/jobs/{id}, not in the result, not in the event stream; a second DELETE answers 404 job_not_found. To both cancel and delete, call DELETE twice.

Deleting hides the job from you; it does not erase it here. The charge for it stays in the account's history, and the recording and its transcript live out their usual retention. Otherwise there would be no way to explain the bill.

POST /v1/transcribe: short and immediate

The same fields as POST /v1/jobs, for recordings up to 5 minutes. A 200 response is the result itself, with the job id in the X-Job-Id header. If the result is not ready within 3 minutes you get 202 with a job_id: poll GET /v1/jobs/{id} from there.

Settings per request

The settings field takes the same keys as the cabinet's "Settings" tab; keys left out come from the project.

json
{
  "formatting": true,
  "itn": true,
  "word_timestamps": false,
  "stereo_channels_as_speakers": true,
  "diarization": true,
  "max_speakers": 2,
  "gender": true,
  "speaker_roles": true,
  "call_direction": "inbound",
  "speaker_identification": true,
  "emotion": false,
  "pii": false
}

webhook_url goes here too, if it is easier to keep it with the rest of the settings; a separate field on the request wins over it.

The keys are checked: an unknown one refuses the upload with 400 unknown_setting, and the hint names it. So diarisation for diarization is an error at once, rather than a plain transcript charged as a plain transcript.

What each key does: Options.

Project vocabulary

The same terms as the cabinet's "Vocabulary" tab: company and product names, people's names. By default the list belongs to the project: a job takes a copy of the active version when it is created, so editing the vocabulary never rewrites work already sent. A single recording can also arrive with a list of its own — below.

RequestWhat it does
GET /v1/vocabularyThe active vocabulary: terms, version, updated_at, min_terms, max_terms
PUT /v1/vocabulary {"terms": [...]}Save a new version and activate it; an empty array clears the vocabulary
bash
curl -X PUT https://console.gapi.uz/v1/vocabulary \
     -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
     -d '{"terms": ["MetaSell", "Bitrix24", "amoCRM", "Toshkent"]}'

From 1 to 20 terms, at most 4 words and 80 characters each. Repeats and empty strings are dropped silently and the order is kept; a repeat is judged without case, so "metasell" and "MetaSell" are one term and the first one stays. The shorter the list, the more often the model lands on the word you meant, so list only what is actually spoken in your recordings. Errors: term_too_long, term_too_many_words, too_many_terms.

Every save is a new numbered version and the newest one becomes active, so a bad edit is a step back rather than a loss. The version history is visible in the cabinet.

A vocabulary for one request

When the terms are wanted for this recording only — you are working through someone else's catalogue, or trying a list out before saving it — send them with the file:

bash
curl -X POST https://console.gapi.uz/v1/jobs \
  -H "Authorization: Bearer $TOKEN" \
  -F file=@call.mp3 \
  -F 'vocabulary=["MetaSell", "Bitrix24", "amoCRM"]'

The list replaces the project vocabulary for that job and for nothing else: the active version does not change, and the next recording goes back to the project's list. An empty array means "run this recording with no vocabulary at all". The rules and the error codes are the ones PUT uses, and a bad term refuses the upload before a job exists.

The JSON upload form takes the same field: "vocabulary": [...]. A file sent as the bare request body uses the project's list. POST /v1/transcribe accepts the field just as POST /v1/jobs does.

What was applied shows up in the result: vocabulary_terms is how many terms the job took, vocabulary_dropped is true when the cleaning left none.

Project voices

RequestWhat it does
GET /v1/speakersList of voices: id, number, name (empty for unnamed ones), voiceprints, plus kind, seen_count, last_seen_at, expires_at, has_sample
POST /v1/speakers {"name"}Create a voice without prints
PATCH /v1/speakers/{id} {"name"}Name or rename
DELETE /v1/speakers/{id}Delete the voice with all its prints
POST /v1/speakers/{id}/enrollAdd a print from a finished job: {"job_id", "speaker_index"} or {"job_id", "segment_indexes": [...]}
DELETE /v1/speakers/{id}/voiceprintsDelete all prints, keep the name
GET /v1/speakers/{id}/sampleUp to twelve seconds of audio where this voice is heard (audio/wav; usually shorter — the longest utterance is taken). 404 no_sample when no kept recording of this voice exists, 410 audio_expired once the recording itself is gone
DELETE /v1/speakers?kind=candidateDelete every voice of one kind at once: candidate, recurring or named

In detail: Voices and speakers.

Usage

GET /v1/usage returns the balance in g and the daily top-up target:

json
{"unit": "g", "balance_g": 203.63, "in_flight_g": 4.5, "available_g": 199.13, "topup_target_g": 240,
 "next_topup": "00:00 Asia/Tashkent", "day": "2026-09-09", "used_seconds": 3425.6, "remaining_seconds": 11947.8}

GET /v1/billing returns the balance and the history of top-ups and charges. How cost is computed: Balance and billing.

Webhooks

When the project has a webhook_url or one is passed in the request, a POST with JSON arrives at that address when the job finishes:

json
{"event": "job.done", "id": "41f79b78-…", "state": "done", "class": "batch", "estimated_seconds": 283,
 "audio_seconds": 282.7, "error": null, "created_at": "…", "finished_at": "…",
 "result_url": "https://console.gapi.uz/v1/jobs/41f79b78-…/result"}

event is job. and the state: job.done, job.failed, job.cancelled — one switch sorts the delivery out. audio_seconds is the length that was charged for; a failed job has none.

The webhook fires on any end of the job, not only a happy one: state can be done, failed or cancelled, so a cancelled or failed job also closes the wait, and a timeout on your side is only needed in case we never get through.

The first request leaves the moment the job finishes: it waits for no background sweep — finishing the job is what wakes delivery.

Answer with any 2xx; you have ten seconds to answer. Every delivery opens its own connection and closes it — we hold no connection open on your side.

On failure delivery is retried up to 5 times, and the pause is counted from the end of the job rather than from the previous attempt: roughly 30 s, 2 min, 4.5 min and 8 min after it finished. Retries are swept once a minute, so those numbers are approximate — unlike the first request.

A deleted job sends no webhook.

Limits

WhatHow much
File size200 MiB (209,715,200 bytes)
Recording lengthup to 4 hours
Synchronous /v1/transcriberecordings up to 5 minutes
Uploads per minute per token60
Read requests per minute per token3000
Management requests per minute per token600
Result retention30 days
Balancetopped up to 240 g every midnight Tashkent time
Projects per account, tokens per project3, 10
Voices per project, prints per voice500, 10
Vocabulary terms1 to 20

Sending a recording and asking about it cost different things, so their limits differ: an upload occupies a machine with a model on it, while GET /v1/jobs/{id} is one row in a database. Polling for state is meant to be comfortable — 3000 a minute is thirty jobs at two polls a second. Better still, GET /v1/jobs/{id}/events or a webhook, and there is nothing to poll.

There are three buckets and they are counted apart. Uploads are POST /v1/jobs and POST /v1/transcribe. Management is everything that changes the project without occupying a machine: voices and the vocabulary. The rest is reading.

A 429 carries a Retry-After header with a number of seconds: that is what is left before a slot frees in the window. Waiting longer is unnecessary, waiting less is pointless.

The numbers here are defaults rather than constants: the top-up target, file size, length and concurrent jobs per project are changed per account by arrangement.

Over a limit you get 429: rate_limited, insufficient_balance or too_many_active_jobs.

Error codes

The code is in error, with a human-readable hint beside it. Three groups worth telling apart: fix the request, wait and retry, and gone for good.

The request will not be taken as it is.

CodeHTTPWhen
missing_token, invalid_token401No Authorization header, or the token is wrong or revoked
account_blocked403The account is blocked; write to us
file_required400The multipart body carries no file
invalid_multipart, invalid_json400The body does not parse
invalid_url, fetch_failed400, 502The URL is not http(s), does not parse, or the audio behind it did not download
invalid_webhook_url400webhook_url is not http(s)
invalid_call_direction, invalid_max_speakers400Out of range: direction is inbound, outbound or empty; speakers up to 4
unknown_setting400settings carries a key that does not exist; the hint names it and lists the ones that do
upload_failed400The request body is empty or ended halfway
invalid_speaker_name400A voice name outside 1–120 characters
missing_job_id400enroll without job_id
speaker_index_out_of_range400The result has no speaker with that number; the hint says how many it has
invalid_request400A value is not the shape this endpoint expects
method_not_allowed405The same address takes another method; it is named in the Allow header
no_voiceprint422This speaker has no print: the job needs speaker identification on and at least 3 s of speech
insufficient_speech422Less than 3 s of speech was marked for a print
terms_required400PUT /v1/vocabulary without terms
term_too_long, term_too_many_words, too_many_terms400A term over 80 characters, over 4 words, or more than 20 terms
audio_unreadable422The file does not decode
audio_too_long422The recording is over 4 hours
too_long_for_sync422/v1/transcribe with a recording over 5 minutes; use /v1/jobs
file_too_large413Over 200 MiB. When the size is visible in Content-Length the answer comes at once, without waiting for the upload to finish

Wait and retry.

CodeHTTPWhen
rate_limited429Too many requests; see Limits
insufficient_balance429The balance in g does not cover this recording; the hint says what is there and when the top-up comes
too_many_active_jobs429The project already has as many queued and running jobs as it is allowed
result_not_ready409The job is still going; poll GET /v1/jobs/{id} or subscribe to its events
no_runner_for_diarization, no_runner_for_options503No free machine with the model this job needs; retry later
storage_full507We are out of disk on our side; we can see it

Gone for good.

CodeHTTPWhen
job_not_found404No such job in this project — there never was one, it was deleted, or the id is wrong
speaker_not_found404No such voice in this project
job_result_not_found404enroll names a job that is unfinished, not of this project, or already deleted
no_sample404No kept recording of this voice
not_found404The API has no such address
result_expired410The result is over 30 days old and has been deleted
audio_expired410The recording itself is no longer kept, so a voice sample cannot be played

Separately, internal with 500: that is us breaking, not you. It is worth retrying, and if it keeps coming back, write to us — we see it in our own logs.

A malformed id — not a UUID, an empty variable from a template — is a 404, not a 500: there is simply no such job.