> ## Documentation Index
> Fetch the complete documentation index at: https://developers.mihu.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Asynchronous transcription

> Long files: a job id at once, the result on your webhook

For long files (a 60-minute call recording takes a few minutes to recognise) the request does not have to stay open:
send a `webhook_url`, get a job id at once, and receive the result on your server when it is ready. Works with the
native API and with OpenAI model names (OpenAI SDK: `extra_body`), with every option (`normalize`, `mode`, `diarize`,
`gender_detection`, `hints`, `hint_context`, `speaker_lock`, `voice_isolation`, `response_format` including the OpenAI
formats). Live streaming is unchanged.

| Field | Meaning |
| - | - |
| `webhook_url` | `https://` URL that receives the result (`POST`, JSON). Must resolve to a public address; redirects are not followed |
| `webhook_secret` | Optional (1–256 characters): the webhook is signed with it (`X-Mihu-Signature`) |
| `webhook_metadata` | Optional, up to 4 KB: echoed back as `metadata` (JSON is returned as JSON, anything else as a string; e.g. your call id) |
| `file_url` | Optional, instead of `file`: `https://` URL the server downloads the audio from (up to 1 GB, 10 minutes, at most 5 redirects, public addresses only). Also works without `webhook_url` |

Not combinable with `stream=true`. Maximum audio length: 4 hours.

## 1. Submit → `202`

```bash theme={null}
curl https://stt.eu.mihu.ai/v1/audio/transcriptions -H "Authorization: Bearer $KEY" \
  -F file=@call-60min.mp3 -F model=umay -F language=tr -F diarize=true \
  -F webhook_url=https://example.com/hooks/stt -F webhook_secret=$WEBHOOK_SECRET -F 'webhook_metadata={"call_id": "C-1042"}'
```

The answer comes as soon as the file is uploaded and decoded (a few seconds for an hour of audio):

```json theme={null}
{"id": "6f1d2c3b-9a4e-4b7c-8d2f-1e0a5b9c7d36", "object": "transcription.job", "status": "queued", "duration": 3600.0,
 "created_at": 1793145600,
 "source": {"type": "upload", "filename": "call-1042.wav", "size_bytes": 115200044},
 "request": {"model": "umay", "language": "tr", "response_format": "json", "language_model": true, "normalize": true,
             "diarize": true, "gender_detection": false, "speaker_lock": false, "voice_isolation": false, "hints": false,
             "max_speakers": 8}}
```

Billing: the audio `duration` is counted once, when the job is accepted (this `202`); status reads and the webhook are
not counted. Errors in the request (unknown option, language, bad `webhook_url` or `file_url`, unreadable audio) come
at once as `400`, exactly like a synchronous request.

| Status | Meaning |
| - | - |
| `202` | Accepted; the result follows on the webhook |
| `400` | Invalid field, `webhook_url` / `file_url` not `https` or pointing to a private address, `file_url` download failed |
| `413` | `file_url` larger than 1 GB, upload larger than 1 GB, audio longer than 4 hours |
| `429` | More than 20 jobs of this API key waiting or running; retry when one has finished |
| `503` | Server queue full; retry later |

## 2. Webhook

`POST` to `webhook_url`, `Content-Type: application/json`:

```json theme={null}
{"event": "transcription.completed", "id": "6f1d2c3b-9a4e-4b7c-8d2f-1e0a5b9c7d36", "object": "transcription.job",
 "status": "completed", "message": "Transcription of call-1042.wav (3600 s, tr) completed in 212.4 s.",
 "model": "umay", "language": "tr", "duration": 3600.0, "processing_time": 212.4,
 "created_at": 1793145600, "started_at": 1793145603, "completed_at": 1793145815, "expires_at": 1793149415,
 "source": {"type": "upload", "filename": "call-1042.wav", "size_bytes": 115200044},
 "request": {"model": "umay", "language": "tr", "response_format": "json", "language_model": true, "normalize": true,
             "diarize": true, "gender_detection": false, "speaker_lock": false, "voice_isolation": false, "hints": false,
             "max_speakers": 8},
 "result": {"text": "…", "model": "umay", "language": "tr", "duration": 3600.0, "processing_time": 212.4, "segments": […]},
 "metadata": {"call_id": "C-1042"}}
```

* `result` is the body the same synchronous request answers: the native JSON, or the OpenAI body for OpenAI model
  names (`{"text", "usage", …}`, `verbose_json`, `diarized_json`); `text`, `srt` and `vtt` come as a string. (A job
  decodes the \~28-second parts of a long file one at a time, so a few words of a long file can differ from a
  synchronous run, as they also can between two synchronous runs under different load.)
* `event` says what finished: `transcription.completed` or `transcription.failed` (also in the `X-Mihu-Event` header).
  `message` is the same in one human-readable line. `source` is the audio: `\{"type": "upload", "filename",
  "size_bytes"\}` or `\{"type": "file_url", "url", "filename", "size_bytes"\}` (the URL without its query string, so
  pre-signed credentials are never echoed). `request` lists the options the job ran with, defaults filled in.
  Your `webhook_metadata` comes back as `metadata`.
