Short answer: one API key can authenticate speech-to-text plus transcript summarization, but schedule a tenant-scoped workflow and account for each model stage under one internal job ID.
For a fintech team extracting fields from supplier invoices, that distinction decides whether an operator can explain a charge, replay a failed stage, and prove that a duplicate delivery did not create a second payable result. A gateway may expose speech-to-text and several summarization models through one key. It cannot infer the tenant, invoice, retry policy, or business meaning that your scheduler failed to preserve.
I've been paged for missed scheduled jobs and for duplicate deliveries. The durable lesson was less dramatic than the page: the job ledger is the system of record; the queue and model endpoints are execution mechanisms. Once that invariant is explicit, a provider switch becomes an adapter change rather than a rewrite of operational semantics.
Keep it boring.
How should one API key handle speech-to-text and transcript summarization?
It shouldn't handle tenancy at all.
Use the key to authenticate the runtime call. Before making that call, assign a stable job ID derived from the tenant, source object, extraction version, and scheduled occurrence. That ID follows the supplier-invoice audio through transcription, transcript normalization, summarization, and field validation. Each stage gets its own attempt record and usage record, but all records point back to the same tenant-owned job.
This separation matters because "one API" can describe several very different products. One service might accept audio and return text. A model gateway might route text prompts among multiple model families. An internal facade might expose both operations under your own contract while using one or more credentials behind it. Those shapes are integration choices; none is a cost ledger.
The scheduler should therefore make five pieces of context non-optional: tenant ID, job ID, stage, model policy, and idempotency key. Put them in typed request metadata and structured logs. Don't ask engineers to recover tenant ownership later from an object name or prompt body. Supplier names can collide, invoices can be resubmitted, and a summary can be regenerated under a new extraction policy while the original transcript remains valid.
For per-tenant cost visibility, keep provider-reported usage in its native units and attach it to the stage attempt. Do not immediately flatten audio duration, input tokens, output tokens, and request counts into a single synthetic number. The billing calculation can change; the underlying usage event should not. A nightly reconciliation can apply the appropriate rate table and compare the internal ledger with the provider invoice without destroying the evidence needed to investigate a mismatch.
The same rule protects access control. A shared secret may be operationally convenient, but authorization still belongs at your service boundary. The caller presents a tenant-scoped identity, the service resolves the permitted source object, and only then does the worker use the runtime credential. One key is not one tenant.
Build the replay boundary before choosing the runtime
Start with states you can operate: pending, transcribing, summarizing, validating, succeeded, and dead. A worker claims a stage with a lease, records its attempt, performs the external call, and commits the result only if the lease and expected state still match. If a worker loses its lease, its late response must not overwrite the accepted result.
Retries are normal. Ambiguous completion is the hard case — the worker may lose contact after the remote service accepted work but before the local commit. Picture the duplicate-delivery page from the operator's side: two workers have the same tenant and invoice source, the first finishes transcription while its lease expires, and the second begins before the first commits. Both attempts may consume runtime capacity, but only the worker holding the current lease may publish the accepted transcript; the other response is recorded as a superseded attempt. The preventative path is a local idempotency key plus compare-and-swap on stage state. Where an external API accepts an idempotency value, send the same stage key on retry. Where it does not, your ledger still prevents two accepted business results, although repeated external usage can remain possible and should be visible as separate attempts. This is why the runbook starts with the job ID and attempt history, not the queue depth graph.
Here is the core in Go. The runtime is deliberately an interface: the scheduling contract does not depend on a commercial route, SDK, or model name.
package workflow
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
)
type Stage string
const (
Transcribe Stage = "transcribe"
Summarize Stage = "summarize"
)
type Job struct {
ID string
TenantID string
SourceURI string
Version string
Occurrence string
}
type Usage struct {
AudioSeconds int64
InputTokens int64
OutputTokens int64
}
type Result struct {
Body []byte
Usage Usage
}
type Runtime interface {
Run(ctx context.Context, stage Stage, input []byte, idempotencyKey string) (Result, error)
}
type Ledger interface {
Claim(ctx context.Context, jobID string, stage Stage, key string) (bool, error)
Commit(ctx context.Context, jobID string, stage Stage, key string, result Result) error
Fail(ctx context.Context, jobID string, stage Stage, key string, cause error) error
}
func StableID(parts ...string) string {
h := sha256.New()
for _, part := range parts {
h.Write([]byte{0})
h.Write([]byte(part))
}
return hex.EncodeToString(h.Sum(nil))
}
func NewJob(tenantID, sourceURI, version, occurrence string) Job {
return Job{
ID: StableID(tenantID, sourceURI, version, occurrence),
TenantID: tenantID, SourceURI: sourceURI,
Version: version, Occurrence: occurrence,
}
}
func Execute(ctx context.Context, ledger Ledger, runtime Runtime, job Job, stage Stage, input []byte) error {
key := StableID(job.ID, string(stage))
claimed, err := ledger.Claim(ctx, job.ID, stage, key)
if err != nil {
return fmt.Errorf("claim %s: %w", stage, err)
}
if !claimed {
return nil // Another attempt already owns or completed this stage.
}
result, err := runtime.Run(ctx, stage, input, key)
if err != nil {
if recordErr := ledger.Fail(ctx, job.ID, stage, key, err); recordErr != nil {
return errors.Join(err, recordErr)
}
return err
}
if err := ledger.Commit(ctx, job.ID, stage, key, result); err != nil {
return fmt.Errorf("commit %s: %w", stage, err)
}
return nil
}
The storage implementation needs a uniqueness constraint on (job_id, stage, idempotency_key) and a conditional transition when committing. A process-local mutex is insufficient once two workers or two regions can claim the same message. Keep the raw transcript immutable; write corrected or normalized forms as versioned derivatives so an audit can trace each extracted invoice field to its source.
There is a catch. This pattern does not promise exactly-once execution of an external request. It provides one accepted workflow result and an honest record of attempts. If the business operation itself moves money or approves an invoice, keep that operation behind a separate idempotent command with its own authorization and review rules. Model output should propose fields; deterministic validation and business controls decide what becomes payable.
Measure tenant cost without coupling policy to a model
The useful unit of analysis is one tenant-owned workflow occurrence. Its ledger might contain an audio transcription usage event, one summarization event, and perhaps a second summarization attempt selected by policy. Store the requested capability and policy revision beside the resolved model identifier. The former explains intent; the latter supports invoice reconciliation and incident review.
A compact schema is enough:
| Record | Stable dimensions | Mutable outcome |
|---|---|---|
| Job | tenant, source, version, occurrence | workflow state |
| Attempt | job, stage, idempotency key, policy revision | start, finish, disposition |
| Usage event | attempt, provider, resolved model, native unit | reported quantity |
| Artifact | job, stage, content digest | accepted version |
Do not put a dollar estimate on the queue message. Rates and contractual adjustments belong in a versioned billing table, applied after usage is recorded. This lets finance reproduce a tenant report for a closed period while engineering changes routing policy for new jobs. It also makes an uncomfortable question answerable: did a tenant cost more because its audio was longer, because its summaries consumed more tokens, or because retries increased attempts?
I'm not sure a single allocation rule will satisfy every finance team. Shared platform charges may need to be assigned by request count, direct usage, or a separate subscription rule. Resolve that with finance and document it. The engineering invariant is narrower: never discard the tenant and attempt dimensions required to apply the chosen rule later.
Streaming is optional here. Server-Sent Events use a text/event-stream response and let a server send events over a persistent HTTP connection. They can improve operator visibility for a long extraction, but they don't replace durable state: a browser disconnect must not cancel the invoice job or erase its progress. Persist transitions first, then publish them. For machine-to-machine completion, a queue event or signed callback may fit better; the scheduler should still be able to poll the ledger after losing that notification.
Decide with failure drills, not a feature checklist
Evaluate candidate runtimes through the adapter using recorded, permitted test fixtures. The acceptance test is operational: can the system preserve a stable transcript artifact, summarize it by declared policy, capture native usage, and reconcile every attempt to a tenant? A multi-model gateway may make OpenAI, Claude, and Gemini model families available behind a common interface; those names define a candidate set, not an architecture or endorsement. Run the same tests when adding or replacing any model family. Provider breadth has little value if the gateway hides the resolved model or returns usage too vaguely for your allocation rule.
Exercise duplicate queue delivery, a worker stopping after the runtime response but before commit, lease expiry, a late result, and policy changes between scheduled occurrences. Then inspect the ledger. There should be one accepted artifact per stage version, a visible attempt history, and no path from generated invoice fields directly to payment approval.
Choose a combined gateway when its authentication model, transcription input limits, model-routing controls, usage metadata, data handling terms, and regional requirements fit the workload. Keep separate speech and text services when specialist audio controls or independent failure domains matter more than credential consolidation. Self-host components when data residency or runtime control justifies owning capacity, upgrades, and on-call work. None of these is universally correct.
The key limitation is organizational: a shared gateway reduces integration surface, but it concentrates quota management and credential blast radius. It is not suitable when tenant contracts require isolated provider accounts or when speech processing must remain inside a controlled environment. In those cases, retain the same internal Runtime interface and tenant ledger, then bind each tenant or workload class to an approved adapter.
One API key can make configuration tidier. It cannot supply scheduling correctness, replay safety, or per-tenant accountability. Those belong in the workflow you own.