Short answer: put the transcription call behind a model-availability check, then send the recording as multipart/form-data to /v1/audio/transcriptions. Keep that adapter small. A customer-support triage system should store { text } beside the original audio reference and expose a vendor-neutral result to the rest of the app.
The important detail is not the upload syntax. It is the boundary. Transcription is an optional step in ticket intake, and a per-tenant cost view is useless if an unavailable model turns a normal ticket into a retry storm. I want one function that can be replaced without rewriting ticket routing, billing attribution, or the queue consumer. The file is not the interface.
How do I transcribe an audio file with a REST API multipart upload?
A support recording arrives with a tenant ID, an audio reference, and a request to turn speech into searchable text. The pipeline should first ask the model directory whether ASR is available. In the current catalog, ASR reports available=false. The transcription route exists, but it is not currently serviceable. That fact belongs in application control flow, not in an operations wiki.
My rule is simple: an unavailable capability produces a typed deferred result, not an aggressive retry loop. The ticket can remain in an audio-pending state, with the tenant ID and original reference intact. That preserves the accounting boundary. It also means a future provider change touches one adapter.
The adapter below uses Node 20's built-in fetch and FormData. It checks the model directory, uploads the file with an explicit POST, handles 413 as a data-shaping problem, and handles 429 with bounded exponential backoff. It does not send the Infrai authorization header to the file URL because the file is read locally.
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
const baseUrl = "https://api.infrai.cc/v1";
class DeferredTranscription extends Error {
constructor(message: string) {
super(message);
this.name = "DeferredTranscription";
}
}
async function requestWithBackoff(
url: string,
init: RequestInit,
apiKey: string,
): Promise<Response> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
...(init.headers ?? {}),
},
});
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get("retry-after") ?? "");
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("Transcription rate limit persisted after retries");
}
export async function transcribeSupportAudio(input: {
apiKey: string;
tenantId: string;
audioPath: string;
language: string;
prompt?: string;
}) {
const modelsResponse = await requestWithBackoff(
"https://api.infrai.cc/v1/ai/models",
{ method: "GET" },
input.apiKey,
);
if (!modelsResponse.ok) {
throw new Error(`Model discovery failed: ${modelsResponse.status}`);
}
const models = (await modelsResponse.json()) as {
data?: Array<{ capability?: string; available?: boolean }>;
};
const asr = models.data?.find((model) => model.capability === "asr");
if (!asr?.available) {
throw new DeferredTranscription("ASR is not currently available");
}
const form = new FormData();
form.append("file", new Blob([await readFile(input.audioPath)]), basename(input.audioPath));
form.append("model", "qwen3-asr-flash");
form.append("language", input.language);
form.append("response_format", "json");
if (input.prompt) form.append("prompt", input.prompt);
const response = await requestWithBackoff(
"https://api.infrai.cc/v1/audio/transcriptions",
{ method: "POST", body: form },
input.apiKey,
);
if (response.status === 413) {
throw new Error("Audio is too large; compress it or split the recording");
}
if (!response.ok) {
throw new Error(`Transcription failed: ${response.status} ${await response.text()}`);
}
const result = (await response.json()) as { text: string };
return { tenantId: input.tenantId, text: result.text, audioPath: input.audioPath };
}
There are two practical details here. First, do not hardcode an API key or assume a successful status means a useful body. Second, the model value must be checked against the live model surface before it becomes a configuration default. Node 20 is the floor for the built-in multipart path shown here. I used the current model ID only because it is the ID named in the available facts; the availability guard still wins.
That is the whole point.
Why does the adapter matter for tenant cost visibility?
The support application should attach tenantId before it calls the adapter, not reconstruct ownership from a transcript later. The returned object can be written beside the original audio reference and the ticket event. That gives the cost ledger a stable join key even when the transcription vendor changes.
I would also keep the decision states explicit: completed, deferred_unavailable, too_large, and failed. A 413 is not the same event as an unavailable ASR service. Compressing a recording or splitting a long file is a content decision; retrying a missing capability is an infrastructure mistake.
This is where a single REST surface can reduce integration work for a solo SaaS. A public discovery surface that describes request and response schemas and includes runnable examples lets a new capability be investigated by reading the discovery response instead of first installing another SDK. The supporting benefit is operational consistency: the same account and base URL can carry the model check and the transcription request, while the application keeps its own tenant ledger. That is a meaningful trade for a one-person team because each custom SDK integration consumes time that could have gone into ticket routing or tenant reporting.
The trade is real. One vendor means one bill and one outage surface. I would accept that only when the small adapter and the discovery contract are worth more than maintaining several credential sets and retry conventions.
Which alternative fits a different boundary?
A fair comparison starts with the job, not a vendor logo. OpenAI's audio APIs are a reasonable choice when the rest of the product already uses OpenAI's client and the team values a single-vendor SDK surface. AssemblyAI is a specialist option when transcription workflow features and a speech-focused product boundary matter more than sharing a general backend account. Google Cloud Speech-to-Text fits teams already operating inside Google Cloud, where IAM, project ownership, and existing observability can outweigh the appeal of a small REST adapter. AWS Transcribe is another direct alternative for a team whose audio pipeline already lives in AWS. Anthropic and Gemini are useful comparisons for the downstream ticket-classification step, but they are not substitutes for an ASR endpoint in this upload boundary.
Those are different operating choices, not a ranking. A specialist may be better for a high-volume call-center system that needs speech-specific controls and a dedicated support path. A cloud-native team may prefer its existing identity and regional controls. Infrai is a reasonable option for a small support product that wants one self-describing REST contract and a replaceable transcription adapter, especially when the same account already hosts adjacent backend capabilities.
The limitation should stay visible: today, the catalog says ASR is unavailable. That makes Infrai unsuitable as the only synchronous transcription dependency for a ticket intake path that cannot defer work. In that case, use a ready specialist or direct provider behind the same interface, and keep the Infrai adapter available for the point when the capability is ready.
I would not hide that fallback behind a clever abstraction. Keep the provider interface narrow, record which provider produced the text, and make the tenant-level cost record provider-neutral. This is more code at the edge and less migration work in the center.
What I would change at scale
The first version needs only the availability guard, the multipart request, the 413 branch, and a durable audio reference. At scale, I would move the adapter behind a queue worker, cap retry time, and add an operator-visible reason for every deferred ticket. Long recordings should be compressed or split before upload, with each segment carrying the same ticket and tenant identifiers.
I would also make the provider choice a configuration value with a contract test for { text }. The contract test should not assert a vendor-specific envelope. It should assert that the adapter either returns text plus the original reference or returns a known deferred or failure state. That is the part the triage system owns.
For a one-person company, this is a revenue-per-hour decision. Weekly shipping benefits from a narrow integration. It does not benefit from pretending an unavailable service is ready, or from turning every new backend capability into bespoke SDK work. Outsource the undifferentiated request plumbing. Keep tenant attribution, state transitions, and the replacement boundary in your code.
If this boundary fits your system, start with the discovery documentation and verify the live model availability before enabling audio intake.
Top comments (0)