Article

Audio Transcription API for Recorded Clinical Visits, From Upload to Usable Transcript

Audio Transcription API for Recorded Clinical Visits, From Upload to Usable Transcript

Audio Transcription API for Recorded Clinical Visits, From Upload to Usable Transcript

1 min read

An audio transcription API takes a finished recording and returns text, usually through a background job. The part most guides skip is that your encoding decides how long a visit can be: with Sully's 100 MiB and 24-hour caps, uncompressed 16 kHz mono audio stops at about 54.6 minutes, and 9.7 kbps or lower is the only way to fit a full 24 hours.

So the limit you'll hit is a choice you make before writing any API code. This guide follows one recorded visit from encoding to deletion. For live audio, see the real-time transcription API guide.

Key Takeaways

  • The size cap usually binds first. Sully's transcription docs cap uploads at 100 MiB and 24 hours. Below about 9.7 kbps the duration cap binds. Above it, the size cap does.

  • No published bitrate is proven for clinical audio. The evidence comes from other engines and non-clinical audio, so test on your own recordings and count errors that change meaning.

  • Don't auto-retry the POST. A lost response can leave a billable job running whose ID you never received, which also means you can't delete it.

  • Use webhooks and polling together. A webhook wakes your worker, and a polling sweep catches what it missed.

  • Your parser has to tolerate gaps. Results come back per channel, and words and speaker are optional.

Why encoding is the first audio transcription API decision

The 100 MiB cap (104,857,600 bytes) turns every encoding into a maximum recording length: 104,857,600 × 8 ÷ bitrate, ignoring container overhead.


Encoding

Bitrate

Longest recording in 100 MiB

48 kHz 16-bit stereo PCM

1,536 kbps

9.1 minutes

16 kHz 16-bit stereo PCM

512 kbps

27.3 minutes

16 kHz 16-bit mono PCM

256 kbps

54.6 minutes

Compressed, 128 kbps

128 kbps

1.8 hours

Compressed or 8 kHz mu-law, 64 kbps

64 kbps

3.6 hours

Compressed, 16 kbps

16 kbps

14.6 hours

Compressed, 9.7 kbps

9.7 kbps

About 24 hours

Look at the first row: a recorder left on 48 kHz stereo WAV hits the cap in nine minutes.

The bottom row looks generous and isn't. RFC 7587 lists Opus "sweet spots" of 16-20 kbit/s for wideband speech and 8-12 kbit/s for narrowband, so a full 24-hour file lands in the narrowband range. You can only reach that cap by giving up audio quality.

What the evidence says about bitrate

The search for this article found no published study measuring a bitrate on clinical audio. What exists, none of it clinical:

  • Google's encoding guide says to use lossless FLAC or LINEAR16 if you control the source, and that 16 kHz suits speech. That's guidance for Google's engine.

  • A 2020 multichannel Opus study reports word error rate degrading by 12.6% relative at 16 kbps compared with uncompressed audio.

  • AmiVoice's vendor test found accuracy between 97.9% and 98.2% from 256 kbps down to 16 kbps, then 95.5% at 6 kbps, on its own engine and sample audio.

Those last two disagree at 16 kbps, and RFC 7587's numbers describe audio quality rather than recognition accuracy. They tell you where to start testing, not what to ship. So our recommendation isn't a number:

  1. If the recording fits, send lossless. FLAC's size depends on the audio, so measure it on your own files.

  2. If it doesn't fit, test before you compress. Encode the same reference clips at each candidate bitrate and compare meaning-changing errors. Pick the lowest bitrate that doesn't add any.

  3. Fix capture settings, not files. Converting an MP3 to FLAC makes it bigger and restores nothing.

  4. Don't downmix multichannel audio to save space. -ac 1 below is for single-microphone sources only.

# Single-microphone source to 16 kHz mono FLAC
ffmpeg -i visit.wav -ac 1 -ar 16000 -c:a flac visit.flac

# Check duration and size before you upload
ffprobe -v error -show_entries format=duration,size -of json visit.flac
# Single-microphone source to 16 kHz mono FLAC
ffmpeg -i visit.wav -ac 1 -ar 16000 -c:a flac visit.flac

# Check duration and size before you upload
ffprobe -v error -show_entries format=duration,size -of json visit.flac
# Single-microphone source to 16 kHz mono FLAC
ffmpeg -i visit.wav -ac 1 -ar 16000 -c:a flac visit.flac

# Check duration and size before you upload
ffprobe -v error -show_entries format=duration,size -of json visit.flac

Splitting is the last resort. OpenAI's guide warns that cutting mid-sentence can reduce accuracy. Each part also becomes its own job, timestamps need offsetting, and you shouldn't assume speaker values carry across uploads.

