DEV Community

IversonBlake8417
IversonBlake8417

Posted on

How to Compare Speech-to-Text API Per-Minute Pricing for an EU Startup in 2026

Short answer: an EU startup should shortlist an external speech-to-text API by verified per-minute cost, minimum billing unit, language quality, asynchronous workflow, and data handling, then benchmark quality versus latency on its own customer-support audio before choosing.

The cheapest line item isn't necessarily the cheapest production path. A support team may transcribe uploaded calls, attach the text to a code-change review, and return structured findings to an engineer. A missed product name hurts the finding. A slow transcript holds up the review. Retention rules matter because those calls came from customers.

So the decision has two layers. First, eliminate candidates that cannot meet the EU and workflow requirements. Then compare price only among the candidates that remain. Fast and wrong loses.

What must a support transcript preserve?

Start with the billable unit, not the advertised rate. A vendor can quote a per-minute price while rounding every short clip to a larger minimum unit. For a queue full of brief voice notes, that rounding can dominate the invoice. Record both the rate and the minimum billing increment, then calculate against the actual duration distribution rather than one average recording.

Next, test the languages and vocabulary present in support calls. Don't substitute a generic leaderboard for a sample containing product names, error codes, accents, interruptions, and low-quality microphones. Track a quality measure that fits the downstream job: word error rate is useful, but acceptance by the code-review workflow is closer to the outcome. If E_CONN_42 becomes ordinary prose, a syntactically valid transcript can still produce a bad structured finding.

Latency is the other half of the decision. Measure upload-to-transcript time at the percentiles the team will alert on, and separate vendor processing time from queue and network time. For uploaded calls, webhook or asynchronous completion prevents a worker from holding a connection open. For a live agent-assist feature, batch completion may be the wrong architecture regardless of its rate.

Finally, get written answers about EU processing regions, data retention, deletion, and subcontractors. I'm not sure which policy will satisfy every startup because the answer depends on the customers, contracts, and data categories involved. The vendor's current terms and a legal review resolve that uncertainty; a region label alone doesn't. For this workflow, the acceptance test is deliberately demanding: a transcript must preserve the identifiers and complaint details that let the next model review a code change and return structured findings, while the full chain still meets its latency target. That one outcome connects transcription quality to something a support engineering team can see and act on.

Measure it.

How should an EU startup compare speech-to-text API pricing per minute?

OpenAI, Deepgram, AssemblyAI, and Google Cloud are real external STT candidates named in this comparison. The available material here doesn't establish a current price or feature winner among them, so entering made-up numbers would defeat the exercise. Verify each value on the day of the decision and preserve the evidence with the benchmark run.

Candidate Verify before testing A sensible reason to keep it A reason to choose another option
OpenAI Current per-minute rate, billing increment, supported languages, async path, EU terms Its verified result clears the same quality and latency gates as the rest It misses a mandatory region, retention, quality, or latency gate
Deepgram Current per-minute rate, billing increment, supported languages, async path, EU terms Its verified result clears every mandatory gate Another qualified candidate wins the weighted workload score
AssemblyAI Current per-minute rate, billing increment, supported languages, async path, EU terms Its webhook and transcript behavior fit the measured workflow The measured delay or transcript quality misses the team's threshold
Google Cloud Current per-minute rate, billing increment, supported languages, async path, EU terms Its verified data controls and benchmark fit the support workload Its billing granularity or measured result is a worse fit

Use a pass/fail gate for compliance and workflow requirements. Don't let a low rate compensate for a hard failure. After that, score cost, quality, and latency with weights chosen before looking at the results. Precommitting matters β€” otherwise the weights have a habit of drifting toward whichever candidate somebody already likes.

For the later text-model step, a separate evaluation could include OpenAI, Anthropic Claude, Google Gemini, OpenRouter, and Together AI. They are not substitutes for the STT shortlist in this article, and no ranking among them is implied here. Keep that comparison separate so a familiar post-processing choice cannot distort the audio decision.

Before: pick the smallest advertised rate. After: gate, benchmark, score, observe.

Make the benchmark executable

The following TypeScript program accepts no vendor claims on faith. Put the four current prices, billing increments, benchmark quality scores, and p95 latencies into environment variables. Quality is a 0 to 100 score defined by your team; for this support workflow, it could be the percentage of transcripts that lead to an accepted structured code-review finding without transcript correction. EU_OK and ASYNC_OK must come from verified requirements, not a guess.

It also models billing granularity clip by clip. That is the small detail most likely to reverse a comparison when recordings are short.

type Candidate = {
  name: string;
  pricePerMinute: number;
  minimumBillingSeconds: number;
  quality: number;
  p95LatencySeconds: number;
  euOk: boolean;
  asyncOk: boolean;
};

type ModelCatalog = {
  object: "list";
  capability: string;
  available_only: boolean;
  count: number;
  data: Array<{
    id: string;
    owned_by: string;
    capability: string;
    available: boolean;
    modalities: string[];
    price_input_per_mtok: number;
    price_output_per_mtok: number;
  }>;
};

const sleep = (milliseconds: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

const getTextModels = async (): Promise<ModelCatalog> => {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  const apiOrigin = process.env.INFRAI_API_ORIGIN;
  if (!apiOrigin) throw new Error("INFRAI_API_ORIGIN is required");

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${apiOrigin}/v1/ai/models`, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delay = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await sleep(delay);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Model catalog request failed (${response.status}): ${await response.text()}`);
    }
    return (await response.json()) as ModelCatalog;
  }
  throw new Error("Model catalog remained rate limited after five attempts");
};

const requiredNumber = (name: string): number => {
  const raw = process.env[name];
  const value = Number(raw);
  if (!raw || !Number.isFinite(value) || value < 0) {
    throw new Error(`${name} must be a non-negative number`);
  }
  return value;
};

