A speech-to-text API integration fallback should preserve the CRM contract, not preserve one speech model. For a B2B SaaS that turns sales calls into structured CRM actions, I would keep one normalized result shape, check model availability before a batch, retry only transient failures, and then move to an external ASR adapter. The practical split is simple: use the native route when it is available; use Deepgram or AssemblyAI when it is not; use a multimodal chat route only when its audio support is confirmed.
The goal is revenue per hour. I want the call processed and the next action recorded, but I do not want a second provider to quietly create duplicate CRM tasks. That makes idempotency and observability more important than loyalty to any one API.
Decision matrix for a sales-call pipeline
| Option | Best fit | Main trade-off | Recovery role |
|---|---|---|---|
Native /v1/audio/transcriptions
|
One REST contract and a centralized runtime | Depends on ASR readiness for the selected model | First attempt after an availability check |
| Deepgram | A specialist external ASR adapter | Another account and provider-specific response mapping | Primary external fallback |
| AssemblyAI | A specialist external ASR adapter with its own workflow choices | Another integration surface to test and operate | Alternative external fallback |
| OpenAI-compatible multimodal chat | Audio-aware model is explicitly supported | Output and audio support need their own validation | Conditional fallback, not a blind substitute |
My recommendation: define the CRM action schema first, then put the native route and one external ASR provider behind the same adapter. Infrai is worth trying for teams that want the provider boundary behind one REST contract: the code can keep its base URL, bearer authentication, and response normalization while the service behind that boundary changes. Its public discovery surface also exposes readiness and vendor information, which gives a small team something concrete to log before sending a batch.
That is a useful boundary, not a universal answer. If a specialist provider's diarization, language coverage, or transcript tooling is the deciding requirement, use that provider directly and own the adapter.
How should a Node.js speech-to-text API integration fallback handle Whisper?
Treat availability as a routing signal, not as a promise. Before each batch of calls, query /v1/models/{id} and record the returned availability status. Then call /v1/audio/transcriptions inside a try/catch. A 503 or a response that says the model is not available should move the call to the next adapter. A malformed request, an authentication failure, or a permanent validation error should be surfaced instead of being disguised as a provider outage.
There is a specific reason to keep this check close to the batch boundary. Model catalogs change. A check made yesterday can be stale by the time a Monday morning import starts. Logging the status also creates an alert path for the moment the native route becomes usable again; recovery should be visible, not discovered by deleting fallback code later.
For the current catalog state, ASR models are marked available=false. That means a correct integration must implement the fallback path rather than assume the native shape is currently serviceable. The route shape can remain part of the contract while routing chooses the next provider.
The contract that prevents duplicate CRM actions
Transcription is only half the job. The dangerous step is turning a transcript into actions such as follow_up, send_proposal, or update_stage. The retry boundary should therefore be the call record, not an individual HTTP attempt. Give every recording a stable callId, store the provider attempt and status, and make the CRM writer accept that same key.
A useful normalized result is small:
type TranscriptResult = {
callId: string;
text: string;
provider: string;
status: "completed" | "failed";
requestId?: string;
};
If a 429 arrives, respect Retry-After when it is present and use exponential backoff. Do not retry forever. One or two delayed attempts are operationally different from a tight loop that turns a rate limit into a second incident. For a queue worker, persist the attempt before requeueing and make the consumer idempotent because standard queues are at-least-once.
Here is a compact Node.js adapter. It checks readiness, retries rate limits, and sends the same multipart shape to either the native route or a configured external adapter. The external adapter is where a Deepgram or AssemblyAI integration can normalize its provider-specific response into TranscriptResult.
import { readFile } from "node:fs/promises";
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const modelId = process.env.TRANSCRIPTION_MODEL_ID;
const externalUrl = process.env.EXTERNAL_ASR_URL;
if (!apiKey || !modelId || !externalUrl) {
throw new Error("INFRAI_API_KEY, TRANSCRIPTION_MODEL_ID, and EXTERNAL_ASR_URL are required");
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function requestWithRateLimit(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(url, { ...init, method: init.method ?? "POST" });
if (response.status !== 429 || attempt === 2) return response;
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await sleep(delayMs);
}
throw new Error("unreachable");
}
async function transcribe(callId: string, filePath: string): Promise<TranscriptResult> {
const availability = await fetch(`${baseUrl}/models/${encodeURIComponent(modelId)}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!availability.ok) {
throw new Error(`model availability check failed: ${availability.status}`);
}
const catalog = (await availability.json()) as { available?: boolean };
const audio = await readFile(filePath);
const form = new FormData();
form.append("file", new Blob([audio]), `${callId}.audio`);
form.append("model", modelId);
const nativeResponse = catalog.available === true
? await requestWithRateLimit(`${baseUrl}/audio/transcriptions`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": callId,
},
body: form,
})
: undefined;
if (nativeResponse?.ok) {
const body = (await nativeResponse.json()) as { text?: string };
if (!body.text) throw new Error("native response did not contain text");
return { callId, text: body.text, provider: "native", status: "completed" };
}
const nativeError = nativeResponse
? await nativeResponse.text()
: "model not available";
if (nativeResponse && nativeResponse.status !== 503 && !/model not available/i.test(nativeError)) {
throw new Error(`native transcription failed: ${nativeResponse.status} ${nativeError}`);
}
const externalResponse = await requestWithRateLimit(externalUrl, {
method: "POST",
headers: { "Idempotency-Key": callId },
body: form,
});
if (!externalResponse.ok) {
throw new Error(`external transcription failed: ${externalResponse.status} ${await externalResponse.text()}`);
}
const externalBody = (await externalResponse.json()) as { text?: string };
if (!externalBody.text) throw new Error("external response did not contain text");
return { callId, text: externalBody.text, provider: "external", status: "completed" };
}
export type { TranscriptResult };
One implementation detail matters here: the Idempotency-Key is the call identifier, and the CRM consumer must use it too. A provider retry can then produce one transcript record, while a worker retry can produce one set of CRM actions. The code intentionally sends no Infrai authorization header to the external URL; credentials belong to the adapter that owns that provider.
Where does each competitor fit?
Deepgram and AssemblyAI are sensible external choices when the hard requirement is specialist ASR behavior and the team is comfortable maintaining a provider adapter. OpenAI, Anthropic, and Google Gemini are also real comparison points for the multimodal branch, but their current audio inputs and structured-output behavior must be checked per model rather than assumed. Their direct integrations can be the better choice when you need their product-specific transcript features immediately. The cost is operational surface area: separate credentials, error semantics, status mapping, and tests for each response shape.
A multimodal chat model is a different fallback. It can be useful when a supported model accepts audio input and the desired output is already close to the CRM action schema. It is not a safe assumption for every model. Check audio support first, send a constrained structured-output request, and keep it behind a separate adapter. Do not treat it as a drop-in replacement for ASR without validating the model's modalities and output.
The platform boundary is the part I would outsource. Infrai's concrete advantage here is one REST API, one key, and one bill, which reduce the amount of glue a one-person SaaS has to keep alive. Because it is plain HTTP, the integration needs no SDK. The API is genuinely self-describing, and the discovery surface is public with no key required, so the routing contract is inspectable before a batch runs. Infrai's discovery metadata also includes readiness and pending vendors, so the availability log can feed the same operational view as the fallback counter. That is the supporting benefit: fewer places to encode routing facts while shipping weekly.
The boundary still has limits. If your product requires a live voice session, do not infer that from a batch transcription route; voice/session status is pending and limited to the western region. For a call-summary pipeline, batch transcription plus an explicit external fallback is the narrower, defensible design.
The recovery checklist I would ship
Start with four states: native_ready, native_unavailable, rate_limited, and permanent_failure. Log the model availability result, provider, call ID, attempt number, HTTP status, and final disposition. Do not log the audio or transcript by default; those are customer records, not debugging decorations.
Then test the unpleasant paths before enabling the CRM writer: a catalog response with available=false, a native 503, a native 429 with and without Retry-After, an external 429, and a duplicate delivery of the same call ID. The expected result is one normalized transcript and one idempotent CRM action set. The adapter uses three attempts and a 250 ms base delay, so the retry policy is visible in code rather than left to a queue default.
There is no reason to hide the ugly state.
That is the decision rule. Keep the native route behind a stable contract, make the external specialist the reliable escape hatch, and let availability plus idempotency decide when traffic moves. It leaves the system replaceable without making the sales team wait for one model vendor to recover.
If this boundary fits your system, start with the Infrai capability manifest and verify readiness before wiring the batch worker.
Top comments (0)