Core workflow

Asynchronous transcription

Use direct upload for short clips and OpenAI compatibility. For long recordings, create a job first and upload directly to signed storage for an immediate job handle, fewer proxy hops, and safer retries.

POSThttps://api.omi.health/v1/audio/transcriptions

Direct upload: 200 inline or 202 job

POSThttps://api.omi.health/v1/jobs

Create upload slot

POST/v1/jobs/{job_id}/complete

Freeze upload and enqueue

GET/v1/jobs/{job_id}

Poll status and result

The direct-upload front door

Send the same multipart request to /v1/audio/transcriptions for files up to exactly 100,000,000 bytes. Audio up to 60.000 seconds without a callback returns an OpenAI-compatible 200 response. Audio above 60.000 seconds—or any request with webhook_url—returns a 202 job envelope with Location and Retry-After: 5. Files longer than 60 seconds use the asynchronous-optimized processing pipeline automatically. The direct endpoint still has to receive, validate, and durably store the complete request body before it can return the 202 job envelope.

One job accepts up to 2 hours of audio (the job envelope's max_audio_seconds field is the authoritative live value). Long recordings are segmented and reassembled server-side — send the whole consultation as one file and one job; you never need to split audio yourself.

Prefer presigned upload for long recordings

The direct endpoint is convenient, but it cannot return a job ID while the client is still uploading the body. For recordings at or above 60 seconds—especially remote clients or files measured in tens of megabytes—use the job flow below. The job ID arrives before the upload, and the bytes travel directly to EU object storage. The same path supports files up to 1 GiB.

1. Create a job

curl https://api.omi.health/v1/jobs \
  -H "Authorization: Bearer $OMI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "omi-medical-1",
    "filename": "consultation.flac",
    "content_type": "audio/flac",
    "content_length_bytes": 1234567,
    "language": "en",
    "vocabulary": ["Hepcludex", "Tinel"],
    "diarize": true,
    "max_speakers": 2
  }'

The response contains a presigned POST form in upload, plus owner-scoped complete_url and poll_url. Send every returned upload field exactly as provided.

2. Upload, complete, and poll

# Install once: python -m pip install requests
import os, time, requests
from urllib.parse import urlparse

api = "https://api.omi.health"
headers = {"Authorization": f"Bearer {os.environ['OMI_API_KEY']}"}
audio_path = "consultation.flac"
size = os.path.getsize(audio_path)

# 1. Reserve an owner-scoped upload.
job = requests.post(
    f"{api}/v1/jobs",
    headers=headers,
    json={
        "model": "omi-medical-1",
        "filename": "consultation.flac",
        "content_type": "audio/flac",
        "content_length_bytes": size,
        "language": "en",
    },
).json()

# 2. Upload directly to signed storage.
with open(audio_path, "rb") as audio:
    upload = requests.post(
        job["upload"]["url"],
        data=job["upload"]["fields"],
        files={"file": ("consultation.flac", audio, "audio/flac")},
    )
upload.raise_for_status()

# 3. Freeze the upload and enqueue transcription.
requests.post(job["complete_url"], headers=headers).raise_for_status()

# 4. Long-poll server-side. This returns as soon as the job changes state,
#    so there is no blind client sleep after completion. Results up to 4 MiB
#    are returned inline, avoiding a second API round trip.
while True:
    state_response = requests.get(
        job["poll_url"],
        headers=headers,
        params={"wait": 20, "include_result": "true"},
        timeout=30,
    )
    state_response.raise_for_status()
    state = state_response.json()
    partial = (state.get("transcription") or {}).get("partial_result_url")
    if state["status"] == "running" and partial:
        preview = requests.get(partial, headers=headers).json()
        print("provisional:", preview["text"][:80], "...")  # may revise
    if state["status"] == "succeeded":
        result_envelope = state["result"]
        result = result_envelope.get("content")
        if result is None:
            result_url = result_envelope["download_url"]
            result_host = (urlparse(result_url).hostname or "").lower()
            result_headers = headers if result_host in {
                "api.omi.health", "api.eu.omi.health"
            } else {}
            result_response = requests.get(result_url, headers=result_headers)
            result_response.raise_for_status()
            result = result_response.json()
        if (result.get("decoration") or {}).get("status") == "processing":
            time.sleep(0.25)  # text is final; speaker labels are still attaching
            continue
        print(result["text"])
        break
    if state["status"] == "failed":
        raise RuntimeError(state["error"]["message"])

