DEV Community

CelesteRaine1783
CelesteRaine1783

Posted on

Storage Contracts for Long-Form Audio: Choosing an Async Transcription API

Short answer: choose a batch audio transcription API with asynchronous jobs, a documented webhook contract, and a status operation you can reconcile from your own ledger. For support calls and podcasts, the decisive property is not the prettiest transcript demo; it is whether the audio, callback, transcript, and CRM action can be recovered and reprocessed without changing providers' identifiers into your system's source of truth.

I would make provider portability an invariant. Store the original recording once, normalize every provider response into the same internal job record, and keep transcription separate from the later step that turns text into CRM actions. A provider may own decoding, but your application must own provenance, deduplication, retention, and the decision about when a result is trustworthy enough for a human to review.

What should a batch audio transcription API promise for long recordings?

Start by writing the contract down before comparing model names. A submission needs a client-generated job ID, an immutable audio reference, a selected language or configuration, and a processing version. The response needs a provider job ID that can be reconciled later. A completion callback needs a stable event ID, the job ID, a result reference, and a state that can be interpreted without guessing from free-form text.

The callback is a notification, not a database.

Treat it as untrusted input.

Long recordings expose the difference. An hour-long support call should not occupy an HTTP worker while a speech model runs, and a podcast episode should not be treated as a single request whose timeout determines whether the work exists. Submit the job, persist the correlation, acknowledge a valid callback after its event is committed, and let a worker fetch or process the transcript afterward. A bounded status lookup repairs a late or missing callback; it should not become the primary workload loop.

There are several failure boundaries to name explicitly:

Boundary Invariant Recovery question
Audio intake The source object is immutable and addressable Can the same bytes be submitted again?
Job submission The client ID maps to one logical transcription Does a retry create a second charge or job?
Webhook ingress Every accepted event is durable and deduplicated Can a duplicate or delayed event be replayed?
Transcript storage Text retains source and configuration provenance Can a changed parser reproduce the result?
CRM enrichment Actions are derived, versioned, and reviewable Can enrichment be rerun without retranscribing?

The catch is that asynchronous does not mean reliable by itself. Ask for documented duration and object-size limits, supported audio formats, timestamp behavior, speaker labeling, retention windows, callback retry policy, authentication, and status semantics. Then test those claims against a boundary corpus: a quiet recording, crosstalk, accents, overlapping speakers, the longest support call you expect to retain, and the noisiest podcast episode in scope.

I am not sure which transcription engine will win on your microphones and vocabulary; your mileage may vary between a studio recording and a headset call with interruptions. Measure the corpus you actually own. A generic accuracy number cannot settle a portability decision.

Build the ledger before you pick the provider

The internal record should be deliberately dull. It might contain source_id, source_uri, client_job_id, provider_name, provider_job_id, event_id, state, transcript_uri, schema_version, and timestamps for submission, callback, reconciliation, and review. The provider adapter maps its vocabulary into this record. The CRM workflow never reads a provider-specific payload directly.

That separation matters when a sales call becomes three different outputs: a transcript for search, a structured summary for the support manager, and follow-up actions for the CRM. If the enrichment prompt or schema changes, rerun that stage against the durable transcript. Do not pay for, or wait on, speech decoding again merely because a JSON field was renamed.

This is where the storage design earns its keep. Imagine a support call finishes at 09:14, the callback is accepted, and the worker writes the transcript before the machine is terminated. At 09:15 the CRM queue has no action, which is an incomplete workflow but not a lost recording: reconciliation sees a completed transcript with no enrichment receipt, creates the derived job using the same client job ID, and leaves the original callback untouched. If the provider retries the callback at 09:20, the event key absorbs it. If a parser deployed at 09:30 rejects an optional speaker field, the raw envelope and transcript provenance still let an engineer replay only the adapter or enrichment stage after reviewing the schema change. The failure is visible at the correct boundary, and replacing the speech provider later does not require pretending that the CRM was the owner of audio state.

The callback handler should authenticate the sender, parse only the fields required to identify the event, and commit the raw envelope plus the state transition. It should not download a large audio object, invoke a text model, or write CRM actions in the same request. A worker can claim the committed event and do those slower operations. If it is terminated halfway through, the event remains available for replay.

Here is a small provider-neutral ingress. It is intentionally a storage example rather than a transcription SDK: the adapter-specific signature check and response parsing belong at the boundary, while the deduplication rule stays ours.

import hashlib
import hmac
import json
import os
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

DB_PATH = os.environ.get("TRANSCRIPTION_DB", "transcription.db")
CALLBACK_SECRET = os.environ["TRANSCRIPTION_CALLBACK_SECRET"]


