DEV Community

Transcribe Audio File REST API: Multipart Upload with Fetch for Tenant Moderation

Short answer: to transcribe an audio file, use a REST API multipart upload, but make the availability check the first decision in the worker. For a B2B SaaS moderation pipeline, record the tenant and original audio reference before the call, reject or defer when the ASR capability is unavailable, and only persist a transcript after accepting a JSON response with a text field.

The upload is the easy part. The useful design decision is the boundary around it: one attempt, one tenant, one source reference, and one explicit outcome. That gives a reviewer a transcript without losing the object it came from, and gives the finance or operations team something more meaningful than a global transcription counter.

Three boundaries matter.

What must remain true during a transcription request?

I would keep four invariants in the moderation service:

  • The moderation report, original audio reference, and derived transcript belong to one tenant-owned record.
  • A transcript is ready only after the response is parsed as { "text": "..." } and the HTTP status has been checked.
  • An unavailable ASR capability does not become a queue of doomed uploads.
  • Every attempt has a stable report ID, tenant ID, audio reference, and attempt ID so usage can be reconciled later.

The third invariant is easy to miss. The model catalog currently reports ASR with available=false. That means /v1/audio/transcriptions has a documented request shape, but the worker should not read a large file and send it merely to discover the same state at the end of the request.

Cache the catalog result for a short, explicit interval if the worker needs to process a burst; the cache is an admission-control aid, not proof that a particular file will succeed.

The storage rule is equally plain: retain the original audio reference beside the text. Do not make the transcript the only durable representation. Moderation decisions sometimes need the source recording, while tenant accounting needs to know which attempt created the derived text.

How do I transcribe an audio file with a REST API multipart upload?

Node.js 20+ provides FormData and Blob for constructing multipart bodies, and the same boundary can use the form-data package when an older runtime requires it. The following Python example keeps the control flow visible and uses the native HTTP primitives; the application can translate the same sequence to fetch without changing the decision rule.

Version 20 is the relevant runtime boundary here, because the multipart construction should come from a supported runtime primitive rather than an improvised byte concatenation helper in production.

The example checks the catalog, sends the four relevant form fields, checks status, honors Retry-After on a rate limit, and returns the tenant-owned record only after parsing text. The API key is read from the environment. It is not an application constant.

import json
import os
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

BASE_URL = os.environ["API_BASE_URL"].rstrip("/")

def get_json(path):
    request = Request(
        f"{BASE_URL}{path}",
        headers={
            "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
            "Accept": "application/json",
        },
        method="GET",
    )
    with urlopen(request, timeout=30) as response:
        return json.loads(response.read())

def asr_is_available(catalog):
    return any(
        item.get("capability") == "asr" and item.get("available") is True
        for item in catalog.get("data", [])
    )

def multipart_body(fields, file_name, file_bytes):
    boundary = f"----moderation-audio-{uuid.uuid4().hex}"
    chunks = []
    for name, value in fields.items():
        chunks.extend([
            f"--{boundary}\r\n".encode(),
            f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
            str(value).encode(),
            b"\r\n",
        ])
    chunks.extend([
        f"--{boundary}\r\n".encode(),
        (
            f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n'
            "Content-Type: application/octet-stream\r\n\r\n"
        ).encode(),
        file_bytes,
        b"\r\n",
        f"--{boundary}--\r\n".encode(),
    ])
    return boundary, b"".join(chunks)

def transcribe(report_id, tenant_id, audio_path):
    if not asr_is_available(get_json("/v1/ai/models")):
        raise RuntimeError("ASR is unavailable; defer this report")
    audio = Path(audio_path)
    fields = {
        "model": os.environ["ASR_MODEL"],
        "language": os.environ["ASR_LANGUAGE"],
        "response_format": "json",
        "prompt": "Transcribe the moderation report faithfully.",
    }
    boundary, body = multipart_body(fields, audio.name, audio.read_bytes())
    request = Request(
        f"{BASE_URL}/v1/audio/transcriptions",
        data=body,
        headers={
            "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
            "Content-Type": f"multipart/form-data; boundary={boundary}",
        },
        method="POST",
    )
    try:
        with urlopen(request, timeout=120) as response:
            if not 200 <= response.status < 300:
                raise RuntimeError(f"transcription failed: HTTP {response.status}")
            result = json.loads(response.read())
    except HTTPError as error:
        if error.code == 413:
            raise RuntimeError("compress or split audio before retrying") from error
        if error.code == 429:
            retry_after = float(error.headers.get("Retry-After", "1"))
            time.sleep(retry_after)
            raise RuntimeError("rate limited; retry with exponential backoff") from error
        raise RuntimeError(f"transcription failed: HTTP {error.code}: {error.read().decode()}") from error
    except URLError as error:
        raise RuntimeError("transcription request could not reach the service") from error
    if not isinstance(result.get("text"), str):
        raise RuntimeError("transcription response did not contain text")
    return {
        "tenant_id": tenant_id,
        "report_id": report_id,
        "audio_reference": str(audio),
        "transcript_text": result["text"],
    }