Upload once: calling the transcription API without duplicate work

The request is multipart, and the file field must be named audio. These examples follow Sully's transcription docs:

curl "$SULLY_API_BASE_URL/v2/audio/transcriptions" \
  --request POST \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY" \
  --form "audio=@./fictional-visit.flac" \
  --form "language=en" \
  --form "multichannel=false"
curl "$SULLY_API_BASE_URL/v2/audio/transcriptions" \
  --request POST \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY" \
  --form "audio=@./fictional-visit.flac" \
  --form "language=en" \
  --form "multichannel=false"
curl "$SULLY_API_BASE_URL/v2/audio/transcriptions" \
  --request POST \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY" \
  --form "audio=@./fictional-visit.flac" \
  --form "language=en" \
  --form "multichannel=false"
{
  "data": {
    "id": "tx_...",
    "status": "pending",
    "created_at": "2026-09-22T20:15:30.000Z",
    "updated_at": "2026-09-22T20:15:30.000Z"
  }
}
{
  "data": {
    "id": "tx_...",
    "status": "pending",
    "created_at": "2026-09-22T20:15:30.000Z",
    "updated_at": "2026-09-22T20:15:30.000Z"
  }
}
{
  "data": {
    "id": "tx_...",
    "status": "pending",
    "created_at": "2026-09-22T20:15:30.000Z",
    "updated_at": "2026-09-22T20:15:30.000Z"
  }
}

A 201 only means processing was accepted. Write data.id to durable storage before doing anything else.

Three details are easy to miss:

  • Boolean fields must be exactly true or false. Python's str(True) produces True, which doesn't meet that rule.

  • language defaults to en. Store each visit's language with the recording. Language handling has its own guide.

  • Timeouts have arithmetic behind them. The SDK reference uses a five-minute timeout because creation can exceed the one-minute default. Sending 100 MiB takes about 14 Mbps of sustained upload to finish in one minute, and about 2.8 Mbps in five. A slower clinic uplink, with a client that treats the timeout as a total deadline, will fail on the largest files.

Why not to auto-retry the POST

Sully's SDK examples set maxRetries: 0 on creation because repeating the POST can create duplicate billable work. The less obvious problem is what a lost response leaves behind.

Say the job is created and the connection drops before you read the 201. You may now have a job running with no ID. Without an ID you can't look it up, and you can't DELETE it, because deletion needs the ID. That's a possible billing duplicate and a possible orphaned transcript of a patient visit.

A lost response isn't a failure. It's an unknown, and your state machine should have a state for it.

  • The body never finished sending: the server never had a complete file, so resending is low risk.

  • The body was sent and no response came back: mark the attempt unknown and resubmit deliberately, at most once. A missing visit transcript usually costs more than a possible duplicate, but make that decision on purpose and log it.

  • You have webhooks: the orphan's terminal event carries its ID. An event for an ID you don't recognize, after a grace period long enough for the original POST to have been recorded, is a candidate for deletion. First confirm nothing else on the account creates transcriptions.

Handling each failure


Signal

What it means

What to do

Resend the same file?

402 insufficient_credits

The account is out of credits

Pause the whole queue and alert someone. Retrying per file only fills your dead-letter queue.

After adding credits

413

File over 100 MiB

Re-encode, and check size before upload next time.

No

422 invalid_audio_duration

Duration unreadable, or outside 0-24 hours

Inspect with ffprobe, then remux or re-encode.

Not unchanged

503

Service unavailable

Wait at least Retry-After, then retry with jitter. You have to code this, since automatic retries are off.

Yes

status: failed

The job ended without a transcript

Log data.result.error, keep the source audio, and route it to review. Resubmitting is a new POST.

Once, at most

Watch the 422 if you record in the browser. Addpipe's write-up of Chrome's WebM output says non-chunked recordings have carried Duration since Chrome 140, but chunked recordings, the usual choice for long sessions, still don't. Sully's docs ask for readable duration metadata, so run your real capture path through the API before launch. A remux can write the duration into a new file:

ffmpeg -i recording.webm -c copy fixed.webm
ffmpeg -i recording.webm -c copy fixed.webm
ffmpeg -i recording.webm -c copy fixed.webm

Log x-request-id too. The docs ask for it if a 409 billing_conflict persists.

Polling vs webhooks: wait for the result properly

Poll the ID until data.status moves from pending or processing to completed or failed.

curl "$SULLY_API_BASE_URL/v2/audio/transcriptions/$TRANSCRIPTION_ID" \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY"
curl "$SULLY_API_BASE_URL/v2/audio/transcriptions/$TRANSCRIPTION_ID" \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY"
curl "$SULLY_API_BASE_URL/v2/audio/transcriptions/$TRANSCRIPTION_ID" \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY"