* Job ids are UUIDs.
* On failure: `"event": "transcription.failed"`, `"status": "failed"` and `"error": {"status": 400, "message": "…"}`
  instead of `result`.
* Headers: `X-Mihu-Webhook-Id` (= job id, the same on every attempt: use it to ignore duplicates), `X-Mihu-Event`, `X-Mihu-Timestamp`
  (Unix seconds), `X-Mihu-Attempt`, and with a secret `X-Mihu-Signature: sha256=&lt;hex HMAC-SHA256(webhook_secret,
  "\{timestamp\}.\{raw body\}")>`.
* Answer with any `2xx` within 10 seconds (store the body, process it later). Anything else, a timeout or a redirect
  counts as a failed attempt; it is retried after **1 s, 10 s, 1 min, 5 min and 30 min** (6 attempts, \~36 minutes),
  then given up (the result can still be read with `GET` until it expires).

Verify the signature (reject old timestamps, compare in constant time):

```python theme={null}
import hashlib, hmac, time

def verify(headers, raw_body: bytes, secret: str, tolerance=300) -> bool:
    ts, sig = headers.get("X-Mihu-Timestamp", ""), headers.get("X-Mihu-Signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
        return False
    want = "sha256=" + hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, want)
```

```js theme={null}
const crypto = require("crypto");

function verify(headers, rawBody /* Buffer */, secret, tolerance = 300) {
  const ts = headers["x-mihu-timestamp"] || "", sig = headers["x-mihu-signature"] || "";
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;
  const want = "sha256=" + crypto.createHmac("sha256", secret).update(ts + ".").update(rawBody).digest("hex");
  return sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));
}
```

Use the raw request body as received (before JSON parsing).

## 3. Status and result: `GET /v1/audio/transcriptions/{id}`

Same API key (tenant) as the request that created the job; any other key gets `404`.

```json theme={null}
{"id": "6f1d2c3b-9a4e-4b7c-8d2f-1e0a5b9c7d36", "object": "transcription.job", "status": "processing", "model": "umay",
 "language": "tr", "duration": 3600.0, "created_at": 1793145600, "started_at": 1793145601, "completed_at": null,
 "processing_time": null, "progress": 0.42, "expires_at": null,
 "source": {"type": "upload", "filename": "call-1042.wav", "size_bytes": 115200044}, "request": {"model": "umay", …},
 "webhook": {"state": "waiting", "attempts": 0, "last_status": null, "last_error": null, "next_attempt_at": null},
 "metadata": {"call_id": "C-1042"}}
```

`status`: `queued` → `processing` → `completed` | `failed`. `progress` (0–1) while processing where it can be measured
(`model=umay`), else `null`. Once completed the answer also has `event`, `message` and `result` (or `error`), and `webhook.state` shows the
delivery: `pending`, `delivered`, `failed`. Polling instead of a webhook is fine (every 10–30 s).

## Data retention

* The audio is deleted as soon as recognition has finished (uploaded and downloaded files right after decoding; the
  decoded audio kept for a queued job is deleted when the job ends).
* The job with its result is deleted **1 hour after completion**; afterwards `GET` answers `404`. Transcripts are not
  kept longer. Everything stays on the server of the region you call.

## OpenAI SDK

```python theme={null}
import json
from openai import OpenAI
client = OpenAI(base_url="https://stt.eu.mihu.ai/v1", api_key=KEY)

raw = client.audio.transcriptions.with_raw_response.create(
    model="gpt-4o-transcribe", file=open("call-60min.mp3", "rb"), language="tr", response_format="verbose_json",
    extra_body={"webhook_url": "https://example.com/hooks/stt", "webhook_secret": SECRET,
                "webhook_metadata": json.dumps({"call_id": "C-1042"}), "diarize": True})
job = json.loads(raw.http_response.text)          # {"id": "<uuid>", "status": "queued", "duration": …, "created_at": …, "source": …, "request": …}
```

The SDK expects a transcription in the answer, so read the `202` from the raw response as above. The webhook's `result`
is the OpenAI body of the chosen `response_format`. `webhook_metadata` may also be a dict in `extra_body` (the SDK sends
it as `webhook_metadata[key]` fields; it comes back as an object with string values).

## Downloading from a URL: `file_url`

```bash theme={null}
curl https://stt.eu.mihu.ai/v1/audio/transcriptions -H "Authorization: Bearer $KEY" \
  -F file_url=https://storage.example.com/calls/C-1042.wav?sig=... -F model=umay -F language=tr \
  -F webhook_url=https://example.com/hooks/stt
```

Pre-signed storage links (S3, GCS, Azure) work; the link must stay valid until the `202` arrives. Without
`webhook_url` the request waits for the result as usual.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.