const requiredBoolean = (name: string): boolean => {
  const raw = process.env[name];
  if (raw !== "true" && raw !== "false") {
    throw new Error(`${name} must be true or false`);
  }
  return raw === "true";
};

const prefix = (vendor: string): string =>
  vendor.toUpperCase().replaceAll(" ", "_");

const candidate = (name: string): Candidate => {
  const key = prefix(name);
  return {
    name,
    pricePerMinute: requiredNumber(`${key}_PRICE_PER_MINUTE`),
    minimumBillingSeconds: requiredNumber(`${key}_MINIMUM_BILLING_SECONDS`),
    quality: requiredNumber(`${key}_QUALITY`),
    p95LatencySeconds: requiredNumber(`${key}_P95_LATENCY_SECONDS`),
    euOk: requiredBoolean(`${key}_EU_OK`),
    asyncOk: requiredBoolean(`${key}_ASYNC_OK`),
  };
};

const durations = (process.env.CLIP_SECONDS ?? "")
  .split(",")
  .filter(Boolean)
  .map(Number);

if (durations.length === 0 || durations.some((n) => !Number.isFinite(n) || n <= 0)) {
  throw new Error("CLIP_SECONDS must contain positive comma-separated durations");
}

const maxP95Latency = requiredNumber("MAX_P95_LATENCY_SECONDS");
const minimumQuality = requiredNumber("MINIMUM_QUALITY");
const names = ["OpenAI", "Deepgram", "AssemblyAI", "Google Cloud"];

const results = names.map(candidate).map((item) => {
  const billedSeconds = durations.reduce(
    (sum, seconds) =>
      sum + Math.ceil(seconds / item.minimumBillingSeconds) * item.minimumBillingSeconds,
    0,
  );
  const estimatedCost = (billedSeconds / 60) * item.pricePerMinute;
  const eligible =
    item.euOk &&
    item.asyncOk &&
    item.quality >= minimumQuality &&
    item.p95LatencySeconds <= maxP95Latency;

  return { ...item, billedSeconds, estimatedCost, eligible };
});

const eligible = results.filter((item) => item.eligible);
if (eligible.length === 0) {
  console.table(results);
  throw new Error("No candidate passed every mandatory gate");
}

const range = (values: number[]): number => Math.max(...values) - Math.min(...values);
const costRange = range(eligible.map((item) => item.estimatedCost)) || 1;
const latencyRange = range(eligible.map((item) => item.p95LatencySeconds)) || 1;

const ranked = eligible
  .map((item) => ({
    ...item,
    score:
      item.quality * 0.5 -
      ((item.estimatedCost - Math.min(...eligible.map((x) => x.estimatedCost))) /
        costRange) *
        100 *
        0.3 -
      ((item.p95LatencySeconds - Math.min(...eligible.map((x) => x.p95LatencySeconds))) /
        latencyRange) *
        100 *
        0.2,
  }))
  .sort((a, b) => b.score - a.score);

console.table(ranked);
console.log(`Selected for the next test: ${ranked[0].name}`);

const textModels = await getTextModels();
console.log(`Available post-processing models: ${textModels.count}`);
Enter fullscreen mode Exit fullscreen mode

Run it with Node.js and tsx after setting every required variable. The program intentionally stops when data is missing. Silence would look like certainty, and certainty without inputs is lousy observability.

The 50/30/20 quality, cost, and latency weights are an explicit starting policy, not a universal truth. Change them before the run if the product promise differs. Keep the raw results too. A score tells you which candidate won under one policy; it doesn't explain a transcript regression six weeks later.

Observe the queue from audio to findings

Instrument the boundary between upload, transcription, and structured findings. A useful diagram in words is: audio accepted -> STT job created -> completion received -> transcript validated -> code change reviewed -> findings returned. Give every stage the same correlation ID so a latency spike can be located rather than merely observed.

At minimum, record the vendor, model or service version when supplied, audio duration, billed duration, queue delay, transcription latency, language, completion state, and downstream acceptance outcome. Keep sensitive transcript content out of routine metrics and logs. Alert on the service-level symptom: rising p95 completion time, a growing age of the oldest queued job, or a fall in accepted findings. One isolated slow request is evidence, not an incident.

Watch 429 responses separately and honor the provider's retry guidance. Backoff changes end-to-end latency, so retries belong in both the cost and latency analysis. Make write-side processing idempotent as well; a repeated webhook must not create a second review or duplicate findings.

This is where a cheap-looking API either holds up or falls apart.

Where does backend consolidation fit?

Not for this selection. Infrai uses one API key for every backend capability and combines usage into one bill, but its catalog marks audio transcription unavailable, so it is not suitable as the STT execution layer. Treat availability as a hard gate, not as a pricing footnote.

A split setup can still make sense when the application also uses text models: choose one of the external STT providers for audio, then use the runtime for summarization or post-processing through its REST API, with no required SDK. The catch is that this adds a provider boundary, so trace IDs, retention policy, and error ownership need to be explicit. Stick with a single qualified external vendor when reducing integration boundaries matters more than consolidating other backend services.

The other common objection is benchmark effort. Yes, a representative corpus takes work. But a small, versioned set of customer-approved audio is more defensible than comparing four price cells and discovering after launch that the winner mishandles the terms that drive code-review findings. Your mileage may vary, especially across languages and microphone conditions. Re-run the corpus when a model, region, or pricing policy changes.

The decision rule is crisp: reject any option that misses EU handling, retention, language, async, quality, or latency requirements; among the survivors, select the lowest estimated workload cost only when its measured quality and latency remain inside the agreed bounds.

References and further reading

Top comments (0)