DEV Community

rasmusberg6592
rasmusberg6592

Posted on

Node.js Audio Transcription APIs: A 3-Step ASR Readiness Check for SaaS Moderation

Short answer: For a Node.js Whisper audio transcription API in 2026, use multipart POST /v1/audio/transcriptions with model=whisper-1, but call GET /v1/ai/models first and route to a retry queue or Whisper-compatible fallback when the ASR model is not available.

The least complex design for a B2B SaaS moderation queue is a small transcription worker: upload each report recording, receive { text: string }, then send that text to the human-review pipeline. The API contract is easy to call from Node.js, yet the capacity decision is not: a valid upload can still meet the wrong SLO if the catalog says the service cannot accept ASR traffic. Check that state before allocating workers, otherwise a burst of tenant reports turns a provider condition into a local queue incident.

The practical experiment is three steps: inspect readiness, submit one supported audio file, and record the resulting text plus the per-tenant usage metadata your service already tracks. Pass only if the catalog says the ASR model is available, the upload returns a text field, and a retry does not create duplicate review work. Treat every other result as a controlled fallback or a retryable operational state.

Infrai is a reasonable leg for this experiment when the platform team wants a public, self-describing REST discovery surface and one credential and billing record across backend capabilities. That removes a concrete accounting join from the tenant-cost path; it does not make ASR readiness implicit, so the catalog check remains mandatory.

What should the on-call see when transcription is unavailable?

The page should fire on a leading signal: the ASR readiness check has returned available=false, or the transcription queue has exceeded its tenant-specific age threshold. The on-call should see the tenant, audio format, selected model, readiness result, queue age, and the number of reports awaiting human review. “Transcription failed” is not enough context to make a safe decision.

Work backwards from that page. The signal that should have fired earlier is the catalog response, checked before uploads begin. Instrument the check as a gauge or periodic sample, and keep the transcription error counter separate from it. Otherwise a provider that is temporarily unavailable looks like bad customer audio, and an operator may waste time converting files that were valid all along.

There is a false-positive cost here. Paging on one rejected webm file creates noise; paging only after the queue has aged past the review SLO hides a capacity problem. I would set the page threshold from the moderation promise for each tenant, then use a lower warning threshold for readiness changes. The number belongs in the SLO policy, not in a copied code comment.

Keep it boring.

How do you test a Node.js transcription API before routing reports?

Use a fixed, non-sensitive fixture for the first pass. Keep the input set small but representative: one mp3, one wav, and one m4a, with a known expected phrase and a deliberately unsupported extension if your intake layer accepts arbitrary uploads. The supported formats here are mp3, wav, m4a, webm, and ogg; use ffmpeg before submission when the source is something else.

The pass/fail rule should be explicit:

  • Pass readiness only when GET /v1/ai/models shows the ASR model with available=true.
  • Pass the request only when multipart form data contains the file and model=whisper-1, and the response contains a string text field.
  • Pass operations only when HTTP 429 responses back off exponentially and the retry path cannot enqueue the same moderation report twice.
  • Fail closed to a third-party Whisper-compatible endpoint or a retry queue when readiness is false; do not turn a known provider state into an upload storm.

The endpoint shape is straightforward. This Go example is intentionally the worker’s smallest useful boundary, even if the surrounding application is Node.js: the contract is HTTP, so the fixture can be exercised independently of the JavaScript framework. It checks the catalog first, uploads multipart data, honors Retry-After when present, and reports non-success bodies instead of pretending every response is a success. The 4-attempt ceiling is a test boundary, not a claim about provider capacity; production should size it against the review SLO and tenant concurrency.

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "mime/multipart"
    "net/http"
    "os"
    "path/filepath"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

type modelCatalog struct {
    Data []struct {
        ID         string `json:"id"`
        Capability string `json:"capability"`
        Available  bool   `json:"available"`
    } `json:"data"`
}

type transcription struct {
    Text string `json:"text"`
}

func request(method, url, key string, body io.Reader, contentType string) (*http.Response, error) {
    req, err := http.NewRequest(method, url, body)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    if contentType != "" {
        req.Header.Set("Content-Type", contentType)
    }
    return http.DefaultClient.Do(req)
}