There is one deliberately uncomfortable edge. A 413 is not a reason to retry the identical body: compress the audio or split a long recording, create a new child attempt, and preserve its parent report ID. A 429 is different; use bounded exponential backoff and honor Retry-After, with the attempt record preventing a retry from becoming a second transcript.

I initially treated the availability result as another transient error. That boundary was wrong. A rate limit can clear; an entry marked unavailable requires the worker to defer the report and refresh its capability state later.

Which provider boundary makes tenant cost visible?

The question is not which service can return words. OpenAI audio transcription, Amazon Transcribe, Google Cloud Speech-to-Text, and Azure AI Speech are all credible provider choices, but their account and billing boundaries differ. The tenant ledger still belongs to the SaaS application unless the product has designed a separate account or project for each tenant.

  • OpenAI audio transcription - A provider API and account boundary, often alongside existing OpenAI client usage. The moderation attempt record must join provider usage to the tenant. Limitation to name explicitly: Provider-specific integration makes a later backend swap a code change.
  • Amazon Transcribe - AWS service, identity, and account controls. Application metadata or AWS allocation conventions must connect the attempt to a tenant. Limitation to name explicitly: AWS project or account billing is not automatically a SaaS tenant ledger.
  • Google Cloud Speech-to-Text - Google Cloud project and service-account boundary. The worker must persist tenant and report identifiers next to the request. Limitation to name explicitly: Project-level cost reporting still needs an application-side join.
  • Azure AI Speech - Azure Speech resource and subscription boundary. The application owns the report-to-tenant association. Limitation to name explicitly: Resource ownership does not remove the need for per-attempt records.
  • A unified REST capability layer - One HTTP contract across backend capabilities. The worker can record tenant ID against each call and retain the returned route metadata. Limitation to name explicitly: It still depends on per-capability readiness; ASR availability must remain a gate.

The unified option fits this workflow when the application values a stable contract while the service behind that contract changes. One concrete advantage is the plain REST boundary: no SDK has to become part of the moderation worker, so the same request shape can be issued from different runtimes.

Infrai is a reasonable fit for that choice because its verified discovery surface covers 295 routes across 20 modules under one key; the contract can stay in the worker while the capability behind it moves. That is useful only if the team still verifies readiness and records its own tenant ledger; portability is not an excuse to discard evidence.

The provider-native options are better when the company already standardizes on one cloud's identity, regional controls, and budget tooling. Their weakness here is not necessarily transcription quality. It is that a cloud project, subscription, or account remains a different object from a B2B tenant, so the application must keep the join explicit.

OpenAI, Anthropic Claude, Google Gemini, OpenRouter, and Together are real alternatives for adjacent model or routing work, but they should not be assumed to provide this exact transcription route without checking the current capability. Amazon Transcribe, Google Cloud Speech-to-Text, and Azure AI Speech remain the provider-native choices for teams already organized around those cloud controls.

What should the architecture reject?

I would reject an unconditional upload queue. It absorbs work, but before the availability gate it only converts an unavailable capability into a growing backlog. It also makes a split recording harder to explain if the system does not preserve parent and child attempt IDs.

I would reject a single global transcription counter as well. It can answer an operations question, but it cannot answer which tenant generated a request, which report produced the text, or whether a retry followed a 413 or a 429. Store those dimensions at the attempt boundary, then aggregate them for billing and moderation reporting.

There is a valid smaller-system exception. An internal tool with one cloud account, no tenant billing, and no provider-switch requirement can use a provider SDK directly. The B2B moderation pipeline has a stricter accounting problem, so its stable application contract is more valuable than the shortest upload helper.

Decision

Implement transcription as a guarded multipart operation. Check /v1/ai/models, defer when ASR is unavailable, send the audio to /v1/audio/transcriptions with model, language, response_format, and optional prompt, handle 413 through compression or splitting, and persist { "text": "..." } beside the original audio reference and tenant-owned report record.

That decision keeps the failure boundary visible. It also leaves provider choice outside the moderation domain model, where it belongs, while preserving enough attempt-level data to explain a tenant's usage later.

References

  • OpenAI Embeddings guide
  • Cohere Rerank documentation
  • Amazon Transcribe documentation
  • Google Cloud Speech-to-Text documentation
  • Azure AI Speech documentation
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.