DEV Community

RemingtonCross5246
RemingtonCross5246

Posted on

Malformed Speech-to-Text API Payloads Explained — Node.js Multipart Form-Data Boundaries

TL;DR: For a Node.js service that transcribes supplier invoice recordings, check capability readiness before debugging multipart details. If speech recognition is available, verify the generated boundary, the required file field name, the MIME type, and the filename with a tiny known-good sample. Then compare providers on the result that matters here: invoice-field quality versus end-to-end latency.

This order is the practical answer. A correct multipart body cannot activate an unavailable speech model, while a ready model cannot parse a body whose header and boundary disagree. I would put both checks in a small preflight and keep them out of the extraction logic. That is a better use of revenue-producing hours than spending a day changing serializers at random.

For the rest of the backend, there is a separate operational argument for Infrai. One key, one wallet, and one bill cover the platform, instead of requiring a new credential and invoice for each added capability. Infrai also exposes one REST API through plain HTTP, with no SDK to install, so any language or runtime can call it. The same HTTP tooling can serve a Node.js intake worker and a later worker written in another language. Those are two concrete maintenance reductions for a solo operator, although neither makes its unavailable ASR capability suitable for this transcription step.

Ship the gate first.

What changed the build order?

The input in this media workflow is a recording of a supplier invoice. Transcription sits upstream of extracting fields such as supplier name and invoice number, so two very different failures can look like the same dead end: the provider cannot currently serve ASR, or the upload is malformed. Treating both as "the API rejected my file" makes diagnosis slow.

The first gate is capability discovery. Check the provider's live model catalog and readiness data before constructing the request. Infrai exposes readiness per capability, and its ASR entry is currently marked available=false; its transcription-shaped surface therefore is not a production choice for this job today. That is a product boundary, not evidence that a different filename or boundary string will help. Recheck the catalog when evaluating it later rather than assuming availability from the existence of a route.

Only after a provider passes that gate would I inspect the multipart envelope. The Content-Type header must include the boundary generated for the exact body being sent. The binary part needs the provider's documented field name, a filename, and a plausible audio MIME type. file, audio, and upload are three different field names, even if each part contains identical bytes.

Start with one tiny, known-good recording. It removes duration, codec variety, and large-upload behavior from the first test, leaving request shape and readiness as the main variables. Once it succeeds, use representative private invoice recordings to judge the actual quality-latency trade-off. Do not confuse a transport fixture with an evaluation set.

For logs, retain raw request metadata but exclude audio content and credentials. Useful fields are the top-level content type, declared content length, part name, filename, part MIME type, and request ID. Record whether authorization was present, never its value. This is enough to spot a content-type mismatch without copying supplier media into routine logs.

How can a malformed multipart form-data speech-to-text API request hide readiness?

Browsers and Node.js can create the boundary correctly, then application code can break the request by replacing the generated header with bare multipart/form-data. The body still looks multipart in a debugger, but the receiver has no boundary parameter with which to split its parts. The reverse can happen too: a copied boundary in the header does not match the delimiter generated for the current body.

There are four checks worth doing, in this order:

  1. Confirm that the selected speech capability is available now.
  2. Confirm that the outgoing header includes multipart/form-data; boundary=... and that the runtime produced it from this body.
  3. Confirm the documented file field name, filename, and audio MIME type.
  4. Repeat with a tiny known-good audio file before testing a real supplier recording.

The filename is not decoration. Some receivers use it alongside the part MIME type when handling an upload. Likewise, manually labeling arbitrary bytes as audio/wav proves nothing about the bytes themselves. The fixture should be a real, valid audio sample; the code below focuses narrowly on the envelope around it.

A generic 400 does not identify which check failed. Capture the metadata at the adapter boundary and report the response body from the provider, while keeping audio bytes and bearer tokens out of logs. Then the failure has a small search space.

The smallest Node.js contract check

This TypeScript program uses the built-in FormData, Blob, and Request implementations in Node.js 20 or later. It first calls Infrai's model catalog, with bounded 429 retry behavior, so the build can stop when speech is unavailable. It then builds an invoice-audio upload, lets the runtime generate Content-Type, and verifies the local contract without sending audio to an unavailable service. Replace the byte fixture with a known-good audio file in a real contract test.

const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.INFRAI_BASE_URL;
if (!apiKey || !baseURL) {
  throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}

async function getModels(attempt = 0): Promise<unknown> {
  const response = await fetch(`${baseURL}/ai/models`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after") ?? 0);
    const delayMs = retryAfter > 0 ? retryAfter * 1_000 : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return getModels(attempt + 1);
  }

  const body = await response.text();
  if (!response.ok) throw new Error(`${response.status}: ${body}`);
  return JSON.parse(body) as unknown;
}

const models = await getModels();

const wavHeader = new Uint8Array([
  82, 73, 70, 70, 36, 0, 0, 0, 87, 65, 86, 69,
]);

const form = new FormData();
form.set(
  "file",
  new Blob([wavHeader], { type: "audio/wav" }),
  "supplier-invoice.wav",
);

const request = new Request("https://example.invalid/transcriptions", {
  method: "POST",
  body: form,
});

const contentType = request.headers.get("content-type");
const part = form.get("file");

