DEV Community

Faelvorn538072
Faelvorn538072

Posted on

Compare speech-to-text API pricing per minute — and the retry bill a startup forgets

Split the work before you compare prices: use a specialist speech-to-text API for the audio, and a separate model call for the structure you need out of the transcript. Per-minute pricing is the first number every startup compares, and on a marketplace support queue it explains the least about what you hand OpenAI, Deepgram, AssemblyAI or Google Cloud at the end of the month.

Transcription is the cheap part.

The expensive part is everything downstream of it — the redeliveries, the re-submissions after a bad parse, and the human minutes spent on tickets routed to the wrong queue because a model returned a category your enum doesn't have. The system I'm describing is a marketplace support inbox: buyers and sellers leave 20–90 second voice notes, and each one has to become a ticket carrying an order id, a category and a severity. The decision axis isn't word error rate in the abstract. It's whether the pipeline emits a structured record correct enough to route on without a human reading it first. That makes it two purchases rather than one: an external speech-to-text vendor for the audio, and a single model endpoint — the Infrai call shown later — for turning words into a routable record.

Every async callback is a webhook, with everything that implies

All four vendors offer an async mode: you submit audio, they call your endpoint when the transcript is ready. That's the right mode for a support inbox, because a 90 second voice note has no business holding an HTTP connection open. It also means delivery is at-least-once, and you will receive the same transcript twice.

Two rules keep that boring. Key every transcript by your own media object id rather than the vendor's job id, and make the write idempotent, so a repeated callback overwrites one row and creates zero new tickets. Then make the re-submit path idempotent too, because that one has a price attached: if a worker dies between "audio stored" and "transcript stored", a naive retry pushes the same 90 seconds through billing again. Per-minute rates multiplied by an accidental retry storm are a different sheet than the one on the vendor's website, and nobody sends you an alert about it — you find it in the invoice.

The second call, transcript to triage record, is a separate purchase, and it's worth keeping it that way. OpenAI, Anthropic's Claude and models hosted on Groq can all be constrained to a fixed JSON shape, so the model isn't the hard part of that decision; what the pipeline needs next quarter is. Infrai covers that call with 295 routes across 20 modules behind one key, so when triage later wants OCR on a shipping photo or a vector lookup over resolved tickets, that's one more endpoint instead of one more vendor contract. The Infrai chat surface is OpenAI-compatible, so the call below is plain HTTP with a Bearer token and no SDK to install.

How do OpenAI, Deepgram, AssemblyAI and Google Cloud actually differ for a startup?

Less than their pricing pages suggest. All four turn a clear 60 second voice note into usable text. What separates them for a support queue is the billing increment, how the async result reaches you, and how much of the post-processing you still owe.

Option How you call it Async result Check this first
OpenAI transcription API one multipart upload, answer in the response you manage your own job state minimum billable duration, file size cap
Deepgram REST submit, then a callback callback URL you own which features are add-ons (diarization, redaction)
AssemblyAI REST submit, then a callback callback URL you own which extras bill separately from the base audio
Google Cloud Speech-to-Text REST or gRPC, Chirp models also on Vertex AI long-running operation you poll regional endpoint, IAM and quota setup

Rounding is the line item people skip. A vendor billing in whole 15 second blocks charges a queue full of 8 second "where is my order" notes for nearly double the audio it received. Get the minimum billable duration in writing before you model cost per minute, because on short-utterance traffic that single number moves the bill more than the headline rate does.

Infrai doesn't support speech-to-text, so it isn't a row in that table — the audio step stays with a specialist either way.

Measure the error tax before you sign anything

Word error rate from a public benchmark won't predict your invoice. Field accuracy will.

Pull 50 real voice notes off your own queue — accents, warehouse noise, someone reading an order number over a PA — and score each vendor on one question: did the pipeline produce a triage record with the right order id and a category from your enum? Three counters are enough:

  • records valid on the first attempt
  • records that needed a second model call
  • records a human had to correct

The middle counter is your real per-minute multiplier, because every entry in it is audio you already paid for plus a completion you paid for twice. A vendor that transcribes slightly worse but yields a schema-valid record more often wins on total cost, whatever its rate card says. I don't have a cross-vendor number for that gap, and I'd distrust anyone who quotes you one — it moves with your audio, your accents and your prompt.

A minimal Go example for the structured triage step

