POST /v1/transcribe. Multipart in, JSON out. Requires a key and a balance.
Request
file, required. The audio. The field nameaudiois accepted as well.model, optional. Defaults tohohe-asr, currently the only model. An unknown id is a 400invalid_request.Idempotency-Keyheader, optional and strongly recommended. See Credits and metering.Authorization: Bearer hohe_..., required.
The body must be multipart/form-data, otherwise the call is a 415 unsupported_content_type. The upload limit is 25 MiB by default, enforced both on the request body and on the file; past it, a 413 file_too_large. An empty file is a 400.
curl -X POST https://api.hohelabs.com/v1/transcribe \
-H "Authorization: Bearer $HOHE_API_KEY" \
-H "Idempotency-Key: meeting-2026-10-15-01" \
-F "model=hohe-asr" \
-F "[email protected]"const form = new FormData()
form.set("model", "hohe-asr")
form.set("file", new Blob([bytes], { type: "audio/m4a" }), "interview.m4a")
const res = await fetch("https://api.hohelabs.com/v1/transcribe", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HOHE_API_KEY}`,
// Safe to retry: the same key never bills twice.
"Idempotency-Key": "meeting-2026-10-15-01",
},
body: form,
})
if (!res.ok) {
const { error } = await res.json()
throw new Error(`${error.code}: ${error.message} (${error.request_id})`)
}
const { text, usage } = await res.json()
console.log(text, usage.credits_charged)import os, requests
with open("interview.m4a", "rb") as audio:
res = requests.post(
"https://api.hohelabs.com/v1/transcribe",
headers={
"Authorization": f"Bearer {os.environ['HOHE_API_KEY']}",
"Idempotency-Key": "meeting-2026-10-15-01",
},
data={"model": "hohe-asr"},
files={"file": audio},
timeout=330,
)
res.raise_for_status()
body = res.json()
print(body["text"])
print(body["usage"]["credits_charged"], "credits")Response
{
"id": "ue_0Nq8c1xWv3kR",
"object": "transcription",
"model": "hohe-asr",
"text": "ጤና ይስጥልኝ፣ ስለ ኢትዮጵያ ቋንቋዎች እንነጋገር።",
"duration_seconds": 4.32,
"usage": {
"unit": "audio_second",
"units": 5,
"free_units_applied": 5,
"credits_charged": 0,
"balance_remaining": 100000,
"quantities": {
"audio_seconds": 4.32,
"characters": 32,
"words": 6
}
}
}id: the usage event id. Quote it when asking about a bill.object: alwaystranscription.model: the model that ran.text: the transcript.duration_seconds: the audio length as the decoder measured it.usage.unit:audio_second.usage.units:duration_secondsrounded up to a whole second. This is what is billed on.usage.free_units_applied: how many of those units the monthly free allowance covered.usage.credits_charged: the cost of this call in credits. Also in thex-credits-chargedheader.usage.balance_remaining: the balance after the charge. A call fully covered by the allowance leaves it unchanged.
Timing and timeouts
The response is not streamed. The model host handles one clip at a time and a long clip can take close to its own duration, so a call may wait behind others before it runs. The server gives the model host 300 seconds by default and answers 504 inference_timeout past that. Set your client timeout above that.
A reliable client retries 429, 503 and 504, honours Retry-After, and reuses one Idempotency-Key per logical job:
async function transcribe(file: Blob, key: string) {
for (let attempt = 0; attempt < 4; attempt++) {
const form = new FormData()
form.set("file", file, "clip.m4a")
const res = await fetch("https://api.hohelabs.com/v1/transcribe", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HOHE_API_KEY}`,
"Idempotency-Key": key, // one key per logical job, reused on every retry
},
body: form,
signal: AbortSignal.timeout(330_000),
})
if (res.ok) return res.json()
const { error } = await res.json()
// 409 idempotency_key_used: the first attempt succeeded. Read your own
// stored result; the transcript is not replayed.
// 503 and 429 are worth retrying. Everything else is not.
const retryable = res.status === 503 || res.status === 429 || res.status === 504
if (!retryable) throw new Error(`${error.code}: ${error.message}`)
const wait = Number(res.headers.get("retry-after") ?? 2 ** attempt)
await new Promise((r) => setTimeout(r, wait * 1000))
}
throw new Error("gave up after 4 attempts")
}Errors
Every failure from this endpoint is listed in Errors. The ones specific to transcription are the 4xx and 5xx codes about the audio and the model host: unsupported_audio, invalid_audio, inference_unavailable, inference_timeout and bad_upstream_response. None of them is charged.