The state machine is awaiting_upload → accepted → running → succeeded, with failed as the terminal error state. Use bounded server-side long polling with ?wait=20&include_result=true. It returns when state changes and prevents a completed job from sitting unseen behind a client sleep.

Text-first delivery on diarized jobs

Jobs reach succeeded as soon as the transcript text is final. If you requested diarize, speaker labels and word timestamps may still be attaching for a short window after that: the first result you download can carry decoration: {"status": "processing", "stale_after": …} and word_timing_available: false instead of the speaker fields. The transcript text in that result is already final and safe to use.

  • Treat succeeded as “text is final,” and decoration.status as the speaker-label lifecycle. When enrichment lands — typically well under a minute — the stored result is replaced in place; download the result again (each poll may mint a fresh URL) to receive the decorated transcript.
  • If decoration.status is still processing, re-download on your normal poll cadence until it is gone or until the stale_after timestamp passes.
  • On the rare enrichment failure the job stays succeeded and decoration resolves to {"status": "unavailable", "review_required": true} — the transcript is delivered, speaker labels are not.
  • Webhook consumers: the callback fires once, at succeeded. If your workflow needs speaker labels, fetch the result on receipt and apply the same re-download rule when decoration is still processing; there is no second callback on enrichment.
  • Jobs without diarize are unaffected — their first result is complete.

Long jobs may additionally expose provisional text while still running: the status envelope can carry transcription: {"status": "processing", "partial_result_url": …, "result_revision": …, "completed_audio_seconds": …, "provisional": true}. The partial artifact covers roughly the first minutes of audio, may be revised, and is replaced entirely by the final result at succeeded. Pollers that ignore the block keep today's behavior exactly; never treat a partial as the final transcript.

Job request fields

FieldDefaultNotes
modelomi-medical-1Only the flagship is available on this route.
filenamerequiredMust match the declared content type.
content_typerequiredSupported audio MIME type.
content_length_bytesrequiredValidated before an upload slot is created.
languagekey default, then enExplicit tag or automatic per-utterance routing; available on every plan.
vocabularynoneArray of up to 1,000 terms; lists above 50 select the 50 most relevant terms.
patternsnoneRequest-scoped; available on every plan.
profiledefaultOmit or send default. Historical standard and turbo inputs are deprecated aliases with identical behavior.
diarizefalseAttach speaker labels and word timestamps to the final transcript.
max_speakers4Integer 1–4.
webhook_urlnoneHTTPS callback on port 443; requires a separate signing secret.

Retention and result access

  • Uploaded audio is deleted when processing completes or the job terminally fails — audio never waits out a retention window.
  • Result artifacts are retained until your configured expiry: default 24 hours, configurable from 1 to 72 hours in the console. Owner-scoped job metadata and expiry tombstones may remain for up to 72 hours so expired jobs return a stable response. Both api.omi.health and api.eu.omi.health are served from the EU (eu-central-1). Jobs with a webhook_url have an effective 9-hour minimum so the final delivery retry can still be picked up.
  • Uploads and results are owner-scoped; another API key cannot poll your job.
  • Request include_result=true to receive terminal JSON up to 4 MiB in result.content. Larger results retain result.download_url.
  • Send the same API-key Authorization header when downloading an Omi API result URL. Storage-backed result links expire after 15 minutes.
  • Verbose long-job results expose start/end in seconds like inline responses. Legacy start_ms/end_ms fields remain during migration.
  • Each successful poll may mint a fresh result URL while the result is retained.
  • After the configured expiry, polling returns result: {"expired": true}. An already-expired signed URL returns the storage provider’s native expiry response.
  • Do not reuse a presigned form or upload a different file into an existing job.

Automatic language detection on long audio

Async language: "auto" is available on every plan when the detector is healthy. Explicit-language jobs are available on every plan as well.

Omitted language uses bounded detection

Omi detects the dominant language from the first speech-bearing 30-second window, then transcribes the original full recording with the selected language. The completed job declareslanguage_source: "detected". Explicitlanguage: "auto" enables per-utterance routing and optional language hints.

Idempotent job creation

Send an Idempotency-Key of 1–255 characters when creating an async job. Keys are scoped to the API credential and retained for 24 hours. Repeating the same request returns the original job; reusing the key for different audio or options returns idempotency_conflict. On an inline 200 request, the header is accepted and ignored.