If webhooks are configured for your account, terminal events arrive as audio_transcription.succeeded or audio_transcription.failed. Run both: the webhook triggers the work, and a periodic sweep polls anything still unfinished.

Polling alone needs no public endpoint, but it does need job state in a database so it survives deploys. Choose an interval: start at a few seconds, back off toward a minute, and alert on jobs that stay unfinished longer than your measured normal.

Webhooks alone can miss an event and leave a visit silently stuck. Two things to design for:

  • Expect duplicates. The docs mention webhook retries, so your handler must be safe to run twice for one ID.

  • Your receiver now holds PHI. Events include "the same public transcription object", which on success means the transcript. Request logs, APM traces and dead-letter queues can all capture it.


Audio transcription API lifecycle from encoding and upload through polling to stored transcript and deletion

From raw response to usable transcript

One result per channel

Set multichannel=true only when the source really has separate channels. You then get one entry in result.channels for each, with transcript, confidence and, optionally, words.

Record who was on which channel at capture, so you can map the results back. A channel also isn't a speaker: a relative sitting by the patient's microphone shares that channel. And a recorder that writes one microphone to both stereo channels would give you two copies of one conversation. The diarization guide covers labels versus channels.

To get one conversation order, merge by word timing and handle the case where it's missing:

def merge_channels(result):
    """Interleave channels by word start time. Returns None if any channel lacks words."""
    merged = []
    for index, channel in enumerate(result["channels"]):
        words = channel.get("words")
        if not words:
            return None  # words is optional: fall back to per-channel transcripts
        merged += [{**word, "channel": index} for word in words]
    return sorted(merged, key=lambda word: word["start"])
def merge_channels(result):
    """Interleave channels by word start time. Returns None if any channel lacks words."""
    merged = []
    for index, channel in enumerate(result["channels"]):
        words = channel.get("words")
        if not words:
            return None  # words is optional: fall back to per-channel transcripts
        merged += [{**word, "channel": index} for word in words]
    return sorted(merged, key=lambda word: word["start"])
def merge_channels(result):
    """Interleave channels by word start time. Returns None if any channel lacks words."""
    merged = []
    for index, channel in enumerate(result["channels"]):
        words = channel.get("words")
        if not words:
            return None  # words is optional: fall back to per-channel transcripts
        merged += [{**word, "channel": index} for word in words]
    return sorted(merged, key=lambda word: word["start"])

Treat speaker the same way, since it's optional too.

Checks that meetings and podcasts don't need

A meeting transcript with a few wrong words is still useful. A visit transcript with one wrong word may not be. Check these directly on reference clips:

  • Drug names. Hydralazine and hydroxyzine have been mixed up for decades, according to reports to the Institute for Safe Medication Practices. One is a blood pressure drug and the other an antihistamine and anxiolytic, and either can fit a clinical sentence.

  • Negation. "Denies chest pain" and "chest pain" differ by one word and mean opposite things. Sully's word error rate article explains how to count these separately.

  • Long visits. A two-minute clip doesn't test a long file. Sample the last third of your longest recordings, at the encoding you'll ship.

  • Confidence scores. Before routing review on word-level confidence, check on your own clips whether wrong drug names actually score low.

One detail to plan for: word_boost is a streaming option, so check how you'd pass a formulary with a recorded file. For one clinician dictating, see the dictation guide and the dictation option.

Delete the resource last

Delete only once you're sure you have everything:

  1. Save the transcript to your own system and run your parsing and checks.

  2. Call DELETE, which returns 204 No Content. The docs say retrying doesn't start another transcription, and a 404 on a retry most likely means it's already gone.

  3. Remove your other copies, such as webhook payloads, queue messages, logs and temporary audio, according to your retention policy.

curl "$SULLY_API_BASE_URL/v2/audio/transcriptions/$TRANSCRIPTION_ID" \
  --request DELETE \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY"
curl "$SULLY_API_BASE_URL/v2/audio/transcriptions/$TRANSCRIPTION_ID" \
  --request DELETE \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY"
curl "$SULLY_API_BASE_URL/v2/audio/transcriptions/$TRANSCRIPTION_ID" \
  --request DELETE \
  --header "X-Account-Id: $SULLY_ACCOUNT_ID" \
  --header "X-Api-Key: $SULLY_API_KEY"

Keep the source audio until step 1 passes, since it's your only way to resubmit. Sully's API page lists Zero Data Retention: it deletes the audio, transcript, and input and output tokens after processing, so save the result as soon as the job completes. The API also has a DELETE endpoint. The HIPAA guide has the questions to ask any vendor.

How other transcription APIs handle recorded files

