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— WAV containing PCM s16le, or raw PCM s16le with anaudioobject in therequestpart
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'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, 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 uploads the Router derives these
values from the container.
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.
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.
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), or no eligible model can accept any chunking of the audio. |
| 415 | unsupported_media | Not a WAV/PCM s16le container, 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. |
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 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.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 or ElevenLabs, four chunks on OpenAI (25 MB per upload) or Smallest (10 minutes), and 35 on Google Chirp 3 (60 seconds).
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 |
| 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 audio formats beyond the synchronous endpoint's (WAV / raw PCM s16le).
- No cross-chunk speaker merging — see
speaker_scope. - No job listing or cancellation endpoint; keep the
job_id. - No word-level timestamps yet; segments are utterance-level.