Media Hosting
Upload an audio file once, get a stable file id, and re-resolve it to a short-lived signed URL for browser playback (with seek/Range support) any time — without running transcription.
Media hosting is a durable, transcription-decoupled storage layer. You upload a file once, receive a stable file_id, and can resolve that id to a fresh short-lived signed URL whenever you need to stream the audio back — for example to play a clip in a browser while a reviewer reads its transcript. The audio never expires from your control, but each playback URL is short-lived and self-authenticating.
💡 Tip
file_id next to your transcript/annotation records; call GET /v1/media/{file_id} to get a fresh URL each time you render the player. No need to keep the audio bytes or manage GCS yourself.How it differs from transcription uploads
A normal transcription job stores its audio only for as long as the job lives. A media asset is a first-class, standalone record: it is keyed on your account (so it survives API-key rotation), it is never garbage-collected with a job, and you can re-resolve it to a playback URL without ever running ASR on it.
| GET /v1/transcriptions/{id}/audio | GET /v1/media/{file_id} | |
|---|---|---|
| Tied to | A transcription job | Nothing — standalone asset |
| Lifetime | The job | Durable until you delete it |
| Primary use | Fetch the audio of a job you ran | Play back any uploaded file, ASR or not |
| Ownership | Job owner | Account (survives key rotation) |
The two-step model
Every integration is the same two moves: upload once to get a file_id, then resolve on demand to get a playback URL.
- 1. Upload — either proxy the bytes through the API (
POST /v1/uploads) or upload straight to storage with a presigned PUT (GET /v1/uploads/signed-urlthenPOST /v1/uploads/{file_id}/finalize). Both return afile_idand register a durable media asset. - 2. Resolve — call
GET /v1/media/{file_id}to get a signed URL valid for one hour. Signed URLs support HTTP Range requests, so browser<audio>seek works out of the box.
Quickstart: upload, then resolve
Upload a file (bytes proxied through the API):
# 1. Upload -> { "file_id": "...", "file_url": "lesan://uploads/...", ... }
curl "https://asr.lesan.ai/v1/uploads" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "file=@clip.wav;type=audio/wav"
# 2. Resolve the file_id to a signed playback URL (valid 1h)
curl "https://asr.lesan.ai/v1/media/FILE_ID" \
-H "Authorization: Bearer YOUR_API_KEY"
# -> { "file_id": "...", "url": "https://storage.googleapis.com/...", "expires_in": 3600, ... }GET /v1/media/{file_id}
Resolve a media asset id to a short-lived signed URL. Requires a read-scoped key. Ownership is enforced on the owning account; a missing or non-owned id returns 404 (so existence is never leaked). ADMIN keys bypass the ownership check.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
| redirect | integer | 0 | If 1, respond with a 302 redirect straight to the signed URL instead of a JSON body. Handy for server-side fetches and curl; see the browser note below. |
Response
| Field | Type | Description |
|---|---|---|
| file_id | string | The stable media asset id (echoes the path). |
| url | string | Short-lived signed URL for a direct GET / playback. Supports Range. |
| expires_in | integer | Seconds until the signed URL expires (3600 = 1 hour). |
| content_type | string | null | MIME type of the stored object, if known. |
| size_bytes | integer | null | Object size in bytes, if known. |
⚠ Warning
url is self-authenticating — do not attach your Bearer key when fetching it. Conversely, GET /v1/media/{file_id} itself does require the Bearer key, so you cannot point a raw <audio src> at ?redirect=1 — the browser can't send the auth header. In a browser, fetch the JSON first, then set audio.src to the url it returns (see the eval example below).Uploading straight to storage (SPA / large files)
To avoid proxying bytes through the API — useful for large files or browser uploads — use a presigned PUT and then finalize:
GET /v1/uploads/signed-url?filename=clip.wav&content_type=audio/wav→ returns{ upload_url, file_id, method: "PUT", headers }and reserves apendingmedia asset.PUTthe bytes toupload_url(no Bearer key — the URL is signed).POST /v1/uploads/{file_id}/finalize→ verifies the object landed, promotes the asset frompendingtoactive, backfills the authoritative size/content-type, and returns a ready-to-use signed URL.
import requests
API = "https://asr.lesan.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
data = open("clip.wav", "rb").read()
# 1. Reserve a slot + get a presigned PUT URL
s = requests.get(
f"{API}/v1/uploads/signed-url",
headers=HEADERS,
params={"filename": "clip.wav", "content_type": "audio/wav"},
).json()
# 2. PUT the bytes straight to storage (no Bearer — the URL is signed)
requests.put(
s["upload_url"],
data=data,
headers={"Content-Type": "audio/wav", **s.get("headers", {})},
).raise_for_status()
# 3. Finalize -> active asset + a signed playback URL
final = requests.post(
f"{API}/v1/uploads/{s['file_id']}/finalize", headers=HEADERS
).json()
print(final["file_id"], final["url"])Integrating with an evaluation / annotation tool
The canonical pattern for a review UI: upload once at ingest, persist the file_id alongside your own records, then resolve on demand each time a reviewer opens an item. Because resolution mints a fresh 1-hour URL every call, you never store or refresh signed URLs yourself — you only ever store the stable file_id.
import requests
API = "https://asr.lesan.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
def ingest_sample(path: str, reference_text: str) -> dict:
"""Upload a clip and record it in the eval dataset. Store file_id, not a URL."""
with open(path, "rb") as f:
up = requests.post(
f"{API}/v1/uploads", headers=HEADERS,
files={"file": (path, f, "audio/wav")},
)
up.raise_for_status()
file_id = up.json()["file_id"]
# Persist file_id (durable) alongside your eval fields — never the signed URL.
return db.samples.insert({
"file_id": file_id,
"reference": reference_text,
"hypothesis": None, # filled in later by a model run
"score": None, # filled in by a reviewer
})
def playback_url(file_id: str) -> str:
"""Mint a fresh 1-hour URL when a reviewer opens the sample."""
r = requests.get(f"{API}/v1/media/{file_id}", headers=HEADERS)
r.raise_for_status()
return r.json()["url"]ℹ Note
GET /v1/media/{file_id} call through your own backend (which holds the key) and hand the resulting url to the front-end. The signed URL is safe to send to the browser — it expires in an hour and grants read-only access to that one object.Ownership & expiry
- Account-scoped — assets are owned by the account behind your key, not the key itself, so rotating or reissuing keys never orphans your uploads.
- 404, not 403 — resolving an id your account doesn't own returns
404 MEDIA_NOT_FOUND, identical to a nonexistent id, so existence isn't leaked. ADMIN keys bypass ownership. - URLs expire, ids don't — the signed
urllasts one hour; thefile_idis durable. Re-resolve whenever you need a fresh URL.
Errors
| HTTP | Code | When it fires |
|---|---|---|
| 401 | authentication_error | Missing or invalid API key. |
| 404 | MEDIA_NOT_FOUND | Unknown file id, or the asset is not owned by your account. |
| 404 | OBJECT_NOT_FOUND | Finalize called but no object was actually stored for that id. |
| 500 | SIGNED_URL_FAILED | The storage backend could not mint a signed URL. Safe to retry. |
See also
- ASR Guide — transcribe an uploaded file by id.
- Media Probe — inspect a remote URL before ingesting it.
- Audio Formats — supported upload containers.
- Authentication — API keys and scopes.