Short answer: use a direct multipart file upload for the first Node.js integration, but put it behind a tiny adapter that records tenant, region, audio duration, queue time, and provider request ID. That is the least complex path from logistics MP3 or WAV recordings to searchable text, and it preserves the evidence needed to compare speed and per-tenant cost later.
Start with this decision table. The fastest integration is the one whose operating model fits the job, not the one with the shortest demo snippet.
| Option | Pick this when | Main operational trade-off |
|---|---|---|
| Synchronous multipart upload | Short depot notes need an immediate transcript | The caller stays coupled to request latency and retry behavior |
| Asynchronous transcription job | Recordings are longer or arrival volume is uneven | A job ID, polling or callbacks, and durable state become part of the contract |
| Queued batch pipeline | A known collection can finish later | Better scheduling flexibility, but it is the wrong shape for interactive answers |
Govern tenant data before choosing a transport
Treat upload, transcription, and knowledge indexing as three observable stages. In words, the diagram is: depot recorder to regional intake; regional intake to transcription adapter; adapter to a tenant-scoped transcript store; transcript store to the private knowledge index. Keep the original object reference and the transcript record separate. Then a failed index write doesn't force another audio upload, and a transcript can be re-indexed under a corrected metadata policy.
The adapter should accept MP3 and WAV at its boundary only after validating the filename, declared media type, file size, tenant ID, and requested processing region. It should return your own stable job record even if the selected API can answer synchronously. That small indirection matters. Application code can ask one question — “what happened to this recording?” — without knowing a provider's response shape.
Don't log the audio or transcript body. Log identifiers and measurements: tenant_id, recording_id, region, media_type, audio_bytes, audio_duration_ms, queue_ms, transcription_ms, provider_request_id, attempt, and outcome. The duration is especially useful because bytes are a poor stand-in for speech length across encodings. Keep the logs structured, attach the same correlation ID to metrics, and alert on outcomes rather than raw request counts.
A practical status vocabulary is deliberately boring: accepted, uploading, transcribing, indexing, ready, and rejected. Map an API's states into those terms inside the adapter. A 429 belongs in telemetry as a throttled attempt; it should not silently become a second billable job. Use an idempotency key derived from the tenant and recording identity when the chosen API supports one, and otherwise deduplicate before submission in your own job store.
Direct multipart upload is the best first probe for a dispatcher recording a short delivery exception and expecting the private knowledge base to answer soon after. It gives the team one request path to exercise in both US and EU deployments. Keep processing regional: choose the endpoint from the tenant's policy before opening the file, record that decision, and prevent a fallback from crossing the allowed boundary. There is a catch. A synchronous client is not suitable when callers can't hold a connection for the full processing window or when bursts would exhaust application workers. Switch to an asynchronous job when recordings are long, traffic is spiky, or retries must survive a Node.js process restart. Persist accepted before calling the external API, store the external request ID in the same tenant-scoped row, and let a worker advance the job while the user-facing service reads your row instead of the upstream API. Polling is simpler to reason about than callbacks in a first integration because all state changes enter through one worker; callbacks can reduce needless polls, but they require signature verification, replay handling, and a public ingress. Neither choice is universally faster. I'm not sure which will produce lower end-to-end latency for a given API without a regional trial using representative files; published feature lists can't resolve queue behavior for your workload. Batch processing is a separate option, not a disguised interactive API. The OpenAI Batch API guide documents an asynchronous collection model with a completion window, which is useful evidence for how batch work differs from online calls. Stick with an online job contract when a depot worker is waiting. Pick batch only when the knowledge refresh has a deadline measured by a scheduled workflow rather than a live interaction.
Fast is contextual.
Now follow one delivery-exception recording through the system. Tenant north-yard sends a WAV file to its allowed EU intake. The intake creates a recording identity before submission, the adapter adds the same correlation identity to the upload, and the worker records the upstream request identity without exposing the audio in logs. When transcription finishes, the indexer writes chunks that retain the recording identity and tenant key. A question over the private knowledge base can now be traced backward from an answer to chunks, transcript, recording, upload attempt, and regional decision. If the answer is late, queue and stage timings show where time accumulated. If a usage charge arrives, the provider request identity joins it to the tenant job. If a deletion request arrives, the recording identity locates the derived records. One trace supports operations, accounting, and data lifecycle work because those concerns share stable identities; one giant end-to-end latency metric supports none of them well.
Measure it.
Instrument one upload boundary in TypeScript
The adapter below is intentionally generic. STT_UPLOAD_URL_US and STT_UPLOAD_URL_EU are deployment configuration, so the example doesn't invent a vendor route. The code uses Node.js fetch, FormData, and Blob, emits one structured event, and returns the provider response for a schema-specific parser to validate. Add that parser before production; a successful HTTP status alone does not prove that a transcript has the fields your indexer requires.
import { readFile, stat } from "node:fs/promises";
import { extname } from "node:path";
import { randomUUID } from "node:crypto";
type Region = "US" | "EU";
type UploadInput = {
tenantId: string;
recordingId: string;
path: string;
region: Region;
};
const contentTypes: Record<string, string> = {
".mp3": "audio/mpeg",
".wav": "audio/wav",
};
function endpointFor(region: Region): string {
const value = process.env[`STT_UPLOAD_URL_${region}`];
if (!value) throw new Error(`Missing upload URL for ${region}`);
return value;
}
async function postWithBackoff(url: string, form: FormData, idempotencyKey: string) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.STT_API_KEY ?? ""}`,
"idempotency-key": idempotencyKey,
"x-correlation-id": idempotencyKey,
},
body: form,
});
if (response.status !== 429 || attempt === 2) return response;
const delayMs = 500 * 2 ** attempt + Math.floor(Math.random() * 250);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Retry loop ended without a response");
}
export async function uploadRecording(input: UploadInput): Promise<unknown> {
const extension = extname(input.path).toLowerCase();
const contentType = contentTypes[extension];
if (!contentType) throw new Error("Only MP3 and WAV files are accepted");
const correlationId = randomUUID();
const fileInfo = await stat(input.path);
const bytes = await readFile(input.path);
const form = new FormData();
form.set("file", new Blob([bytes], { type: contentType }), input.recordingId + extension);
const startedAt = performance.now();
const response = await postWithBackoff(endpointFor(input.region), form, correlationId);
const elapsedMs = Math.round(performance.now() - startedAt);
console.info(JSON.stringify({
event: "speech_upload_finished",
tenant_id: input.tenantId,
recording_id: input.recordingId,
correlation_id: correlationId,
region: input.region,
media_type: contentType,
audio_bytes: fileInfo.size,
request_ms: elapsedMs,
outcome: response.ok ? "accepted" : "rejected",
status_code: response.status,
provider_request_id: response.headers.get("x-request-id"),
}));
if (!response.ok) throw new Error(`Transcription request rejected: ${response.status}`);
return response.json();
}
When should Node.js reject a speech-to-text API for MP3 and WAV uploads?
Run the same contract test against the configured US and EU endpoints with representative MP3 and WAV fixtures. Assert media validation, tenant isolation, region selection, response-schema validation, deduplication, and retry classification. Measure upload, queue, transcription, and indexing separately. One end-to-end timer hides the stage that actually moved.
For per-tenant cost visibility, join usage to the durable job record rather than estimating from aggregate invoices. Store the provider's billable unit and billed quantity when the response or usage export supplies them; don't manufacture a cost from bytes. A useful dashboard starts with completed audio duration, rejected jobs, retries, and recorded charge grouped by tenant and region. The alert should fire when charge attribution is missing, because unattributed usage is an accounting defect even when transcription succeeds.
Indexing needs the same tenant key. If transcripts enter Postgres, pgvector can add vector similarity search alongside relational metadata, but the extension does not replace row-level tenant controls, retention rules, or the application check that binds a question to an allowed corpus. Keep transcript chunks traceable to recording_id so an answer can point back to its source and deletion can remove every derived chunk.
This design doesn't select a universal fastest speech API. Network distance, queue behavior, file duration, encoding, language mix, and regional availability all affect the result, and the supplied feature page for any service is not a workload benchmark. Run the adapter with your own depot recordings in each permitted region, then choose using p95 time to ready, rejection rate, retry rate, and percentage of usage attributed to a tenant.
Direct upload is not suitable for live streaming captions; use a streaming contract for that job. Batch is not suitable when a person is waiting for an answer. A managed API may also be the wrong choice when policy requires audio to remain entirely inside infrastructure you operate; in that case, evaluate a self-hosted transcription runtime and accept the deployment and capacity work that comes with it.
The final rule is short: pick the simplest processing mode that meets the response deadline, passes the regional policy, and accounts for every recording by tenant. Keep the adapter. Keep the evidence.
Top comments (0)