Speko Docs
Speech to text

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:

  1. request — a JSON document (at most 1 MiB)
  2. audio — the recording: WAV (PCM s16le), raw PCM s16le with an audio object in the request part, 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

routingobject

The 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.

languagestring

Optional language hint, for example en or es-MX. Without it the provider detects the language.

optionsobject

Optional transcription options: diarization, keywords, noise_reduction, word_timestamps, and provider_options. The same capability rules apply as on the synchronous endpoint.

audioobject

Required 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.

webhookobjectrequired

Where 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_idstring

The job identifier, sttjob_-prefixed. Use it to poll and as the idempotency key for your own processing of deliveries.

audioobject

The 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.secretstring

The 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:

FormatTypical 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=batch lists each model's accepted audio_formats.
  • Idempotency covers the bytes you sent. Resubmitting the same file with the same Idempotency-Key returns 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

Statuserror.codeMeaning
400invalid_requestMalformed parts, missing Idempotency-Key, missing or invalid webhook.url (non-HTTPS, private address, credentials in URL).
400capability_unsupportedThe pinned model has no pre-recorded route, or cannot honor a requested option such as diarization. The hint names the alternative.
413payload_too_largeUpload 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.
415unsupported_mediaNot a supported format, a recognized file that could not be decoded, or the pinned model does not accept this rate/channel layout.
429concurrency_exhaustedYour 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.
504request_timeoutDecoding 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
                     ↘             ↘ failed
statusMeaning
queuedAccepted; waiting for a regional worker.
transcribingChunks are being planned, admitted and transcribed.
deliveringTranscript stored; webhook delivery in progress.
completedTerminal. result is populated. webhook.delivered says whether your receiver accepted it.
failedTerminal. 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.textstring

The 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_scopestring

How 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.modelstring

Present when every chunk was served by the same provider and model. Omitted when failover moved a chunk to another provider; consult chunks.

result.usageobject

duration_ms — the total audio duration metered, summed over chunks. Each chunk bills the duration the provider reports processing, capped at the audio sent.

errorobject

On 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, segments and usage then match what the synchronous endpoint would have returned, but the document shape is still the job result above — route facts live in provider/model/region and chunks[0], not in a route object, so use one decoder for jobs and another for POST /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:

  1. Compare in constant time. crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal — never == on the hex.
  2. Bound the timestamp. Reject a t= more than five minutes from your clock in either direction. A retried delivery is re-signed with a fresh t=, so this bounds replay without rejecting an honest retry.
  3. Deduplicate on Speko-Job-Id.
  4. Verify against the raw bytes. Re-serializing the JSON changes them.
Node.js
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);
}
Python
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

LimitValueExceeded →
Upload size2 GiB (deployment default)413 payload_too_large
Decoded size of a compressed uploadSame ceiling (≈18.6 h of 16 kHz mono)413 payload_too_large
Decode time of a compressed upload15 minutes504 request_timeout
In-flight jobs per organization64429 concurrency_exhausted
In-flight decoded audio per organization32 GiB429 concurrency_exhausted
Transcription attempts per job3job failed
Webhook delivery attempts8 (≈45 min)webhook.delivered: false; result still fetchable
Result retention7 daysresult omitted from the document
Audio retentionDeleted 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 segments or words — see speaker_scope.
  • No job listing or cancellation endpoint; keep the job_id.

On this page