Skip to content
Developer hub v1
Browse documentation
Documentation

api reference

Speech to text

POST /v1/transcribe, field by field.

POST/v1/transcribeAuthenticated

POST /v1/transcribe. Multipart in, JSON out. Requires a key and a balance.

Request

  • file, required. The audio. The field name audio is accepted as well.
  • model, optional. Defaults to hohe-asr, currently the only model. An unknown id is a 400 invalid_request.
  • Idempotency-Key header, 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.

bash
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]"
transcribe.ts
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)
transcribe.py
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

json
{
  "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: always transcription.
  • 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_seconds rounded 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 the x-credits-charged header.
  • 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:

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

All documentation