Sully wrote this comparison, so check it against the linked docs, all read on 3 November 2026. The PCM column is arithmetic at 32,000 bytes per second, treating MB and GB as decimal. "None stated" refers to the linked page.


API

File limit

Duration limit

16 kHz mono PCM stops at

How you get the result

After delivery

Sully

100 MiB

24 hours

54.6 minutes (size)

Poll by ID; webhooks if configured

Fetch by ID, DELETE documented

OpenAI gpt-transcribe

25 MB

None stated

About 13 minutes (size)

In the response, optionally streamed as events

No job to poll

Deepgram

2 GB

None stated; processing over 10 minutes (Nova, Base, Enhanced) returns 504

About 17.4 hours (size)

In the response, or via callback

Doesn't store transcripts

AssemblyAI

5 GB per request; 2.2 GB through /v2/upload

160 ms to 10 hours

10 hours (duration)

Poll by ID; webhooks

Fetch by ID, delete documented

  • Deepgram's callback has a clock on it. It retries up to 10 times with a 30-second delay, about five minutes in total, and Deepgram says it doesn't store transcripts. Read together, a receiver down for longer could lose the result. Not storing transcripts is also a real advantage for some teams.

  • AssemblyAI's webhook is the tidiest for PHI. The payload holds only transcript_id and status, so no transcript text lands in your webhook logs. A 4xx from your endpoint stops its retries, though, so a broken auth check needs a sweep behind it. Sully's event carries the transcription object, which saves a GET and widens what you have to protect.

  • OpenAI is the simplest for short files. There's no job to track, but anything over 25 MB needs compressing or splitting. Several older OpenAI transcription models shut down on February 26, 2027, which the Whisper article covers.

Where another vendor fits better. If your recordings run past 100 MiB and you won't re-encode them, AssemblyAI and Deepgram accept far larger files. If you need vocabulary hints on uploaded files today, OpenAI documents a keywords field on gpt-transcribe. The Deepgram article lists what to check before switching.

Where Sully fits. Sully makes sense when the transcript is a step toward a clinical note, since a transcript isn't a note. Its API also offers Note Generation and Text to JSON as separate calls. The API page lists 92.2% clinical note accuracy, which scores finished notes. Billing is credit-based, and the pricing guide covers the wider market.

Frequently Asked Questions

What is an audio transcription API?

It's a service that accepts a recorded file and returns text, usually through a background job. The browser's Web Speech API, which also ranks for this search, is a different tool for speech recognition inside a web page, and MDN notes it sends audio to a server-based engine by default.

What is the best audio format for a transcription API?

If your files fit the size limit, use a lossless format at 16 kHz, such as FLAC. That follows Google's guidance, which is written for Google's engine. If you need lossy audio to fit, test candidate bitrates on your own clinical clips.

How do I transcribe an audio file that's too large for the API?

Re-encode first and split only as a last resort, because splitting multiplies job IDs, requires timestamp offsets and can reduce accuracy at the cut points.

Should I use polling or webhooks for transcription?

Use both if you can. If you can only build one, build polling, because a missed poll runs again on the next cycle.

Can Sully transcribe a 24-hour audio file?

Only if it's also under 100 MiB, which means about 9.7 kbps or lower. Test transcription quality at that bitrate before relying on it.

To try this, get an API key from the Sully API page and follow the transcription docs. Start with your longest visit at the encoding you plan to ship.

Sources

  1. Sully, Transcription API docs

  2. Sully, SDK reference

  3. Sully, Streaming transcription docs

  4. Sully, Note Generation docs

  5. Sully, Text to JSON docs

  6. Sully, API page

  7. Sully, Word error rate in medical transcription

  8. Sully, Deepgram alternatives for medical transcription

  9. Sully, OpenAI Whisper API alternatives for medical transcription

  10. OpenAI, File transcription guide

  11. Deepgram, Pre-recorded audio docs

  12. Deepgram, STT Callback docs

  13. AssemblyAI, Pre-recorded audio docs

  14. AssemblyAI, Webhooks for pre-recorded audio

  15. AssemblyAI, Delete transcripts

  16. Google Cloud, Introduction to audio encoding for Speech-to-Text

  17. IETF, RFC 7587: RTP Payload Format for the Opus Speech and Audio Codec

  18. Khare et al., Multi-channel Acoustic Modeling using Mixed Bitrate OPUS Compression

  19. AmiVoice, How does speech recognition accuracy change with sampling rate and compression rate?

  20. Addpipe, Duration in WebM Videos Produced by Chrome

  21. Pharmacy Times, Lookalike drug names lead to decades of confusion

  22. MDN, Using the Web Speech API

Make more room for care.

See how Sully’s AI agents take on the work around every visit.

Book a demo

Article

Make more room for care.

Make more room for care.