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:
{"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:
| Field | What it is |
|---|---|
file | Audio: mp3, wav, ogg/opus, m4a, flac, webm. Up to 200 MiB |
settings | JSON with settings for this request on top of the project's, optional |
vocabulary | A JSON array of terms for this request in place of the project vocabulary, optional |
webhook_url | Where 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.
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.
| Event | What it carries |
|---|---|
segments | An array of finished utterances in order, in the same shape as in the result |
state | The job changed state: data is the same job GET /v1/jobs/{id} returns |
done | The 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
{"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.
{
"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.
| Request | What it does |
|---|---|
GET /v1/vocabulary | The 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 |
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:
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
| Request | What it does |
|---|---|
GET /v1/speakers | List 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}/enroll | Add a print from a finished job: {"job_id", "speaker_index"} or {"job_id", "segment_indexes": [...]} |
DELETE /v1/speakers/{id}/voiceprints | Delete all prints, keep the name |
GET /v1/speakers/{id}/sample | Up 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=candidate | Delete 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:
{"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:
{"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
| What | How much |
|---|---|
| File size | 200 MiB (209,715,200 bytes) |
| Recording length | up to 4 hours |
Synchronous /v1/transcribe | recordings up to 5 minutes |
| Uploads per minute per token | 60 |
| Read requests per minute per token | 3000 |
| Management requests per minute per token | 600 |
| Result retention | 30 days |
| Balance | topped up to 240 g every midnight Tashkent time |
| Projects per account, tokens per project | 3, 10 |
| Voices per project, prints per voice | 500, 10 |
| Vocabulary terms | 1 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.
| Code | HTTP | When |
|---|---|---|
missing_token, invalid_token | 401 | No Authorization header, or the token is wrong or revoked |
account_blocked | 403 | The account is blocked; write to us |
file_required | 400 | The multipart body carries no file |
invalid_multipart, invalid_json | 400 | The body does not parse |
invalid_url, fetch_failed | 400, 502 | The URL is not http(s), does not parse, or the audio behind it did not download |
invalid_webhook_url | 400 | webhook_url is not http(s) |
invalid_call_direction, invalid_max_speakers | 400 | Out of range: direction is inbound, outbound or empty; speakers up to 4 |
unknown_setting | 400 | settings carries a key that does not exist; the hint names it and lists the ones that do |
upload_failed | 400 | The request body is empty or ended halfway |
invalid_speaker_name | 400 | A voice name outside 1–120 characters |
missing_job_id | 400 | enroll without job_id |
speaker_index_out_of_range | 400 | The result has no speaker with that number; the hint says how many it has |
invalid_request | 400 | A value is not the shape this endpoint expects |
method_not_allowed | 405 | The same address takes another method; it is named in the Allow header |
no_voiceprint | 422 | This speaker has no print: the job needs speaker identification on and at least 3 s of speech |
insufficient_speech | 422 | Less than 3 s of speech was marked for a print |
terms_required | 400 | PUT /v1/vocabulary without terms |
term_too_long, term_too_many_words, too_many_terms | 400 | A term over 80 characters, over 4 words, or more than 20 terms |
audio_unreadable | 422 | The file does not decode |
audio_too_long | 422 | The recording is over 4 hours |
too_long_for_sync | 422 | /v1/transcribe with a recording over 5 minutes; use /v1/jobs |
file_too_large | 413 | Over 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.
| Code | HTTP | When |
|---|---|---|
rate_limited | 429 | Too many requests; see Limits |
insufficient_balance | 429 | The balance in g does not cover this recording; the hint says what is there and when the top-up comes |
too_many_active_jobs | 429 | The project already has as many queued and running jobs as it is allowed |
result_not_ready | 409 | The job is still going; poll GET /v1/jobs/{id} or subscribe to its events |
no_runner_for_diarization, no_runner_for_options | 503 | No free machine with the model this job needs; retry later |
storage_full | 507 | We are out of disk on our side; we can see it |
Gone for good.
| Code | HTTP | When |
|---|---|---|
job_not_found | 404 | No such job in this project — there never was one, it was deleted, or the id is wrong |
speaker_not_found | 404 | No such voice in this project |
job_result_not_found | 404 | enroll names a job that is unfinished, not of this project, or already deleted |
no_sample | 404 | No kept recording of this voice |
not_found | 404 | The API has no such address |
result_expired | 410 | The result is over 30 days old and has been deleted |
audio_expired | 410 | The 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.