Transcription jobs
POST /v1/stt/transcriptions/jobs — transcribe recordings of any length asynchronously, delivered to a signed webhook.
Transcription jobs are the asynchronous counterpart of
POST /v1/stt/transcriptions. You upload a recording,
get a job_id back immediately, and receive the finished transcript at a
webhook you control — or fetch it with GET. Use jobs when any of these is
true:
- the recording is longer than a few minutes, or larger than the selected model's batch limit — jobs split the audio into provider-sized chunks and stitch the results into one transcript;
- you do not want to hold an HTTP request open for the duration of the transcription;
- you want delivery to be retried durably if your receiver is briefly down.
Every job is transcribed through the provider's pre-recorded (batch) API, never through a realtime socket, and every chunk is admitted, metered, and billed like an ordinary request. There is no separate pricing for jobs.
Submit a job
POST https://router.speko.dev/v1/stt/transcriptions/jobs
The body is multipart/form-data with exactly two parts, in this order —
the same shape as the synchronous endpoint plus a webhook object in the
request part:
request— a JSON document (at most 1 MiB)audio— the recording: WAV (PCM s16le), raw PCM s16le with anaudioobject in therequestpart, or one of the compressed formats jobs decode for you (MP3, M4A, OGG, FLAC, WebM, and others)
curl -s https://router.speko.dev/v1/stt/transcriptions/jobs \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F 'request={
"routing": {"mode": "auto", "objective": "quality"},
"language": "en",
"options": {"diarization": true},
"webhook": {"url": "https://example.com/hooks/speko"}
};type=application/json' \
-F 'audio=@board-meeting.wav;type=audio/wav'A compressed recording is sent the same way:
-F 'audio=@board-meeting.mp3;type=audio/mpeg'An Idempotency-Key header is required. Resubmitting the same key with the
same parts returns the original job rather than creating a second one, so a
client that loses the 202 can safely retry the upload. See
Idempotency.
The request part
routingobjectThe routing object. Omit for auto/balanced. Only
models with a pre-recorded route are candidates for jobs; an explicit pin to
a realtime-only model (for example deepgram/flux-general-en) is refused
with 400 capability_unsupported. GET /v1/models?path=batch lists the
eligible models.
languagestringOptional language hint, for example en or es-MX. Without it the
provider detects the language.
optionsobjectOptional transcription options:
diarization, keywords, noise_reduction, word_timestamps, and
provider_options. The same capability rules apply as on the synchronous
endpoint.
audioobjectRequired only for raw PCM uploads. Set encoding to pcm_s16le and provide
sample_rate_hz and channels. For WAV and compressed uploads the Router
derives these values from the file itself; omit the object.
webhookobjectrequiredWhere to deliver the finished job. url is required and must be https://
on port 443 with no credentials in the URL, resolving to a public address.
Redirects are not followed. The same URL is validated again at delivery
time.
Response — 202 Accepted
{
"job_id": "sttjob_rPg1RaFE0wNC368zk3hYcc3u",
"status": "queued",
"created_at": "2026-08-26T15:34:35.967331Z",
"audio": {
"duration_ms": 1823170,
"pcm_bytes": 58341464,
"sample_rate_hz": 8000,
"channels": 2
},
"webhook": {
"url": "https://example.com/hooks/speko",
"secret": "whsec_2sZ63U4vkNwJP0dj7IVEr3wM57KyUFlv8tRAtsoDuZk",
"delivered": false,
"attempts": 0
}
}job_idstringThe job identifier, sttjob_-prefixed. Use it to poll and as the
idempotency key for your own processing of deliveries.
audioobjectThe trusted media facts the Router parsed from your upload — duration,
decoded PCM bytes, rate, channels. These, not any caller-declared values,
drive chunk planning and metering. For a
compressed upload they describe the decoded audio, so
sample_rate_hz is 16000 and channels is 1 whatever the file was.
webhook.secretstringThe per-job signing secret, whsec_-prefixed. It is returned exactly
once, in this response, and never stored by Speko — later documents omit
it. Store it with the job id; you need it to
verify deliveries.
The upload is stored in a regional bucket only for as long as the job runs; it is deleted as soon as the transcript is stored or the job fails.
Audio formats
Jobs accept everything the synchronous endpoint accepts — WAV containing PCM s16le, or described raw PCM s16le — and also decode the formats recordings usually come in:
| Format | Typical extensions |
|---|---|
| MP3 | .mp3 |
| M4A / MP4 / AAC | .m4a, .mp4, .aac |
| AAC (ADTS) | .aac |
| OGG (Opus or Vorbis) | .ogg, .opus, .oga |
| FLAC | .flac |
| WebM / Matroska | .webm, .mka, .mkv |
| AIFF | .aif, .aiff |
| AMR | .amr |
| WMA | .wma |
WAV that is not PCM s16le (24-bit, 32-bit float, WAVE_FORMAT_EXTENSIBLE) | .wav |
The Router identifies the format from the file's contents, not from its name
or the part's Content-Type, and decodes it to 16 kHz mono PCM s16le
before anything else happens. From then on the job is exactly a WAV job of
the decoded audio: the audio object in the 202, chunk planning, the
models that are eligible, and the duration you are billed for all describe
the decoded audio. Multi-channel recordings are mixed down to mono.
A few consequences to plan for:
- The size ceiling applies to the decoded audio. The 2 GiB upload ceiling
also bounds the decoded 16 kHz mono PCM, which is about 18.6 hours. A
compressed file under 2 GiB that decodes to more than that is refused with
413 payload_too_large. Decoded audio is 256 kb/s, so how much a file grows depends on its bitrate: a 128 kb/s MP3 roughly doubles, a 32 kb/s one grows about eightfold. - Decoding has a time budget of 15 minutes per upload, which covers well
over a day of typical MP3. Exceeding it returns
504 request_timeout; split the recording and submit each part as its own job. - Models that accept only other sample rates are not candidates. OpenAI's
transcription models accept only 24 kHz audio, so automatic routing skips
them for compressed uploads, and a job that pins one of them returns
415 unsupported_media. To use them, convert the recording to 24 kHz mono WAV yourself.GET /v1/models?path=batchlists each model's acceptedaudio_formats. - Idempotency covers the bytes you sent. Resubmitting the same file with
the same
Idempotency-Keyreturns the original job.
A file the Router does not recognize, or recognizes but cannot decode
(truncated, DRM-protected, or containing no audio stream), returns
415 unsupported_media; the hint names the detected format or lists the
accepted ones. The synchronous POST /v1/stt/transcriptions stays
WAV / raw PCM only; send compressed recordings to jobs.
Submit errors
| Status | error.code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed parts, missing Idempotency-Key, missing or invalid webhook.url (non-HTTPS, private address, credentials in URL). |
| 400 | capability_unsupported | The pinned model has no pre-recorded route, or cannot honor a requested option such as diarization. The hint names the alternative. |
| 413 | payload_too_large | Upload over the deployment ceiling (2 GiB by default), a compressed upload whose decoded audio is over it, or no eligible model can accept any chunking of the audio. |
| 415 | unsupported_media | Not a supported format, a recognized file that could not be decoded, or the pinned model does not accept this rate/channel layout. |
| 429 | concurrency_exhausted | Your organization already has the maximum number of jobs in flight (64) or in-flight audio (32 GiB). Wait for jobs to finish; this is a per-organization ceiling, not a rate limit. |
| 504 | request_timeout | Decoding a compressed upload exceeded its 15-minute budget. Split the recording. |
Credit is not checked at submit. It is priced and spent where every other
request spends it — at admission, per chunk — so an organization out of credit
receives a job that fails at its first chunk with insufficient_credit,
delivered to the webhook, rather than a rejection at submit.
Fetch a job
GET https://router.speko.dev/v1/stt/transcriptions/jobs/{job_id}
curl -s https://router.speko.dev/v1/stt/transcriptions/jobs/sttjob_rPg1RaFE0wNC368zk3hYcc3u \
-H "Authorization: Bearer $SPEKO_API_KEY"Returns the job document — the same JSON that is POSTed to your webhook,
so one decoder serves both. result is present once the job has completed;
error once it has failed. A job id that does not belong to your
organization answers 404 invalid_request.
Job lifecycle
queued → transcribing → delivering → completed
↘ ↘ failedstatus | Meaning |
|---|---|
queued | Accepted; waiting for a regional worker. |
transcribing | Chunks are being planned, admitted and transcribed. |
delivering | Transcript stored; webhook delivery in progress. |
completed | Terminal. result is populated. webhook.delivered says whether your receiver accepted it. |
failed | Terminal. error carries the normalized code and message. Chunks that had already finished were billed; nothing else was. |
A transient provider failure re-queues the whole job up to three times;
chunks that already finished are reused, not re-billed. A chunk whose
transcript stops well before the end of its audio while speech continues is
treated as a transient provider failure and retried, rather than delivered
incomplete; audio that simply trails off into silence is not affected. A non-retryable
failure (payload_too_large, capability_unsupported, insufficient_credit)
fails the job immediately.
The job document
{
"job_id": "sttjob_rPg1RaFE0wNC368zk3hYcc3u",
"status": "completed",
"created_at": "2026-08-26T15:34:35.967331Z",
"completed_at": "2026-08-26T15:35:44.210881Z",
"audio": { "duration_ms": 1823170, "pcm_bytes": 58341464, "sample_rate_hz": 8000, "channels": 2 },
"webhook": { "url": "https://example.com/hooks/speko", "delivered": true, "attempts": 1 },
"result": {
"text": "Your call has been forwarded. Hello? ...",
"segments": [
{ "text": "Your call has been forwarded.", "start_ms": 60, "end_ms": 1080, "speaker": "0" },
{ "text": "Hello?", "start_ms": 3120, "end_ms": 3480, "speaker": "1" }
],
"provider": "soniox",
"model": "stt-rt-v5",
"region": "us-east-1",
"speaker_scope": "recording",
"chunks": [
{ "index": 0, "start_ms": 0, "end_ms": 1823170, "provider": "soniox", "model": "stt-rt-v5",
"request_id": "rreq_ngIsy31scnsj3UPtaEXO9PxG", "attempt_id": "ratt_KJWDter8J5ceMJAGr9V3OrdR" }
],
"usage": { "duration_ms": 1823170 }
}
}result.textstringThe full transcript in reading order. May legitimately be empty for silent audio.
result.segmentsobject[]Time-aligned spans with start_ms/end_ms measured from the start of the
whole recording — chunk boundaries are invisible here. speaker is
present only when you asked for diarization and the provider labels
speakers; it is the provider's own label carried verbatim. Some models return
whole-text only, in which case segments is omitted.
result.wordsobject[]Per-word timings, present only when the job asked for word_timestamps.
Recording-relative like segments, stitched across chunks in order; a
word's speaker follows the same per-chunk labeling rule as its segment.
result.speaker_scopestringHow far a speaker label reaches. recording: one provider call labeled the
whole recording, so a label means the same speaker everywhere. chunk: the
audio was split; labels are renumbered so no two chunks share a label, but
one person may carry different labels in different chunks. Speko never
fabricates cross-chunk speaker identity — use chunks to decide what to
merge. Omitted when diarization was not requested.
result.chunksobject[]One entry per provider call: the span of the recording it covered, the provider and model that served it, and the request and attempt ids — the same ids that appear in your usage records, so every charge is traceable to a chunk.
result.provider / result.modelstringPresent when every chunk was served by the same provider and model. Omitted
when failover moved a chunk to another provider; consult chunks.
result.usageobjectduration_ms — the total audio duration metered, summed over chunks. Each
chunk bills the duration the provider reports processing, capped at the
audio sent.
errorobjectOn failed: code from the error vocabulary and a
customer-actionable message. Provider internals are never exposed.
The result stays fetchable for 7 days after completion. After that the
document still answers with its status but result is omitted.
Chunking
Jobs exist for audio longer than one provider call can take. The Router plans
chunks against the selected model's batch_audio_limits (from
GET /v1/models?path=batch), after parsing your upload — never against
declared values:
- Boundaries are frame-aligned and prefer the quietest instant near the limit, so words are not cut mid-syllable. Uniform audio cuts at the limit.
- Every chunk, including the last, is at least one second long.
- When the whole recording fits the model's limits, the job is one chunk.
text,segmentsandusagethen match what the synchronous endpoint would have returned, but the document shape is still the job result above — route facts live inprovider/model/regionandchunks[0], not in arouteobject, so use one decoder for jobs and another forPOST /v1/stt/transcriptions. - With
diarization, automatic routing prefers a model whose limits accept the whole recording over a marginally better-ranked one that would force a split, because provider speaker labels are only meaningful within one call. - Failover for a chunk is restricted to models whose limits accept every planned chunk, so a mid-job fallback can never strand an oversized chunk.
Chunk sizes vary widely by provider: a 30-minute recording is one chunk on Deepgram, AssemblyAI, Soniox, ElevenLabs, Speechmatics or Azure MAI-Transcribe; three or four on Meta Muse (10 minutes) or Smallest (10 minutes); four or five on OpenAI (25 MB per upload, which is about 8.7 minutes of its 24 kHz mono audio) or Gemini (8 minutes); ten or eleven on Fish Audio (3 minutes); and 30 or more on Google Chirp 3 (60 seconds). Because cuts land on pauses just before each limit, a split can produce one more chunk than simple division suggests.
Webhook delivery
When a job reaches a terminal state the Router POSTs the job document to
webhook.url:
POST /hooks/speko HTTP/1.1
Host: example.com
Content-Type: application/json; charset=utf-8
Speko-Job-Id: sttjob_rPg1RaFE0wNC368zk3hYcc3u
Speko-Signature: t=1787758525,v1=6af330d581f26bafecec1924b5a5d8fd2a18544878a0e860fcfaa4b17fce2f89
{ "job_id": "sttjob_...", "status": "completed", ... }Answer any 2xx to accept. Any other status — including a 401 you write
because verification failed — is a delivery failure indistinguishable from a
timeout, and spends one attempt.
Retries. Delivery is retried with exponential backoff starting at 30
seconds and capped at 15 minutes, for up to 8 attempts (about 45 minutes in
total). After the last failure the job is still completed or failed with
webhook.delivered: false, and the result remains fetchable with GET.
Delivery is at-least-once. A worker can POST successfully and then fail to
record that it did, in which case the same terminal document is delivered
again. Every redelivery carries the same Speko-Job-Id and the same status, so
treating the job id as the idempotency key of your own processing is
sufficient.
Failed jobs are delivered too, with status: failed and error set, so a
receiver learns about every outcome without polling.
Verifying a delivery
The signature is HMAC-SHA256 over <t>.<raw request body> — the decimal
t= value, one ASCII period, then the body bytes exactly as received — keyed
with the whole whsec_… secret string as ASCII bytes. Do not strip the
prefix or base64-decode it: some generic webhook libraries do, and one of those
will never verify a Speko signature.
Follow four rules:
- Compare in constant time.
crypto.timingSafeEqual,hmac.compare_digest,hmac.Equal— never==on the hex. - Bound the timestamp. Reject a
t=more than five minutes from your clock in either direction. A retried delivery is re-signed with a fresht=, so this bounds replay without rejecting an honest retry. - Deduplicate on
Speko-Job-Id. - Verify against the raw bytes. Re-serializing the JSON changes them.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: Buffer, signature: string, secret: string): boolean {
// Parse "t=<unix>,v1=<hex>"; fields without "=" are ignored. A malformed
// header is a rejection, never an exception.
const parts: Record<string, string> = {};
for (const field of signature.split(",")) {
const eq = field.indexOf("=");
if (eq > 0) parts[field.slice(0, eq).trim()] = field.slice(eq + 1).trim();
}
const t = Number(parts.t);
if (!parts.v1 || !/^[0-9a-f]{64}$/.test(parts.v1) || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret) // the whole "whsec_..." string
.update(`${parts.t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
try:
parts = dict(p.split("=", 1) for p in signature.split(","))
t = int(parts["t"])
except (KeyError, TypeError, ValueError):
return False # malformed header: reject, never raise
v1 = parts.get("v1", "")
# compare_digest requires ASCII strings; a non-hex v1 is a rejection, not an exception.
if len(v1) != 64 or any(c not in "0123456789abcdef" for c in v1) or abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Speko rotates its signing keys without breaking jobs in flight: a job's secret is minted once, at submit, and remains the secret that signs its deliveries.
Limits
| Limit | Value | Exceeded → |
|---|---|---|
| Upload size | 2 GiB (deployment default) | 413 payload_too_large |
| Decoded size of a compressed upload | Same ceiling (≈18.6 h of 16 kHz mono) | 413 payload_too_large |
| Decode time of a compressed upload | 15 minutes | 504 request_timeout |
| In-flight jobs per organization | 64 | 429 concurrency_exhausted |
| In-flight decoded audio per organization | 32 GiB | 429 concurrency_exhausted |
| Transcription attempts per job | 3 | job failed |
| Webhook delivery attempts | 8 (≈45 min) | webhook.delivered: false; result still fetchable |
| Result retention | 7 days | result omitted from the document |
| Audio retention | Deleted when the transcript is stored or the job fails; 1-day backstop | — |
Per-model chunk limits are advertised on GET /v1/models?path=batch; see the
batch limits table.
Regions
A job is transcribed in the region that accepted it — the Speko-Region of
the 202. router.speko.dev is anycast, so that is normally the region
nearest you. Audio and transcript are stored in that region only, and the
webhook is delivered from it. GET from any region resolves the job.
What jobs do not do
- No caller-supplied webhook secrets, and no non-443 webhook ports.
- No fetching audio from a URL; upload the file itself. Playlists (HLS, concat) are not followed.
- No preserving the original sample rate or channels of a compressed upload; it is always decoded to 16 kHz mono.
- No cross-chunk speaker merging, on
segmentsorwords— seespeaker_scope. - No job listing or cancellation endpoint; keep the
job_id.