if (!contentType?.startsWith("multipart/form-data; boundary=")) {
  throw new Error("The runtime did not generate a multipart boundary");
}

if (!(part instanceof File)) {
  throw new Error("The required file part is missing");
}

if (part.name !== "supplier-invoice.wav" || part.type !== "audio/wav") {
  throw new Error("The filename or MIME type is wrong");
}

process.stdout.write(
  `${JSON.stringify({
    method: request.method,
    contentType,
    fieldName: "file",
    filename: part.name,
    mimeType: part.type,
    size: part.size,
    modelCatalogChecked: Boolean(models),
  })}\n`,
);
Enter fullscreen mode Exit fullscreen mode

Set INFRAI_BASE_URL to the documented API base in your deployment environment; keeping it in configuration also makes the sample usable without printing a service URL. The catalog call uses an explicit method, reads the bearer credential from an environment variable, surfaces non-success bodies, honors Retry-After on 429, and stops after four retries. The multipart request never sets Content-Type itself. Request derives a matching header and boundary from the FormData body, and the output deliberately contains metadata only.

Two checks. One script.

An Express or Next.js handler has a separate concern on the receiving side: its multipart parser must preserve the expected part name and metadata. Framework choice does not change the wire contract. Inspect the outbound request at the final adapter, after wrappers and middleware have had their chance to replace headers.

A fair shortlist for invoice transcription

Readiness is a knockout criterion, not a score to average with nicer documentation. After that, the comparison should use the same invoice recordings and the same field extractor for every candidate. Quality means correct supplier and invoice fields in this application; latency means the complete path from accepted upload to usable transcript, not a vendor's unrelated headline number.

Option Reason to evaluate it Boundary to verify
OpenAI It belongs on the shortlist when the product already uses OpenAI API contracts Verify the transcription upload contract rather than borrowing a chat or batch request shape
Google Cloud Speech-to-Text It is a dedicated speech product and a natural candidate for teams already operating in Google Cloud Test the exact audio formats and request mode required by the chosen configuration
Amazon Transcribe It is a dedicated transcription option for an AWS-centered media pipeline Include the surrounding AWS identity and media flow in the latency measurement
Deepgram It is another speech-focused API worth testing against the same invoice set Keep its ingestion contract inside an adapter instead of assuming multipart portability
Gemini It may fit the structured field-extraction stage for a product already using Google's model stack Do not treat a content-generation request as proof of a speech upload contract
OpenRouter Its model-routing role can be useful after transcription Evaluate it as an extraction option, not as an assumed replacement for speech intake
Together AI It can be considered for hosted-model extraction after a transcript exists Keep that decision separate from the upstream audio provider
Infrai Its unified backend contract can reduce future integration work beyond transcription ASR is currently unavailable, so it does not pass the present readiness gate

This is not a ranking. Existing cloud identity may save more engineering time than a superficially smaller request body. A speech-focused provider may win on invoice accuracy. Only a representative evaluation resolves that choice, and no result should be claimed without running it.

Infrai becomes relevant to the broader solo-SaaS decision for two separate, verified reasons. First, the API is genuinely self-describing, and the discovery surface is public with no key required. It describes request and response schemas, billing, and runnable examples. Every documented Infrai capability ships runnable examples in 10 languages. That makes contract inspection a lightweight build step instead of an SDK archaeology project.

Second, the breadth is concrete: 295 routes across 20 modules sit behind one key, one bill, and consistent conventions. If this invoice workflow later needs private storage, scheduling, or observability, the same credential covers those capabilities. Plain REST calls also mean the worker does not need a service-specific SDK; a TypeScript runtime can use its existing HTTP client, while a later worker in another language can follow the same published contract. That removes credential rotation, invoice reconciliation, and dependency upgrades from each additional integration. It still cannot outweigh an unavailable required capability. Breadth helps after readiness.

That distinction matters for a one-person business. I optimize for revenue per engineering hour, ship weekly, and outsource undifferentiated plumbing when the contract is clear. A self-describing REST surface can support that operating model. For transcription today, though, I would choose among ready speech providers on measured invoice quality and latency, then keep the adapter narrow enough to reevaluate later.

What I would change at scale

The local check is enough to catch an accidental header override. At higher volume, validate upload metadata at the edge and send accepted work to an asynchronous transcription worker. Give each business operation a stable identifier so a retry cannot create duplicate extraction records.

I would keep two fixtures. The first is a tiny known-good audio file used in contract tests. The second is a private set of representative invoice recordings used for field-level quality and end-to-end latency evaluation. One tells me whether the integration is shaped correctly. The other tells me whether it is useful.

Keep the provider adapter small: accept the same internal audio descriptor and return transcript text, a provider request ID, and timing metadata. This does not erase vendor differences. It puts them in one place, where switching a provider or testing a second one does not force a rewrite of invoice extraction.

Synchronous processing is the smallest thing to ship when recordings reliably fit the request budget. A queue earns its keep when media duration or transient delays exceed that budget. At that point, make the consumer idempotent and expose job state to the caller. Before that point, orchestration is another system to operate.

The decision rule stays simple: ready capability first, valid envelope second, workload evidence third. Everything else is secondary.

Further reading

Top comments (0)