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.
https://api.omi.health/v1/audio/transcriptionsDirect upload: 200 inline or 202 job
https://api.omi.health/v1/jobsCreate upload slot
/v1/jobs/{job_id}/completeFreeze upload and enqueue
/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
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
succeededas “text is final,” anddecoration.statusas 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.statusis stillprocessing, re-download on your normal poll cadence until it is gone or until thestale_aftertimestamp passes. - On the rare enrichment failure the job stays
succeededand 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 stillprocessing; there is no second callback on enrichment. - Jobs without
diarizeare 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
| Field | Default | Notes |
|---|---|---|
model | omi-medical-1 | Only the flagship is available on this route. |
filename | required | Must match the declared content type. |
content_type | required | Supported audio MIME type. |
content_length_bytes | required | Validated before an upload slot is created. |
language | key default, then en | Explicit tag or automatic per-utterance routing; available on every plan. |
vocabulary | none | Array of up to 1,000 terms; lists above 50 select the 50 most relevant terms. |
patterns | none | Request-scoped; available on every plan. |
profile | default | Omit or send default. Historical standard and turbo inputs are deprecated aliases with identical behavior. |
diarize | false | Attach speaker labels and word timestamps to the final transcript. |
max_speakers | 4 | Integer 1–4. |
webhook_url | none | HTTPS 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_urlhave 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=trueto receive terminal JSON up to 4 MiB inresult.content. Larger results retainresult.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/endin seconds like inline responses. Legacystart_ms/end_msfields 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
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
language_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.