def open_database():
    db = sqlite3.connect(DB_PATH)
    db.execute("PRAGMA journal_mode=WAL")
    db.executescript(
        """
        CREATE TABLE IF NOT EXISTS callback_events (
            event_id TEXT PRIMARY KEY,
            payload_sha256 TEXT NOT NULL,
            received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
        CREATE TABLE IF NOT EXISTS transcription_jobs (
            client_job_id TEXT PRIMARY KEY,
            provider_job_id TEXT UNIQUE NOT NULL,
            source_uri TEXT NOT NULL,
            transcript_uri TEXT,
            state TEXT NOT NULL,
            adapter_version TEXT NOT NULL
        );
        """
    )
    return db


class CallbackHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/callbacks/transcription":
            self.send_error(404)
            return

        signature = self.headers.get("X-Callback-Signature", "")
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length)
        expected = hmac.new(
            CALLBACK_SECRET.encode(), body, hashlib.sha256
        ).hexdigest()
        if not hmac.compare_digest(signature, expected):
            self.send_error(401)
            return

        try:
            event = json.loads(body)
            event_id = event["event_id"]
            job_id = event["provider_job_id"]
            state = event["state"]
        except (json.JSONDecodeError, KeyError, TypeError):
            self.send_error(400, "invalid callback")
            return

        digest = hashlib.sha256(body).hexdigest()
        with open_database() as db:
            inserted = db.execute(
                "INSERT OR IGNORE INTO callback_events(event_id, payload_sha256) "
                "VALUES (?, ?)",
                (event_id, digest),
            ).rowcount
            if inserted:
                db.execute(
                    "UPDATE transcription_jobs SET state = ? "
                    "WHERE provider_job_id = ?",
                    (state, job_id),
                )

        response = json.dumps({"accepted": True}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(response)))
        self.end_headers()
        self.wfile.write(response)


if __name__ == "__main__":
    open_database().close()
    ThreadingHTTPServer(("127.0.0.1", 8080), CallbackHandler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

The example's unique keys are more important than its framework. A duplicate event is harmless; two event IDs for one provider job remain visible because provider_job_id is unique. If a completion arrives before a progress event, the state machine should accept the legal forward transition and ignore a stale update later. If the same event ID arrives with different bytes, retain the mismatch for operator review rather than silently overwriting the first envelope.

Use the same client job ID or idempotency key when retrying submission. HTTP retry behavior is not a substitute for an application ledger: a timeout after sending a request leaves you unsure whether the remote job exists, so reconciliation must search by your stable identifier or query the provider's status operation. Respect documented rate limits and retry delays, and record each attempt with its outcome.

The ledger is the authority.

Compare the options by recovery cost

There are four useful architecture choices for this workflow. The right answer depends on the data boundary and the operational team, not on whether a sample transcript looks good.

Option Strength Cost or limitation
Managed asynchronous transcription Fastest path to decoding and provider-operated capacity Adds a processor, a callback contract, retention policy, and an adapter to maintain
Self-hosted speech recognition Maximum control over data placement and model tuning Requires accelerator capacity, upgrades, queue recovery, and quality evaluation
Synchronous transcription Simple request flow for short clips and interactive previews Poor fit for long recordings, worker timeouts, and delayed CRM workflows
Hybrid pipeline Keeps audio decoding and text enrichment independently replaceable More ledgers, provenance fields, and cross-stage observability

For customer support, the hybrid shape is usually the most portable: an STT adapter emits a canonical transcript, then a separate extraction worker produces CRM actions. For podcasts, the same shape permits chaptering or search indexing without coupling those outputs to the audio provider. The data model is the reusable part.

Not every team should build this. Self-hosting is not suitable when nobody owns model upgrades, accelerator scheduling, or incident response. A managed service is not suitable when recordings cannot cross a processor boundary or when its regional retention terms fail your policy. Stick with a synchronous API for short, user-facing previews; use an asynchronous job for archival recordings whose completion can arrive later.

Test the handoff, not just the transcript

A passing transcription sample proves very little. Test submission timeouts, duplicate callbacks, callbacks in the wrong order, a callback with an unknown job ID, an expired result URL, an object that disappears during processing, and a worker killed after it writes a transcript but before it enqueues CRM enrichment. Each test should have a deliberate replay or reconciliation result.

Observability should connect client_job_id across intake, provider submission, callback receipt, transcript persistence, enrichment, and human review. Track age by state, callback delay, reconciliation count, duplicate count, transcript retention failures, and the percentage of CRM actions sent for review. Do not use provider job IDs as your only trace key; changing providers should not erase the history of a recording.

The final acceptance test is a provider swap on a fixture corpus. Run two adapters against the same source IDs, compare the canonical transcript schema and downstream action validation, and inspect differences in timestamps, speakers, and redaction. If replacing the decoder requires changing CRM code, portability exists only in the architecture diagram.

References

Top comments (0)