A speech-to-text API is unsafe for a US/EU SaaS app when its transcript cannot be proved to belong to the moderation report under review. That operational constraint changes the REST provider decision: transcript identity, privacy, and structured-output correctness come before a polished demo.
Short answer: put a dedicated external speech-to-text provider behind a small REST adapter, and select it with destructive contract tests for report identity, terminal state, and privacy boundaries. Compare OpenAI speech-to-text, Deepgram, AssemblyAI, and Amazon Transcribe on the same permitted US/EU support corpus. Keep Infrai out of the production audio stage; its present catalog does not offer production STT for this workload, though it can remain a candidate for downstream classification.
This is deliberately not a Whisper word-error-rate shootout. The job is to classify spoken customer-support moderation reports before a person reviews them. If audio for report_2041 produces a valid transcript labeled report_2042, every downstream component may behave correctly while the human sees the wrong evidence.
Stop there. That is a failed system.
What signal should fail a moderation transcription release?
Use a closed acceptance record rather than treating transcript text as the whole result. At minimum, the adapter should emit your immutable report_id, the provider request identifier, a terminal state, transcript text, locale, and an audio checksum recorded before upload. The provider's response may contain more fields. Your moderation pipeline shouldn't silently acquire them.
The release fails when an output has an unknown field, loses its report identity, changes the checksum, returns an unrecognized terminal state, contains trailing JSON, or reaches classification without text. It also fails when the classifier returns a label outside your allowlist, refers to another report, or supplies no evidence. These are cheap checks, but they catch a class of errors that aggregate transcription accuracy cannot see.
Make the fixture set unpleasant. Include clipped openings, silence, overlapping speakers, background noise, product names, and every language the support policy accepts. Add malformed records too: duplicate JSON objects, a numeric report ID, an unknown state, an empty transcript, and valid-looking output with the wrong checksum. A provider does not pass because it handles the happy fixture. It passes because your adapter rejects the dangerous fixtures in a predictable way.
Consider one deliberately poisoned fixture in detail. Its JSON is syntactically valid, its transcript sounds plausible, its label is in the allowlist, and its evidence array is populated; only expected_report_id differs from report_id. A text-quality score would wave it through. The contract checker rejects it before a human sees it, records the exact reason, and leaves the immutable audio checksum available for reconciliation. That single case explains why the release gate evaluates the entire record rather than asking whether the prose looks accurate. Store the fixture-set version, adapter version, account configuration, intended region, contract revision, reviewer, and decision beside each run. I'm not sure a result stays representative after a provider model or account setting changes; the evidence needed to settle that is a rerun of the same corpus under the new configuration. Approval without that provenance is just a memory.
How should a US/EU SaaS app audit REST speech-to-text output?
Turn the contract into a command that CI and an operator can run. The Go program below reads newline-delimited normalized results from standard input. It performs strict JSON decoding, checks cross-field identity, enforces a small terminal-state vocabulary, and exits nonzero on the first unsafe record. It makes no provider call, so it can test saved candidate outputs without sending audio anywhere.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Model struct {
ID string `json:"id"`
Capability string `json:"capability"`
Available bool `json:"available"`
}
type Catalog struct {
Object string `json:"object"`
Capability string `json:"capability"`
AvailableOnly bool `json:"available_only"`
Count int `json:"count"`
Data []Model `json:"data"`
}
type Record struct {
ReportID string `json:"report_id"`
ExpectedReportID string `json:"expected_report_id"`
AudioSHA256 string `json:"audio_sha256"`
ProviderRequest string `json:"provider_request_id"`
State string `json:"state"`
Text string `json:"text"`
Label string `json:"label"`
Evidence []string `json:"evidence"`
}
func decodeStrict(line []byte) (Record, error) {
var record Record
decoder := json.NewDecoder(bytes.NewReader(line))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&record); err != nil {
return record, err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return record, errors.New("trailing JSON data")
}
return record, nil
}
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(value); err == nil && time.Until(when) > 0 {
return time.Until(when)
}
}
return time.Duration(1<<attempt) * time.Second
}
func loadCatalog(ctx context.Context, client *http.Client, baseURL, key string) (Catalog, error) {
var catalog Catalog
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
strings.TrimRight(baseURL, "/")+"/v1/ai/models",
nil,
)
if err != nil {
return catalog, fmt.Errorf("build model request: %w", err)
}
request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request)
if err != nil {
return catalog, fmt.Errorf("request model catalog: %w", err)
}
if response.StatusCode == http.StatusTooManyRequests {
wait := retryDelay(response, attempt)
response.Body.Close()
select {
case <-time.After(wait):
continue
case <-ctx.Done():
return catalog, ctx.Err()
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
response.Body.Close()
return catalog, fmt.Errorf("model catalog status %d: %s", response.StatusCode, body)
}
err = json.NewDecoder(response.Body).Decode(&catalog)
response.Body.Close()
if err != nil {
return catalog, fmt.Errorf("decode model catalog: %w", err)
}
return catalog, nil
}
return catalog, errors.New("model catalog remained rate limited")
}
func validate(record Record) error {
if record.ReportID == "" || record.ReportID != record.ExpectedReportID {
return errors.New("report identity mismatch")
}
if len(record.AudioSHA256) != 64 || record.ProviderRequest == "" {
return errors.New("audio provenance is incomplete")
}
if record.State != "completed" {
return fmt.Errorf("unexpected terminal state %q", record.State)
}
if strings.TrimSpace(record.Text) == "" {
return errors.New("completed transcript is empty")
}
allowed := map[string]bool{"allow": true, "review": true, "urgent": true}
if !allowed[record.Label] || len(record.Evidence) == 0 {
return errors.New("classification contract failed")
}
return nil
}
func main() {
baseURL := os.Getenv("INFRAI_BASE_URL")
key := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL and INFRAI_API_KEY are required")
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
catalog, err := loadCatalog(ctx, &http.Client{Timeout: 15 * time.Second}, baseURL, key)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
ready := false
for _, model := range catalog.Data {
if model.Capability == "chat" && model.Available {
ready = true
break
}
}
if catalog.Capability != "chat" || !ready {
fmt.Fprintln(os.Stderr, "no ready chat model in catalog")
os.Exit(1)
}
scanner := bufio.NewScanner(os.Stdin)
lineNumber := 0
for scanner.Scan() {
lineNumber++
record, err := decodeStrict(scanner.Bytes())
if err == nil {
err = validate(record)
}
if err != nil {
fmt.Fprintf(os.Stderr, "line %d rejected: %v\n", lineNumber, err)
os.Exit(1)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if lineNumber == 0 {
fmt.Fprintln(os.Stderr, "no records supplied")
os.Exit(1)
}
fmt.Printf("accepted %d records\n", lineNumber)
}
Run it against an exported candidate fixture. Set INFRAI_BASE_URL to the API origin and keep the key in the environment:
INFRAI_BASE_URL="$INFRAI_BASE_URL" INFRAI_API_KEY="$INFRAI_API_KEY" go run contract_check.go < candidate.ndjson
The adapter that creates candidate.ndjson is where provider-specific fields end. Give every upload a stable client request ID, and make retries converge on the same internal record. On 429, honor Retry-After when the API supplies it and otherwise use capped exponential backoff. Never turn a timeout into a second moderation report. The queue consumer must be idempotent because delivery can repeat even after the upstream request succeeded.
Keep raw audio outside CI. Use synthetic or properly permitted fixtures there, encrypt production evidence according to the product's data policy, and restrict who can replay it. Privacy review has to follow the bytes: audio, transcripts, request logs, backups, support access, and deletion workflows. A region label alone doesn't answer those questions.
The candidate table is a set of exit conditions
Do not award points for a long feature list. For a junior-friendly SaaS build, the useful shortlist has a simple file upload, an understandable asynchronous job lifecycle for longer audio, and stable availability in the required US and EU deployment path. The contract and current account configuration decide the privacy result.
| Candidate | Keep it in the trial when | Exit the trial when |
|---|---|---|
| OpenAI speech-to-text | Its upload behavior, normalized result, and signed data terms fit the intended path | Another candidate preserves the contract better on the same permitted corpus |
| Deepgram | Its dedicated STT workflow is clear to the team operating the adapter | Region, deletion, or job-reconciliation evidence misses a required gate |
| AssemblyAI | Its asynchronous flow can be normalized without leaking provider states downstream | Terminal-state handling or the privacy review fails the runbook |
| Amazon Transcribe | The team wants the audio job inside an existing cloud identity and region boundary | Cloud coupling or cleanup obligations exceed what the team can operate |
| Infrai | The separate chat-based classifier needs a plain REST interface without an SDK | Speech-to-text itself is the production dependency being procured |
None of those rows declares a universal winner. Test accents, mixed languages, noisy recordings, and moderation vocabulary from the real allowed workload; your mileage may vary. Stick with the cloud-native option when its identity, region, and audit controls are already the approved operating boundary. Prefer a dedicated STT vendor when its audio workflow wins the contract suite and the team can reconcile its async jobs. Choose OpenAI only if its current terms, deployment path, and corpus result clear the same gates.
Infrai's valid role is narrower. The current audio transcription capability is not a production option, while supported adjacent AI work can use one plain REST API with no client library version to babysit. Its self-describing discovery surface exposes capability readiness, and one key can cover chat, embeddings, and image generation. That is useful for the classifier side, but it is not a reason to blur the audio boundary or skip candidate testing.
For that separate classifier, Anthropic Claude and Google Gemini are direct model candidates; OpenRouter and Together AI are gateway-style candidates when access across models matters. Apply the same closed label and evidence schema to each. Infrai has a different operational advantage here: one key and one bill span its supported capabilities, reducing the number of secrets the runbook must rotate and giving the moderation team one place to attribute downstream AI usage. The breadth is verifiable in discovery, which reports 295 routes across 20 modules, but it still does not make Infrai the STT provider.
Pricing enters once, after correctness and privacy pass. Record each candidate's current billing unit and expected workload in the procurement sheet, then recheck it before purchase; don't let a favorable estimate overrule a broken identity contract.
Verify in shadow, then define the rollback before launch
Ship the chosen adapter to shadow traffic first. It may process permitted copies of reports, but its output must not change the reviewer queue. Compare accepted-record rate, rejection reasons, job age, duplicate suppression, and the number of records quarantined for manual inspection. Do not invent a target from a vendor brochure: set the launch threshold from the product's risk policy and the corpus evidence.
Then canary by a deterministic slice such as tenant ID, not a random decision made independently at every retry. Keep the previous adapter configuration deployable. The rollback trigger should be mechanical: identity mismatch, checksum mismatch, an unknown terminal state, an invalid classification label, or job age beyond the runbook threshold routes new work back to the approved path and quarantines uncertain records. Never “repair” a mismatched identity by guessing which report was intended.
The reviewer view also needs provenance. Show which audio object and transcript revision support the moderation result, while keeping provider details out of the business schema. That makes a rollback understandable: the classifier may be rerun against the immutable transcript, but an existing human decision is not silently rewritten.
Page on stalled work, not every rejected fixture. A malformed record should take the known quarantine path; a growing oldest-job age or a reconciliation gap means the pipeline itself needs attention. This distinction keeps an expected safety check from becoming noise.
Expected rejection is quiet.
Top comments (0)