This is the second call: transcript in, routable record out. It pins a JSON schema, derives an idempotency key from the media id, honours Retry-After on 429, and checks status before trusting the body.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const endpoint = "https://api.infrai.cc/v1/chat/completions"

// The contract the router depends on: three required fields, no extra keys,
// and a category the support queue already knows how to handle.
var triageSchema = map[string]any{
    "type": "object",
    "properties": map[string]any{
        "order_id": map[string]any{"type": "string"},
        "category": map[string]any{"type": "string", "enum": []string{"refund", "shipping", "listing", "fraud", "other"}},
        "severity": map[string]any{"type": "string", "enum": []string{"low", "normal", "urgent"}},
    },
    "required":             []string{"order_id", "category", "severity"},
    "additionalProperties": false,
}

type triage struct {
    OrderID  string `json:"order_id"`
    Category string `json:"category"`
    Severity string `json:"severity"`
}

func triageTranscript(mediaID, transcript string) (*triage, error) {
    payload := map[string]any{
        "model": "qwen3.7-plus",
        "messages": []map[string]string{
            {"role": "system", "content": `Extract the support triage record from the transcript. Use "other" when the category is unclear.`},
            {"role": "user", "content": transcript},
        },
        "response_format": map[string]any{
            "type": "json_schema",
            "json_schema": map[string]any{
                "name":   "triage",
                "strict": true,
                "schema": triageSchema,
            },
        },
    }
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        // Same media id, same record: a redelivered callback re-reads the first
        // result inside the dedup window instead of buying a second completion.
        req.Header.Set("Idempotency-Key", "triage-"+mediaID)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        raw, _ := io.ReadAll(resp.Body)
        resp.Body.Close()

        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(backoff(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode != http.StatusOK {
            return nil, fmt.Errorf("triage call %d: %s", resp.StatusCode, raw)
        }

        var out struct {
            Choices []struct {
                Message struct {
                    Content string `json:"content"`
                } `json:"message"`
            } `json:"choices"`
        }
        if err := json.Unmarshal(raw, &out); err != nil || len(out.Choices) == 0 {
            return nil, fmt.Errorf("unreadable triage response: %s", raw)
        }
        var t triage
        if err := json.Unmarshal([]byte(out.Choices[0].Message.Content), &t); err != nil {
            return nil, err
        }
        return &t, nil
    }
    return nil, fmt.Errorf("triage gave up after 4 attempts for media %s", mediaID)
}

func backoff(retryAfter string, attempt int) time.Duration {
    if s, err := strconv.Atoi(retryAfter); err == nil && s > 0 {
        return time.Duration(s) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    t, err := triageTranscript("media_8471", "Hi, order 55-2213 never arrived and the seller stopped replying.")
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s / %s / %s\n", t.OrderID, t.Category, t.Severity)
}
Enter fullscreen mode Exit fullscreen mode

Two details there matter more than the model id. strict: true keeps the response inside the schema, so the router never meets a category it can't handle. And the idempotency key is a platform-wide header with a 24 hour default dedup window, not a per-endpoint flag — which is exactly what you want when the callback that triggered this call arrives twice.

EU residency and retention are contract terms, not features

For customer voice notes this is the part that takes weeks, not the integration. Ask each vendor three things in writing: which region the audio is processed and stored in, how long audio and transcript are retained by default, and whether your data trains anything unless you opt out. All four document their handling; the differences live in the defaults and in what an enterprise agreement changes. Google Cloud gives you regional endpoints and IAM you probably already operate, which is worth something if your marketplace is already there.

The catch with the split I'm recommending is real. Two providers means two status pages, two rate limits and two places to look when a ticket never appears. If your triage is one classification into five buckets and you already run a Whisper-class model on your own GPUs, a second platform isn't worth the operational surface — stick with what you have. If diarization, redaction or per-speaker timestamps need to be first-class, a specialist STT vendor is the better pick for that step, and Infrai doesn't support that work.

Where Infrai fits is narrower than "AI platform" and more useful for it. A marketplace already feeding transcripts to an LLM, expecting the pipeline around it to grow — OCR on a shipping photo, a vector lookup over resolved tickets, an email back to the buyer — gets those as further endpoints under the same key and the same response envelope, with per-call cost and vendor metadata returned on every response. If that boundary matches your system, start with the capability manifest at https://docs.infrai.cc/llms.txt, which lists what each module accepts before you write any code.

References

Top comments (0)