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

Building an annotation or evaluation UI (like eval.lesan.ai)? This is the endpoint you want. Store the 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}/audioGET /v1/media/{file_id}
Tied toA transcription jobNothing — standalone asset
LifetimeThe jobDurable until you delete it
Primary useFetch the audio of a job you ranPlay back any uploaded file, ASR or not
OwnershipJob ownerAccount (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-url then POST /v1/uploads/{file_id}/finalize). Both return a file_id and 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

ParamTypeDefaultDescription
redirectinteger0If 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

FieldTypeDescription
file_idstringThe stable media asset id (echoes the path).
urlstringShort-lived signed URL for a direct GET / playback. Supports Range.
expires_inintegerSeconds until the signed URL expires (3600 = 1 hour).
content_typestring | nullMIME type of the stored object, if known.
size_bytesinteger | nullObject size in bytes, if known.

Warning

The signed 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 a pending media asset.
  • PUT the bytes to upload_url (no Bearer key — the URL is signed).
  • POST /v1/uploads/{file_id}/finalize → verifies the object landed, promotes the asset from pending to active, backfills the authoritative size/content-type, and returns a ready-to-use signed URL.
bash
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

Keep the API key on your server. In a browser eval tool, proxy the 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 url lasts one hour; the file_id is durable. Re-resolve whenever you need a fresh URL.

Errors

HTTPCodeWhen it fires
401authentication_errorMissing or invalid API key.
404MEDIA_NOT_FOUNDUnknown file id, or the asset is not owned by your account.
404OBJECT_NOT_FOUNDFinalize called but no object was actually stored for that id.
500SIGNED_URL_FAILEDThe storage backend could not mint a signed URL. Safe to retry.

See also