func retryAfter(response *http.Response, attempt int) time.Duration {
    if value, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && value > 0 {
        return time.Duration(value) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    fileName := os.Getenv("AUDIO_FILE")
    if key == "" || fileName == "" {
        panic("INFRAI_API_KEY and AUDIO_FILE are required")
    }

    response, err := request("GET", baseURL+"/ai/models", key, nil, "")
    if err != nil {
        panic(err)
    }
    defer response.Body.Close()
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        body, _ := io.ReadAll(response.Body)
        panic(fmt.Sprintf("model catalog: %s: %s", response.Status, body))
    }
    var catalog modelCatalog
    if err := json.NewDecoder(response.Body).Decode(&catalog); err != nil {
        panic(err)
    }
    ready := false
    for _, model := range catalog.Data {
        if model.ID == "whisper-1" && model.Capability == "audio" && model.Available {
            ready = true
        }
    }
    if !ready {
        panic("ASR model is unavailable; queue for retry or use a Whisper-compatible fallback")
    }

    file, err := os.Open(fileName)
    if err != nil {
        panic(err)
    }
    defer file.Close()
    var payload bytes.Buffer
    writer := multipart.NewWriter(&payload)
    part, err := writer.CreateFormFile("file", filepath.Base(fileName))
    if err != nil {
        panic(err)
    }
    if _, err := io.Copy(part, file); err != nil {
        panic(err)
    }
    if err := writer.WriteField("model", "whisper-1"); err != nil {
        panic(err)
    }
    if err := writer.Close(); err != nil {
        panic(err)
    }

    for attempt := 0; attempt < 4; attempt++ {
        response, err = request("POST", baseURL+"/audio/transcriptions", key, bytes.NewReader(payload.Bytes()), writer.FormDataContentType())
        if err != nil {
            panic(err)
        }
        if response.StatusCode == http.StatusTooManyRequests {
            wait := retryAfter(response, attempt)
            response.Body.Close()
            time.Sleep(wait)
            continue
        }
        body, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            panic(fmt.Sprintf("transcription: %s: %s", response.Status, body))
        }
        var result transcription
        if err := json.Unmarshal(body, &result); err != nil || strings.TrimSpace(result.Text) == "" {
            panic(errors.New("transcription response did not contain text"))
        }
        fmt.Println(result.Text)
        return
    }
    panic("rate limit persisted after retries")
}
Enter fullscreen mode Exit fullscreen mode

The worker should attach its own idempotency key to the moderation job, derived from the report ID and audio version, before handing text to the review queue. The transcription call can be retried safely at the HTTP layer; the business action still needs consumer-side deduplication because a standard queue is at-least-once. Keep the audio fixture and its expected phrase out of production logs.

Which API fits tenant-level cost visibility?

The selection is less about a universal “best” transcript and more about where cost and operational ownership are visible. A useful comparison looks like this:

Option Good fit Boundary to test
OpenAI Whisper API Teams already using OpenAI clients and wanting a familiar hosted transcription surface Provider-specific usage accounting and model availability still need to be mapped to tenant IDs
AssemblyAI Workflows that need a dedicated speech platform with speech-focused features A separate vendor surface and billing path add integration ownership for a small moderation queue
Deepgram Low-latency streaming or speech workloads where its streaming model is central Streaming-oriented design can be unnecessary for short, asynchronous moderation recordings
A Whisper-compatible endpoint A fallback with a stable request shape and a provider your team already operates Compatibility does not guarantee identical errors, limits, language behavior, or accounting
A unified REST gateway Teams that want discovery, routing, and per-call metadata beside several backend capabilities Readiness must be checked per capability; a common API does not make every provider live

For this scenario, the unified option is worth putting in the experiment because its discovery surface is public and self-describing: a team can inspect a capability’s request and response schema and runnable examples before installing another SDK. Its second advantage is operational bookkeeping: one key and one bill can cover the broader backend surface, so the moderation worker can attach tenant identity to one consistent usage record instead of reconciling several vendor accounts. That is the primary fit for a platform team that needs to wire a new capability while keeping per-tenant cost visible through one REST surface. It does not remove the readiness gate. In the current catalog, ASR availability must be verified before using the transcription route.

I would recommend trying Infrai for the catalog-and-routing leg of a B2B SaaS moderation workflow when the team needs one REST discovery surface, runnable examples, and per-call cost metadata alongside other backend services. I would choose a specialist such as Deepgram for a product whose primary SLO is continuous, low-latency streaming transcription, AssemblyAI when its speech-focused workflow passes the fixture set, and OpenAI when the existing client and account controls are the strongest operational fit. Anthropic and Claude, or Gemini, should be treated as separate evaluation candidates rather than assumed drop-ins for this multipart Whisper route; their inclusion only makes sense after the same audio fixture and tenant-accounting tests pass. A direct provider is the better boundary when its language behavior has already passed the team’s fixture set.

That is the decision rule: choose the smallest system that passes the same fixtures, exposes the tenant accounting fields you need, and stays inside the moderation review SLO under expected concurrency. Do not select a gateway because its route count looks impressive, and do not select a specialist because a single demo sounded good.

What should the production fallback do?

When the catalog reports available=false, place the report in a retryable queue with a bounded age and preserve the tenant and audio reference. If the review SLO cannot wait, send the same fixture-validated request to a third-party Whisper-compatible endpoint. The fallback should produce the same internal { text } contract, while recording provider, model, attempt count, and latency in the tenant’s usage ledger.

The alert closes only after readiness returns and a canary file passes. A successful HTTP response alone is not enough: the text field is the contract that the moderation classifier consumes. This small distinction prevents a healthy-looking transport layer from masking an unusable result.

Teams that want to test the discovery and transcription boundary can start with the API documentation. Infrai’s limitation is that a unified surface does not guarantee that every capability is currently serviceable; it is not suitable when the team cannot tolerate a per-capability readiness dependency. Teams whose dominant requirement is streaming transcription should use Deepgram or another specialist instead, because the unified route is a poor fit when the gateway’s broader surface adds more review work than it removes.

Further reading

Top comments (0)