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 — WAV containing PCM s16le, or raw PCM s16le with an audio object in the request part
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

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, 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 uploads the Router derives these values from the container.

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.

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.

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), or no eligible model can accept any chunking of the audio.
415unsupported_mediaNot a WAV/PCM s16le container, 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.

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

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

On this page