<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mart Schweiger</title>
    <description>The latest articles on DEV Community by Mart Schweiger (@martschweiger).</description>
    <link>https://dev.to/martschweiger</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3802221%2Fcdb4c7a2-d4f4-444d-908e-30d6ea3bd1a7.png</url>
      <title>DEV Community: Mart Schweiger</title>
      <link>https://dev.to/martschweiger</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martschweiger"/>
    <language>en</language>
    <item>
      <title>Transcription Webhooks &amp; Callbacks: The Complete Guide</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:56:03 +0000</pubDate>
      <link>https://dev.to/martschweiger/transcription-webhooks-callbacks-the-complete-guide-2nk9</link>
      <guid>https://dev.to/martschweiger/transcription-webhooks-callbacks-the-complete-guide-2nk9</guid>
      <description>&lt;p&gt;If you're polling GET /v2/transcript/{id} every few seconds waiting for a transcript to finish, you're burning requests and adding latency for no reason. AssemblyAI can just call you back. Set one field, expose one endpoint, and you get a POST the moment transcription completes.&lt;/p&gt;

&lt;p&gt;This guide covers the whole path—not just "set webhook_url," but the part most docs skip: what the payload actually contains, how to verify the request is really from us, and how retries behave when your server hiccups. If you're building anything production-grade on top of &lt;a href="https://www.assemblyai.com/products/speech-to-text" rel="noopener noreferrer"&gt;speech-to-text&lt;/a&gt;, read to the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Polling vs. webhooks: when to use which&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Both approaches answer the same question—"is my transcript done yet?"—but they trade off differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Polling&lt;/strong&gt; means repeatedly calling GET /v2/transcript/{id} (say, every 3 seconds) until status comes back completed or error. It's dead simple, needs no public endpoint, and works from a script on your laptop. The cost is wasted requests and a delay equal to your polling interval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks&lt;/strong&gt; flip the direction. You register a URL, AssemblyAI POSTs to it the instant the job finishes, and you do zero waiting. The tradeoff: you need a publicly reachable endpoint that returns a 2xx within 10 seconds, plus a little plumbing to secure and verify it.&lt;/p&gt;

&lt;p&gt;Rule of thumb: prototypes and CLI tools poll; backend services that process audio at any real volume use webhooks. If you're running a queue of transcription jobs behind a web service, webhooks are the obvious choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Enabling a webhook&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;You enable a webhook by setting webhook_url in the body of your POST /v2/transcript request against the base URL &lt;a href="https://api.assemblyai.com" rel="noopener noreferrer"&gt;https://api.assemblyai.com&lt;/a&gt;. Auth is a plain authorization header with your API key—no Bearer prefix.&lt;/p&gt;

&lt;p&gt;Here it is with curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;curl&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//api.assemblyai.com/v2/transcript \&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization: &amp;lt;YOUR_API_KEY&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type: application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{
    "audio_url": "https://example.com/audio.mp3",
    "webhook_url": "https://your-app.com/webhooks/transcript"
  }&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Python, build a TranscriptionConfig, call set_webhook(url), and submit the job with submit()—not transcribe(). Using submit() matters: it returns immediately instead of blocking until the transcript is ready, which is the entire point of a webhook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;assemblyai&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;aai&lt;/span&gt;

&lt;span class="n"&gt;aai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;YOUR_API_KEY&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TranscriptionConfig&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;set_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-app.com/webhooks/transcript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;transcriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Transcriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/audio.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Submitted &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. We&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ll get a callback when it&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s done.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AssemblyAI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assemblyai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AssemblyAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;YOUR_API_KEY&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transcripts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/audio.mp3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;webhook_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://your-app.com/webhooks/transcript&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Submitted &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole setup. Full field reference lives in the &lt;a href="https://www.assemblyai.com/docs/api-reference/transcripts/submit" rel="noopener noreferrer"&gt;submit endpoint docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What the payload actually contains&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the gotcha that trips up almost everyone. When transcription finishes, AssemblyAI POSTs to your URL with a &lt;strong&gt;minimal&lt;/strong&gt; payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transcript_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your-transcript-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;status is either completed or error. That's it.&lt;/p&gt;

&lt;p&gt;The payload does &lt;strong&gt;not&lt;/strong&gt; contain the transcript text. It does &lt;strong&gt;not&lt;/strong&gt; contain the error details either. To get the words—or to read the error field when something failed—you have to call GET /v2/transcript/{transcript_id} yourself. The webhook is a notification, not a delivery of the result.&lt;/p&gt;

&lt;p&gt;So your receiver's job is really two steps: catch the notification, then go fetch the actual transcript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;requests&lt;/span&gt;

&lt;span class="nx"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.assemblyai.com/v2/transcript/{transcript_id}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you internalize one thing from this article, make it this: the webhook tells you &lt;em&gt;when&lt;/em&gt;, the GET tells you &lt;em&gt;what&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Building a receiver endpoint&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Let's put it together into a complete, working receiver. Here's a Python/Flask service that validates the custom auth header, checks the status, fetches the transcript on success, and reads the error field on failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;abort&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.assemblyai.com/v2/transcript/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhooks/transcript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_webhook&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Verify the request via your custom auth header.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-My-Webhook-Secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;silent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;transcript_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transcript_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Malformed request—4xx tells AssemblyAI to stop retrying.
&lt;/span&gt;        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Branch on status. Fetch the real data with a GET.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Transcript &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ready: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# ... enqueue downstream work, write to your DB, etc.
&lt;/span&gt;    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Transcript &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# ... alert, retry the job, flag the customer record, etc.
&lt;/span&gt;
    &lt;span class="c1"&gt;# 3. Return 2xx fast so AssemblyAI marks delivery successful.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the shape of it. Verify first, respond fast, and keep heavy work out of the request path. If fetching and processing the transcript is slow, push it onto a background queue and return 200 immediately—you've only got a 10-second budget, which we'll get to.&lt;/p&gt;

&lt;p&gt;The same structure works in Node/Express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/transcript&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-My-Webhook-Secret&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Respond first, then do the work off the request path.&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.assemblyai.com/v2/transcript/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Ready: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Securing your webhook&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;There's no HMAC or signature scheme here—AssemblyAI doesn't sign the payload. So don't go looking for a signature header to validate. Instead, you get three complementary controls, and you should use more than one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom auth header.&lt;/strong&gt; Attach a secret header when you submit the job, and check it on every incoming request. Set webhook_auth_header_name and webhook_auth_header_value in the submit body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;curl&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//api.assemblyai.com/v2/transcript \&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization: &amp;lt;YOUR_API_KEY&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type: application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{
    "audio_url": "https://example.com/audio.mp3",
    "webhook_url": "https://your-app.com/webhooks/transcript",
    "webhook_auth_header_name": "X-My-Webhook-Secret",
    "webhook_auth_header_value": "secret-value"
  }&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Python, pass the extra arguments to set_webhook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TranscriptionConfig&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;set_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-app.com/webhooks/transcript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-My-Webhook-Secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secret-value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AssemblyAI will send that header back on the webhook request, and your receiver rejects anything without the right value—that's the abort(401) in the example above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source-IP allow-list.&lt;/strong&gt; Webhook requests originate from fixed IPs: 44.238.19.20 in the US and 54.220.25.36 in the EU. Restrict your endpoint to those addresses at the firewall or load balancer for a second layer of defense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;webhook_status_code.&lt;/strong&gt; After delivery, the transcript object carries a webhook_status_code field—the HTTP status code AssemblyAI received from your endpoint. Pull it with a GET to confirm your receiver actually returned what you think it did. It's the fastest way to debug a webhook that "isn't firing" (spoiler: it usually is, and your endpoint returned a 500).&lt;/p&gt;

&lt;p&gt;Use the auth header and the IP allow-list together. Neither is a signature, but combined they're a solid perimeter.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Reliability and retries&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Your endpoint has to return a 2xx status within &lt;strong&gt;10 seconds&lt;/strong&gt;. That's the contract.&lt;/p&gt;

&lt;p&gt;If you don't—timeout, 5xx, connection refused—AssemblyAI retries. You get up to &lt;strong&gt;10 total attempts&lt;/strong&gt;, spaced &lt;strong&gt;10 seconds apart&lt;/strong&gt;. That gives your service a bit under two minutes to recover from a transient blip without losing the notification.&lt;/p&gt;

&lt;p&gt;One sharp edge: a 4xx response marks the delivery as failed and &lt;strong&gt;stops retries immediately&lt;/strong&gt;. The reasoning is that 4xx means "your request is wrong and will always be wrong"—retrying won't help. So return 4xx only for genuinely bad requests (a missing transcript_id, a failed auth check). If your database is momentarily down, return a 5xx so the retry machinery kicks in and you get another shot.&lt;/p&gt;

&lt;p&gt;This is exactly why the receiver examples respond 200 before doing the heavy lifting. Fetching the transcript, running downstream analysis, writing to storage—none of that should happen inside the 10-second window. Acknowledge fast, process async.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Adding metadata via query params&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The webhook payload only carries transcript_id and status, so how do you know which customer or order a given callback belongs to? Put it in the URL. AssemblyAI POSTs to your webhook_url exactly as you supplied it, query string and all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TranscriptionConfig&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;set_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://your-app.com/webhooks/transcript?customer_id=1234&amp;amp;order_id=5678&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the callback lands, read those params off the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;order_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the clean way to correlate deliveries and do things like per-customer usage tracking without a lookup table keyed on transcript_id. Just don't put secrets in the query string—keep those in the auth header.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Testing locally&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Your receiver needs a public URL, which is awkward on localhost. Use a tunneling tool—ngrok, Cloudflare Tunnel, or similar—to expose your local port to the internet, then submit a job pointing webhook_url at the tunnel's HTTPS address.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ngrok&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;Use&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;forwarding&lt;/span&gt; &lt;span class="nc"&gt;URL &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//abc123.ngrok.io/webhooks/transcript)&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;your&lt;/span&gt; &lt;span class="nx"&gt;webhook_url&lt;/span&gt; &lt;span class="nx"&gt;when&lt;/span&gt; &lt;span class="nx"&gt;submitting&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Submit a short audio file, watch the request hit your terminal, and confirm you return 200. If nothing arrives, GET the transcript and check webhook_status_code—it'll tell you whether AssemblyAI reached your endpoint and what it got back.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A note on streaming webhooks&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything above is for pre-recorded (async) transcription. If you're working with real-time audio, streaming has its own webhook mechanism—see the &lt;a href="https://www.assemblyai.com/docs/streaming/webhooks" rel="noopener noreferrer"&gt;streaming webhooks docs&lt;/a&gt;. Don't mix the two; the payloads and lifecycle differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Next steps&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Webhooks turn transcription from something you wait on into something that notifies you. Set webhook_url, verify the request, respond fast, and fetch the transcript with a GET—that's a production-grade integration.&lt;/p&gt;

&lt;p&gt;From here, dig into the &lt;a href="https://www.assemblyai.com/docs/pre-recorded-audio/webhooks" rel="noopener noreferrer"&gt;pre-recorded audio webhooks guide&lt;/a&gt; for the canonical reference, or read up on &lt;a href="https://www.assemblyai.com/blog/speech-to-text" rel="noopener noreferrer"&gt;what speech-to-text is&lt;/a&gt; and &lt;a href="https://www.assemblyai.com/blog/how-accurate-speech-to-text" rel="noopener noreferrer"&gt;how accurate it is in 2026&lt;/a&gt; if you're still evaluating models.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.assemblyai.com/docs/api-reference/overview" rel="noopener noreferrer"&gt;&lt;strong&gt;View the full API reference&lt;/strong&gt;&lt;/a&gt; for every field on the transcript object.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does AssemblyAI support webhooks?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. Set webhook_url in the body of your POST /v2/transcript request (or use set_webhook() in the Python SDK / webhook_url in the JS submit() call), and AssemblyAI will POST to that URL when transcription finishes. It's the recommended alternative to polling for backend services.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What's in the webhook payload?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Just two fields: transcript_id and status (completed or error). The payload does not include the transcript text or error details. To get the actual result—or the error field on failure—call GET /v2/transcript/{transcript_id}.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I secure and verify webhook requests?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;There's no HMAC signature. Use a custom auth header (webhook_auth_header_name and webhook_auth_header_value) that your receiver checks on every request, restrict your endpoint to AssemblyAI's source IPs (44.238.19.20 in the US, 54.220.25.36 in the EU), and inspect the webhook_status_code field on the transcript to confirm what your endpoint returned.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What happens if my endpoint is down when a webhook fires?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Your endpoint must return a 2xx within 10 seconds. If it doesn't, AssemblyAI retries up to 10 total attempts, 10 seconds apart. A 4xx response marks the delivery failed and stops retries immediately, so reserve 4xx for genuinely bad requests and return 5xx for transient errors you want retried.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Should I use webhooks or polling?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Polling (GET /v2/transcript/{id} every few seconds) is simplest and needs no public endpoint—good for scripts and prototypes. Webhooks push a notification the instant a job finishes with no wasted requests, but require a publicly reachable endpoint that returns 2xx in 10 seconds. Use webhooks for production backend services.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can I attach my own metadata to a webhook?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. Add query parameters to your webhook_url, like ?customer_id=1234&amp;amp;order_id=5678. AssemblyAI POSTs to the URL exactly as you supplied it, so you read those params off the incoming request to correlate deliveries. Keep secrets in the auth header, not the query string.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webhooks</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Transcribe Audio From a Mobile App (iOS, Android)</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:55:56 +0000</pubDate>
      <link>https://dev.to/martschweiger/how-to-transcribe-audio-from-a-mobile-app-ios-android-16pn</link>
      <guid>https://dev.to/martschweiger/how-to-transcribe-audio-from-a-mobile-app-ios-android-16pn</guid>
      <description>&lt;p&gt;If you searched for an AssemblyAI mobile SDK, here's the short version: there isn't one. The official SDKs are &lt;a href="https://www.assemblyai.com/docs" rel="noopener noreferrer"&gt;Python&lt;/a&gt; (pip install assemblyai) and &lt;a href="https://www.assemblyai.com/docs" rel="noopener noreferrer"&gt;JavaScript/TypeScript&lt;/a&gt; (npm install assemblyai). No Swift, no Kotlin, no React Native package.&lt;/p&gt;

&lt;p&gt;That's not the problem you think it is. The bigger constraint is security: &lt;strong&gt;don't ship your API key to client-side code.&lt;/strong&gt; A mobile binary can be decompiled, and anyone who pulls your key out of it can spend your account balance. So even if a native SDK existed, calling AssemblyAI directly from a phone would be the wrong move.&lt;/p&gt;

&lt;p&gt;The right pattern is a thin backend you control. Your mobile app records audio and sends it to your server. Your server holds the API key, talks to AssemblyAI over the REST API, and returns plain text. That's the whole lesson, and it's a good one—you get key security, request logging, rate limiting, and the freedom to swap models later without shipping a new app build.&lt;/p&gt;

&lt;p&gt;Here's what you'll build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A backend (Node or Python) that receives an audio file, uploads it to AssemblyAI, creates a transcript, polls for the result, and returns the text.&lt;/li&gt;
&lt;li&gt;Three thin clients—Swift, Kotlin, and React Native—that record audio and call &lt;em&gt;your&lt;/em&gt; backend, never AssemblyAI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Prerequisites&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An AssemblyAI account and API key. &lt;a href="https://www.assemblyai.com/dashboard/signup" rel="noopener noreferrer"&gt;Get your free API key&lt;/a&gt; from the &lt;a href="https://www.assemblyai.com/dashboard/home" rel="noopener noreferrer"&gt;dashboard&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Node 18+ or Python 3.8+ for the backend.&lt;/li&gt;
&lt;li&gt;Xcode for iOS, Android Studio for Android, or a React Native toolchain—whichever platforms you're targeting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're new to the API, the &lt;a href="https://www.assemblyai.com/docs/pre-recorded-audio/getting-started/transcribe-an-audio-file" rel="noopener noreferrer"&gt;transcribe an audio file quickstart&lt;/a&gt; covers the async flow end to end. This tutorial adapts it for mobile.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Architecture overview&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;One rule shapes everything: the API key lives on the server, and only the server talks to AssemblyAI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="err"&gt;┌──────────────┐&lt;/span&gt;        &lt;span class="err"&gt;┌──────────────────────┐&lt;/span&gt;        &lt;span class="err"&gt;┌─────────────────┐&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;  &lt;span class="nx"&gt;Mobile&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;  &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="nx"&gt;Your&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt;       &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="nx"&gt;AssemblyAI&lt;/span&gt;    &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Swift&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;    &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;holds&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="nx"&gt;REST&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt;      &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="nx"&gt;Kotlin&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;   &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;                      &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;                 &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="nx"&gt;RN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;                      &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;                 &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;              &lt;span class="err"&gt;│──&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="err"&gt;─▶│&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;transcribe&lt;/span&gt;     &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;                 &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;              &lt;span class="err"&gt;│&lt;/span&gt;  &lt;span class="nx"&gt;file&lt;/span&gt;  &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;v2&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;upload&lt;/span&gt; &lt;span class="err"&gt;─┼──&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="err"&gt;─▶&lt;/span&gt; &lt;span class="nx"&gt;upload_url&lt;/span&gt;      &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;              &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;v2&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="err"&gt;──&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="err"&gt;─▶&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;   &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;              &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;   &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt;  &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;v2&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;──&lt;/span&gt;&lt;span class="nx"&gt;poll&lt;/span&gt;&lt;span class="err"&gt;──▶&lt;/span&gt; &lt;span class="nx"&gt;completed&lt;/span&gt; &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;              &lt;span class="err"&gt;│◀─&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="err"&gt;──│&lt;/span&gt;   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;     &lt;span class="err"&gt;│&lt;/span&gt;        &lt;span class="err"&gt;│&lt;/span&gt;                 &lt;span class="err"&gt;│&lt;/span&gt;
&lt;span class="err"&gt;└──────────────┘&lt;/span&gt;        &lt;span class="err"&gt;└──────────────────────┘&lt;/span&gt;        &lt;span class="err"&gt;└─────────────────┘&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what's &lt;em&gt;not&lt;/em&gt; in the diagram: the mobile client never sees the API key and never has AssemblyAI's URL. It only knows about your endpoint. If you rotate the key or change models, the app doesn't change.&lt;/p&gt;

&lt;p&gt;Why a backend proxy specifically? For pre-recorded audio there's no temporary-token mechanism—temporary tokens exist only for &lt;a href="https://www.assemblyai.com/docs/streaming/authenticate-with-a-temporary-token" rel="noopener noreferrer"&gt;streaming&lt;/a&gt;. So for file transcription, the key has to live somewhere trusted, and that's your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The AssemblyAI async flow&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything the backend does is three REST calls against &lt;a href="https://api.assemblyai.com" rel="noopener noreferrer"&gt;https://api.assemblyai.com&lt;/a&gt;. Auth is your API key in the authorization header—no Bearer prefix.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upload (optional):&lt;/strong&gt; POST /v2/upload with the raw audio bytes as the body. Returns { "upload_url": "..." }. Skip this if your audio already lives at a public URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create transcript:&lt;/strong&gt; POST /v2/transcript with JSON { "audio_url": "..." }. Returns a transcript object with an id and a status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get result:&lt;/strong&gt; GET /v2/transcript/{id}. Poll every few seconds until status is completed or error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The status lifecycle is queued → processing → completed (or error).&lt;/p&gt;

&lt;p&gt;One gotcha that bites people on the upload step: the body must be &lt;strong&gt;raw bytes&lt;/strong&gt;, not JSON. If you send a JSON envelope, the upload appears to succeed but transcription fails later with "Transcoding failed." Send the file bytes directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Backend implementation&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Pick your language. Both versions expose a single POST /transcribe endpoint that accepts a multipart file upload from the mobile client and returns { "text": "..." }. Both read the API key from an environment variable—never hardcode it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Node (Express)&lt;/strong&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;npm&lt;/span&gt; &lt;span class="nx"&gt;install&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="nx"&gt;multer&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="nx"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your_key_here&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;multer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;multer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;multer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;multer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memoryStorage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.assemblyai.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Poll until the transcript is done or errors out.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pollTranscript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v2/transcript/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// poll every 3s&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/transcribe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Upload the raw audio bytes. Body is the buffer, NOT JSON.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uploadRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v2/upload`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;upload_url&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;uploadRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Create the transcript.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;createRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v2/transcript`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;audio_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;upload_url&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;createRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Poll for the result and return just the text.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pollTranscript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Listening on :8080&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;Python (FastAPI)&lt;/strong&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pip&lt;/span&gt; &lt;span class="n"&gt;install&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uvicorn[standard]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="n"&gt;python&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;multipart&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="n"&gt;export&lt;/span&gt; &lt;span class="n"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_key_here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UploadFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.assemblyai.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;poll_transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v2/transcript/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# poll every 3s
&lt;/span&gt;
&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/transcribe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UploadFile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;audio_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;# 1. Upload the raw bytes. data=audio_bytes sends them as-is, not JSON.
&lt;/span&gt;        &lt;span class="n"&gt;upload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v2/upload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;audio_bytes&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;upload_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upload_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="c1"&gt;# 2. Create the transcript.
&lt;/span&gt;        &lt;span class="n"&gt;create&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v2/transcript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;upload_url&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;transcript_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="c1"&gt;# 3. Poll and return the text.
&lt;/span&gt;        &lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_transcript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with uvicorn main:app --port 8080.&lt;/p&gt;

&lt;p&gt;A few production notes that apply to both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Polling vs. webhooks.&lt;/strong&gt; Polling every 3 seconds is simple and fine for short clips. For longer audio, hold the HTTP connection open only if your infra tolerates it—otherwise return the transcript id immediately and let the client poll a GET /transcribe/{id} route, or register a webhook so AssemblyAI notifies you when the job finishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limits.&lt;/strong&gt; The upload endpoint accepts files up to 2.2 GB. The transcript request body caps at 5 GB, and audio duration must be between 160ms and 10 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add features here, not on the client.&lt;/strong&gt; Want speaker labels or summarization? Add the options to the /v2/transcript JSON body server-side. The app doesn't change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For every field you can pass, see the &lt;a href="https://www.assemblyai.com/docs/api-reference/transcripts/submit" rel="noopener noreferrer"&gt;submit transcript reference&lt;/a&gt;, the &lt;a href="https://www.assemblyai.com/docs/api-reference/files/upload" rel="noopener noreferrer"&gt;upload reference&lt;/a&gt;, and the &lt;a href="https://www.assemblyai.com/docs/api-reference/transcripts/get" rel="noopener noreferrer"&gt;get transcript reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Client implementations&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;All three clients do the same three things: record audio to a file, POST that file to your backend as multipart form data, and show the returned text. None of them know AssemblyAI exists.&lt;/p&gt;

&lt;p&gt;Set BACKEND_URL to wherever your server runs. Use &lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt; for a simulator, or your machine's LAN IP for a physical device.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;iOS / Swift&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Record with AVAudioRecorder, then upload with URLSession. Add NSMicrophoneUsageDescription to your Info.plist first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AVFoundation&lt;/span&gt;

&lt;span class="nx"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Transcriber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NSObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AVAudioRecorderDelegate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kr"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AVAudioRecorder&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kr"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;backendURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8080/transcribe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="kr"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;lazy&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;FileManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;temporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendingPathComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recording.m4a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;

    &lt;span class="nx"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startRecording&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AVAudioSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sharedInstance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCategory&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="nx"&gt;playAndRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nx"&gt;AVFormatIDKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kAudioFormatMPEG4AAC&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nx"&gt;AVSampleRateKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;44100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;AVNumberOfChannelsKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nc"&gt;AVAudioRecorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;stopAndTranscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;throws&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;audioData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nc"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contentsOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;boundary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Boundary-&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;(UUID().uuidString)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;URLRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;backendURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpMethod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;multipart/form-data; boundary=&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;(boundary)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;forHTTPHeaderField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;// Build the multipart body under the "audio" field name.&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;(boundary)&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;using&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Disposition: form-data; name=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;; filename=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;recording.m4a&lt;/span&gt;&lt;span class="se"&gt;\"\r\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;using&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type: audio/m4a&lt;/span&gt;&lt;span class="se"&gt;\r\n\r\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;using&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="s2"&gt;--&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;(boundary)--&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;using&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;let &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;URLSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nc"&gt;JSONDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TranscriptResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;struct&lt;/span&gt; &lt;span class="nx"&gt;TranscriptResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Decodable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key anywhere. The client's entire world is backendURL.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Android / Kotlin&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Record with MediaRecorder, upload with OkHttp. Declare RECORD_AUDIO in your manifest and request it at runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;android&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;media&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MediaRecorder&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;okhttp3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MediaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Companion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toMediaType&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;okhttp3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MultipartBody&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;okhttp3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OkHttpClient&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;okhttp3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Request&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;okhttp3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RequestBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Companion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;asRequestBody&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JSONObject&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;java&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;File&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Transcriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="nx"&gt;cacheDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="kr"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OkHttpClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kr"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="nx"&gt;backendUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://10.0.2.2:8080/transcribe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="c1"&gt;// 10.0.2.2 = host from emulator&lt;/span&gt;
    &lt;span class="kr"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="nx"&gt;outputFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recording.m4a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kr"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

    &lt;span class="nx"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;startRecording&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;setAudioSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AudioSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MIC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;setOutputFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OutputFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MPEG_4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;setAudioEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AudioEncoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AAC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;setAudioChannels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;setOutputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputFile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;absolutePath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;stopAndTranscribe&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

        &lt;span class="c1"&gt;// Multipart upload under the "audio" field name.&lt;/span&gt;
        &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MultipartBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MultipartBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FORM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addFormDataPart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recording.m4a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nx"&gt;outputFile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asRequestBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio/m4a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMediaType&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backendUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;use&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;JSONObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call stopAndTranscribe() off the main thread (a coroutine on Dispatchers.IO works well).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;React Native&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Record with a library like react-native-audio-recorder-player, then upload with the built-in fetch and FormData:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AudioRecorderPlayer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-native-audio-recorder-player&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioRecorderPlayer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BACKEND_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8080/transcribe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startRecording&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startRecorder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// returns the file path&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stopAndTranscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filePath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopRecorder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recording.m4a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio/m4a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BACKEND_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// don't set Content-Type; fetch sets the multipart boundary&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because JavaScript is a first-class citizen here, you might be tempted to reach for the assemblyai npm package inside your React Native app. Don't. The Node SDK is built for server environments, and using it on the client means embedding your API key in the bundle—the exact thing we're avoiding. The fetch-to-your-backend pattern above is the correct one for React Native.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Testing and validation&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Test the backend on its own before wiring up a phone. Record or grab any short audio file and hit the endpoint with curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;curl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;X&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//localhost:8080/transcribe \&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio=@sample.m4a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should get back {"text":"..."} within a few seconds for a short clip. If you don't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Transcoding failed"&lt;/strong&gt; almost always means the upload body wasn't raw bytes. Confirm you're sending the buffer/bytes directly, not a JSON wrapper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;401 Unauthorized&lt;/strong&gt; means the authorization header is missing or wrong. Check that ASSEMBLYAI_API_KEY is set in the server's environment—and remember, no Bearer prefix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The request hangs&lt;/strong&gt; on long audio because polling holds the connection open. Switch to the return-id-and-poll pattern or a webhook.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once curl works, point the app at the backend. On a physical device, localhost won't resolve to your dev machine—use its LAN IP (for example &lt;a href="http://192.168.1.20:8080/transcribe" rel="noopener noreferrer"&gt;http://192.168.1.20:8080/transcribe&lt;/a&gt;), and on Android the emulator reaches the host at 10.0.2.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Realtime from mobile (next step)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you need live captions instead of file-at-a-time transcription, AssemblyAI has a streaming API over WebSocket at wss://streaming.assemblyai.com/v3/ws. There's still no mobile-native streaming SDK, but streaming has something file transcription doesn't: short-lived tokens.&lt;/p&gt;

&lt;p&gt;Your backend calls GET /v3/token?expires_in_seconds=60 (tokens last 1–600s and are single-use), hands the token to the app, and the app opens the WebSocket with the token as a token query param. The key still never leaves your server; the token is safe to give the client because it expires fast and works once. Audio goes up as mono 16-bit PCM.&lt;/p&gt;

&lt;p&gt;That flow is documented in &lt;a href="https://www.assemblyai.com/docs/streaming/authenticate-with-a-temporary-token" rel="noopener noreferrer"&gt;authenticate with a temporary token&lt;/a&gt;. For a working streaming client to model your backend on, see &lt;a href="https://www.assemblyai.com/blog/real-time-transcription-python" rel="noopener noreferrer"&gt;real-time transcription in Python&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Next steps&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;You now have a mobile transcription pipeline that keeps your API key where it belongs. From here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add speech understanding.&lt;/strong&gt; Speaker diarization, summarization, and more are options on the /v2/transcript body—turn them on server-side and your app gets them for free. Start with &lt;a href="https://www.assemblyai.com/blog/speech-to-text" rel="noopener noreferrer"&gt;what is speech-to-text&lt;/a&gt; and the &lt;a href="https://www.assemblyai.com/products/speech-to-text" rel="noopener noreferrer"&gt;Speech-to-Text product page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harden the backend.&lt;/strong&gt; Add authentication on your /transcribe route, rate limiting, and request logging—the proxy is the natural place for all of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explore every parameter.&lt;/strong&gt; The &lt;a href="https://www.assemblyai.com/docs/api-reference/overview" rel="noopener noreferrer"&gt;full API reference&lt;/a&gt; documents the complete transcript object.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Is there an official AssemblyAI iOS or Android SDK?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. The only official SDKs are Python (pip install assemblyai) and JavaScript/TypeScript (npm install assemblyai). For mobile, call the REST API at &lt;a href="https://api.assemblyai.com" rel="noopener noreferrer"&gt;https://api.assemblyai.com&lt;/a&gt; from a backend you control.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can I call the AssemblyAI API directly from my mobile app?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Technically the REST API works from anywhere, but you shouldn't. Calling it directly means embedding your API key in the app binary, where it can be extracted. Route every request through your own backend instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I keep my API key safe in a mobile app?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Never ship it to the client. Store it as an environment variable on your server, have the app send audio to your backend, and let the backend attach the authorization header when it calls AssemblyAI. That's the architecture in this tutorial.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does AssemblyAI support React Native?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;There's no React Native SDK. Use fetch with FormData to send audio to your backend, exactly like the web. Avoid the assemblyai npm package on the client—it's a server SDK and would expose your key.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can I do realtime transcription on mobile?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes, over the streaming WebSocket at wss://streaming.assemblyai.com/v3/ws. There's no mobile streaming SDK, so your backend mints a one-time token via GET /v3/token?expires_in_seconds=60 and the app connects with it. Audio is mono 16-bit PCM. See the &lt;a href="https://www.assemblyai.com/docs/streaming/authenticate-with-a-temporary-token" rel="noopener noreferrer"&gt;temporary token docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What are the file size and duration limits?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The upload endpoint accepts files up to 2.2 GB. The transcript request body caps at 5 GB, and audio must run between 160ms and 10 hours.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mobile</category>
      <category>speechtotext</category>
      <category>api</category>
    </item>
    <item>
      <title>Why Realtime Is the Future of Speech-to-Text</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:55:30 +0000</pubDate>
      <link>https://dev.to/martschweiger/why-realtime-is-the-future-of-speech-to-text-2bii</link>
      <guid>https://dev.to/martschweiger/why-realtime-is-the-future-of-speech-to-text-2bii</guid>
      <description>&lt;p&gt;For most of the last decade, transcription was something you did &lt;em&gt;after&lt;/em&gt; the fact.&lt;/p&gt;

&lt;p&gt;You recorded a call, dumped the file into a queue, waited a few minutes (or a few hours), and got back a block of text. That was the deal. Batch, async, post-hoc—whatever you want to call it, the audio was already over by the time the model saw it. And for a long time, that was fine, because that's all the technology could reliably do.&lt;/p&gt;

&lt;p&gt;Recently, the field has quietly crossed the line where the most interesting, highest-value speech-to-text work happens &lt;em&gt;while people are still talking&lt;/em&gt;. Realtime isn't a niche feature you bolt on for a captions demo. It's becoming the default that everything else gets measured against.&lt;/p&gt;

&lt;p&gt;Here's the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The shift: the interesting STT workloads are now live&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Look at where the energy is going in Voice AI right now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.assemblyai.com/blog/ai-voice-agents" rel="noopener noreferrer"&gt;Voice agents&lt;/a&gt; that answer the phone and actually resolve the issue. Agent-assist tools that whisper the next best action to a rep mid-call. Live captions that keep up with a fast talker. Ambient scribes that write the clinical note as the visit unfolds. &lt;a href="https://www.assemblyai.com/blog/real-time-conversation-intelligence" rel="noopener noreferrer"&gt;Real-time conversation intelligence&lt;/a&gt; that flags a compliance risk or a churn signal &lt;em&gt;before&lt;/em&gt; the call ends, not in a report the next morning.&lt;/p&gt;

&lt;p&gt;Every one of those is realtime-first. Not realtime-optional—realtime-first. The value evaporates if you make the user wait.&lt;/p&gt;

&lt;p&gt;Compare that to the classic batch workloads: transcribing a podcast back catalog, indexing a media archive, generating subtitles for content that already exists. Those are real, they matter, and they're not going away. But notice the pattern—they're all about audio that's &lt;em&gt;already done&lt;/em&gt;. The frontier, the stuff people are building companies around in 2026, is about audio that's &lt;em&gt;happening&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So the question flips. It used to be "when do I actually need realtime?" Now it's closer to "why am I making this a two-step process at all?"&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What changed: latency and accuracy crossed the usability line at the same time&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;For years you could have fast transcription or accurate transcription in the streaming setting, but picking both felt like a trap. Realtime models were noticeably worse than their batch cousins. So teams defaulted to batch when quality mattered and only reached for streaming when they absolutely had to.&lt;/p&gt;

&lt;p&gt;That tradeoff is mostly gone. And it went away because two curves crossed the usability threshold at roughly the same moment.&lt;/p&gt;

&lt;p&gt;The first is latency. &lt;a href="https://www.assemblyai.com/blog/universal-3-5-pro-realtime" rel="noopener noreferrer"&gt;Universal-3.5 Pro Realtime&lt;/a&gt;, the current streaming flagship, returns partial transcripts in a few hundred milliseconds and finalizes in the same ballpark. The prior generation, Universal-3 Pro Streaming, already ran around 150ms P50 post-VAD, roughly 240ms at P90, with time-to-complete-transcript near 250ms. Those aren't "good for realtime" numbers. Those are numbers where a person on the other end of a phone call can't tell a machine is in the loop.&lt;/p&gt;

&lt;p&gt;The second curve is accuracy. On Pipecat's open STT benchmark, run on real agent conversations, Universal-3.5 Pro Realtime posted a &lt;strong&gt;6.99% pooled word error rate&lt;/strong&gt;. For context, that same benchmark put Deepgram Flux at 15.58%, ElevenLabs Scribe v2 at 9.76%, and Google Chirp3 at 9.04%. The entity numbers are where it really shows up: 15.31% entity error rate versus 50.50% for the next-closest streaming competitor on that run, and 3.55% on phone numbers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Streaming model&lt;/th&gt;
&lt;th&gt;Pooled word error rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Universal-3.5 Pro Realtime&lt;/td&gt;
&lt;td&gt;6.99%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Chirp3&lt;/td&gt;
&lt;td&gt;9.04%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ElevenLabs Scribe v2&lt;/td&gt;
&lt;td&gt;9.76%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deepgram Flux&lt;/td&gt;
&lt;td&gt;15.58%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sit with that for a second. A &lt;em&gt;streaming&lt;/em&gt; model, transcribing live, is now clearing accuracy bars that batch models were bragging about a couple of years ago.&lt;/p&gt;

&lt;p&gt;And it keeps getting smarter mid-conversation. Universal-3.5 Pro Realtime takes direction from the application through agent_context—you pass in what the agent just asked, so a mumbled reply, a spelled-out account ID, or a one-word "yep" resolves correctly instead of turning into garbage. Across a benchmark of 20,000 voice agent audio files, passing that context &lt;strong&gt;cut WER by 10.2%&lt;/strong&gt;, with fabrications down 18.3% and hallucinations down 17.2%.&lt;/p&gt;

&lt;p&gt;Then there's turn-taking. Old systems decided you were done talking by waiting for silence, which is why they interrupted you every time you paused to think. The end-of-turn detection here reads tonality, pacing, and rhythm—not just a silence timer—and fires in about 300ms. That's the difference between a conversation and a walkie-talkie.&lt;/p&gt;

&lt;p&gt;When fast and accurate stop being a tradeoff, the reason to default to batch quietly disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;But here's the thing: realtime is genuinely harder&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;It may be easy to read the section above and conclude realtime is just batch with a lower latency setting. It's not. It's a different engineering problem, and pretending otherwise is how teams ship a demo that falls apart in production.&lt;/p&gt;

&lt;p&gt;Batch has a luxury realtime never gets: the whole recording. A batch model can look ahead, reconsider, and clean up before it hands you a single finished answer. A streaming model has to commit to words it can't take back while the sentence is still being spoken.&lt;/p&gt;

&lt;p&gt;That creates hard problems that simply don't exist in async:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn detection.&lt;/strong&gt; Deciding &lt;em&gt;when a person is done&lt;/em&gt; is its own discipline. Cut them off and the agent talks over them; wait too long and it feels laggy and dead. Getting this right is arguably harder than the transcription itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial versus final transcripts.&lt;/strong&gt; Streaming emits guesses (partials) that it later confirms or revises (finals). Your application has to be built around that flicker—rendering something useful now, correcting it gracefully a moment later—without making the UI jump around like a broken slot machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live context and memory.&lt;/strong&gt; A batch model gets the full conversation for free. A streaming model has to &lt;em&gt;carry&lt;/em&gt; context forward as it goes. Universal-3.5 Pro Realtime keeps a short rolling memory (Context Carryover, on by default) so it remembers what was said thirty seconds ago instead of treating every utterance as a cold start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diarization with revision.&lt;/strong&gt; Figuring out who's speaking, live, is brutal—especially with overlap and short back-and-forth turns. The approach that actually works is to label speakers in real time and then send a single correction that re-clusters the whole session within about half a second of the stream ending. We wrote up the mechanics in &lt;a href="https://www.assemblyai.com/blog/streaming-speaker-diarization" rel="noopener noreferrer"&gt;streaming speaker diarization&lt;/a&gt;, and it's a good illustration of the general principle: realtime often means being &lt;em&gt;provisionally right now&lt;/em&gt; and &lt;em&gt;definitively right slightly later&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;None of this is a reason to avoid realtime. It's a reason to take it seriously as its own category—and to be skeptical of any provider that treats streaming as an afterthought to their batch product.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The business case: latency is starting to look like a revenue metric&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting for the people who sign the invoices.&lt;/p&gt;

&lt;p&gt;We've spent years talking about transcription accuracy as the thing that drives business outcomes, and it does—we've written a whole piece on &lt;a href="https://www.assemblyai.com/blog/true-cost-of-inaccurate-transcription" rel="noopener noreferrer"&gt;the true cost of inaccurate transcription&lt;/a&gt;. But in realtime systems, latency starts behaving like its own outcome variable.&lt;/p&gt;

&lt;p&gt;Think about a voice agent taking an inbound call. Every extra beat of dead air after the caller stops talking is a beat where the caller wonders if the line dropped, gets annoyed, or hangs up. I'd frame it as an open question the whole industry should be measuring more rigorously: &lt;strong&gt;does streaming latency move call conversion and hangup rates?&lt;/strong&gt; My strong suspicion, from what we see across customers, is yes—and that the effect is larger than most teams assume. A voice agent that responds in about a second feels like a conversation. One that responds in three feels like a hold queue. Same transcript, wildly different business result.&lt;/p&gt;

&lt;p&gt;That reframes the whole evaluation. If you're only benchmarking word error rate, you're measuring half the system. In a live setting, &lt;em&gt;how fast the words arrive&lt;/em&gt; and &lt;em&gt;how naturally the turns flow&lt;/em&gt; are load-bearing for the metric you actually care about, whether that's containment rate, CSAT, or conversion. This is part of why we keep arguing that &lt;a href="https://www.assemblyai.com/blog/word-error-rate-is-broken" rel="noopener noreferrer"&gt;word error rate alone is broken&lt;/a&gt; as a way to judge a model—it says nothing about whether the system feels alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Where batch still absolutely makes sense&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;I'd lose credibility fast if I told you batch is dead. It isn't, and I'd steer you toward it in plenty of cases.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Realtime (streaming)&lt;/th&gt;
&lt;th&gt;Batch (async)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Interactive work — voice agents, agent assist, live captions, ambient scribes&lt;/td&gt;
&lt;td&gt;Already-recorded audio — archives, media libraries, overnight analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;Universal-3.5 Pro Realtime&lt;/td&gt;
&lt;td&gt;Universal-3.5 Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing&lt;/td&gt;
&lt;td&gt;$0.45/hr base, billed on session duration&lt;/td&gt;
&lt;td&gt;$0.21/hr, billed on audio duration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key advantage&lt;/td&gt;
&lt;td&gt;Acts on speech while the conversation is still happening&lt;/td&gt;
&lt;td&gt;Look-ahead over the whole file; simpler and cheaper at volume&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the audio is already recorded and nobody's waiting on the result, batch is the right tool. Transcribing an archive of ten thousand old calls. Subtitling a media library. Running analytics over a quarter of sales conversations overnight. There's no live interaction to preserve, so paying the complexity tax of streaming buys you nothing.&lt;/p&gt;

&lt;p&gt;Cost is real, too. Batch &lt;a href="https://www.assemblyai.com/blog/speech-to-text" rel="noopener noreferrer"&gt;speech-to-text&lt;/a&gt; with Universal-3.5 Pro runs $0.21/hr of audio, billed on audio duration. Streaming with Universal-3.5 Pro Realtime is $0.45/hr base, billed on session duration—the whole time the WebSocket is open. For huge volumes of already-finished audio, the async path is simpler and cheaper, full stop.&lt;/p&gt;

&lt;p&gt;And batch still gets that look-ahead luxury. When you can afford to wait, a model that sees the entire file can sometimes squeeze out accuracy that no streaming system will match on the hardest audio.&lt;/p&gt;

&lt;p&gt;So the honest position isn't "realtime replaces batch." It's "realtime becomes the default for interactive work, and batch keeps owning the archive." Two tools, clearer boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What to look for in a realtime STT API&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you buy the argument that realtime is where the field is heading, here's what I'd actually evaluate—because "we support streaming" on a pricing page tells you almost nothing.&lt;/p&gt;

&lt;p&gt;Ask about latency &lt;em&gt;and&lt;/em&gt; accuracy together, on realistic audio. A model that's fast on clean read speech and falls apart on a noisy phone call in an accent isn't solving your problem. That's why we benchmark on real agent conversations, not lab recordings, and publish the numbers on our &lt;a href="https://www.assemblyai.com/benchmarks" rel="noopener noreferrer"&gt;benchmarks page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Ask how turn detection works. Silence-timer only? Or does it read tonality and pacing? This single choice determines whether your product feels human.&lt;/p&gt;

&lt;p&gt;Ask whether the model can take live context. Can you feed it the agent's current question or a set of key terms mid-stream? agent_context and keyterm prompting are the difference between a model that guesses at "my ID is A-as-in-apple, 4, 4, 9" and one that gets it right.&lt;/p&gt;

&lt;p&gt;Ask about diarization and whether it revises. Live speaker labels that never get corrected will drift. The revise-at-end-of-turn pattern is what keeps them honest.&lt;/p&gt;

&lt;p&gt;And ask about the production stuff—concurrency limits, EU data residency, a signable BAA for healthcare workloads. Universal-3.5 Pro Realtime runs with unlimited concurrency and no rate limits, which matters the day your traffic spikes and you don't want to file a ticket to serve it.&lt;/p&gt;

&lt;p&gt;For the full picture of how we think about the live path, the &lt;a href="https://www.assemblyai.com/products/streaming-speech-to-text" rel="noopener noreferrer"&gt;streaming speech-to-text product page&lt;/a&gt; is the place to start, and if you're weighing accuracy specifically, &lt;a href="https://www.assemblyai.com/blog/how-accurate-speech-to-text" rel="noopener noreferrer"&gt;how accurate is speech-to-text in 2026&lt;/a&gt; digs into the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The close: the transcript stops being the product&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the forward-looking part, and it's the thing I've been circling the whole time.&lt;/p&gt;

&lt;p&gt;As realtime becomes the default, the transcript itself stops being the deliverable. For decades the output of speech-to-text &lt;em&gt;was&lt;/em&gt; a document—text you'd read, search, or file. But a realtime transcript isn't something a human reads. It's something &lt;em&gt;another system&lt;/em&gt; consumes, instantly, to make a decision: route the call, surface the answer, flag the risk, generate the next turn.&lt;/p&gt;

&lt;p&gt;That changes what "good" means. The winning realtime models won't just be the ones with the lowest word error rate. They'll be the ones that emit the most &lt;em&gt;machine-actionable&lt;/em&gt; stream—context-aware partials, confident finals, live speaker turns, end-of-turn signals—in a shape the next system can act on without waiting.&lt;/p&gt;

&lt;p&gt;In other words, speech-to-text is quietly turning from a transcription tool into a real-time interface between human speech and software. The transcript is becoming plumbing. And once you see it that way, batch-versus-realtime stops being a feature comparison and starts looking like the difference between reading history and participating in the present.&lt;/p&gt;

&lt;p&gt;That's the future I'd bet on. The rest of the field is already building for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Is realtime speech-to-text replacing batch transcription?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No—and anyone who says so is oversimplifying. Realtime is becoming the default for &lt;em&gt;interactive&lt;/em&gt; workloads like voice agents, agent assist, live captions, and ambient scribes, where making a user wait kills the value. Batch still wins for already-recorded audio: archives, bulk media, and overnight analytics, where it's simpler and cheaper. Batch async runs $0.21/hr with Universal-3.5 Pro; streaming runs $0.45/hr base. Think two tools with clearer boundaries, not a replacement.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What's the difference between realtime and streaming speech-to-text?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;They're the same thing. "Streaming" describes the mechanism—audio flows over a WebSocket connection and the model returns partial and final transcripts as it listens. "Realtime" describes the outcome—results arrive fast enough to act on while the conversation is still happening. AssemblyAI's streaming API uses wss://streaming.assemblyai.com/v3/ws.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How low does latency need to be for a good voice experience?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For a natural back-and-forth, you want transcripts arriving in a few hundred milliseconds and turn detection firing quickly enough that the system doesn't talk over people or leave dead air. Universal-3.5 Pro Realtime returns partials in a few hundred ms, and its end-of-turn detection reads tonality and pacing to fire in about 300ms rather than waiting on a silence timer. End to end, a well-built voice agent lands around one second.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Do you sacrifice accuracy by going realtime?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Much less than you used to. On Pipecat's open STT benchmark of real agent conversations, Universal-3.5 Pro Realtime posted a 6.99% pooled word error rate—ahead of Deepgram Flux (15.58%), ElevenLabs Scribe v2 (9.76%), and Google Chirp3 (9.04%). Passing agent context cut WER by a further 10.2% across 20,000 files. Batch can still edge ahead on the hardest audio because it sees the whole file, but the gap has narrowed dramatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What are the main realtime speech-to-text use cases?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Voice agents, live agent assist for contact center reps, real-time &lt;a href="https://www.assemblyai.com/blog/real-time-conversation-intelligence" rel="noopener noreferrer"&gt;conversation intelligence&lt;/a&gt;, live captioning, and ambient clinical scribes. The common thread: another system consumes the transcript instantly to make a decision, so post-call transcription wouldn't work.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I get started with realtime speech-to-text?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Open a WebSocket to the streaming endpoint and start sending audio—you'll get partial and final transcripts back right away. The &lt;a href="https://www.assemblyai.com/products/streaming-speech-to-text" rel="noopener noreferrer"&gt;streaming speech-to-text product page&lt;/a&gt; has the details.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>speechtotext</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI Medical Scribe: Build vs Buy Nuance DAX &amp; Abridge</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:55:23 +0000</pubDate>
      <link>https://dev.to/martschweiger/ai-medical-scribe-build-vs-buy-nuance-dax-abridge-53hk</link>
      <guid>https://dev.to/martschweiger/ai-medical-scribe-build-vs-buy-nuance-dax-abridge-53hk</guid>
      <description>&lt;p&gt;Every healthcare AI team lands on the same question eventually. You want ambient clinical documentation in your product, and there are two obvious paths. Buy a finished scribe like Nuance DAX Copilot or Abridge and wire it in. Or build your own.&lt;/p&gt;

&lt;p&gt;Here's the reframe most comparison posts miss. DAX and Abridge are finished products. They're good ones. If you're a health system that wants a scribe in clinicians' hands next quarter and you're never going to differentiate on documentation itself, buying is often the right call. Full stop.&lt;/p&gt;

&lt;p&gt;But if you're a product team building a clinical application, a specialty EHR, or an ambient scribe you plan to sell, the "which scribe do I buy" question is the wrong one. You're not shopping for a scribe. You're deciding whether documentation is a feature you own or a feature you rent. And if you build, the question becomes: what do you build on?&lt;/p&gt;

&lt;p&gt;That's the fork. Let's walk it honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The build-vs-buy fork for ambient clinical documentation&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When you buy DAX or Abridge, you get a lot the same day: a working scribe, a note format clinicians already recognize, EHR integrations, a compliance posture, and a vendor who owns the roadmap. That's real value, and pretending otherwise is a mistake.&lt;/p&gt;

&lt;p&gt;What you give up is control. The note format is theirs. The specialties they support are theirs. The pace of improvement is theirs. And the economics are per-seat, per-provider, forever—which means your gross margin on documentation is capped by someone else's price list.&lt;/p&gt;

&lt;p&gt;When you build, you flip every one of those. You own the note structure, the specialty coverage, the UX, the model choices underneath, and the margin. You also own the work: accurate medical transcription, speaker separation, note generation, and compliance. That's not a weekend project. But none of it is mysterious anymore, and most of the hard part isn't where teams expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What "buy" actually costs you&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The sticker price is the easy part. The real costs show up later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Margin.&lt;/strong&gt; Per-seat pricing scales with your clinician count, not your efficiency. Get 10,000 providers on the platform and your documentation cost grows linearly right alongside. You can't engineer that number down because you don't own the stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Control over the note.&lt;/strong&gt; Cardiology notes and behavioral health notes shouldn't look the same. If your differentiation is a note tuned to a specialty or a workflow, a general-purpose scribe fights you on it. You file feature requests and wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in.&lt;/strong&gt; Once your workflows, integrations, and clinician muscle memory are built around a vendor's output format, switching is a migration project, not a config change. That's leverage—theirs, not yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Differentiation.&lt;/strong&gt; If you and three competitors all ship the same underlying scribe, documentation stops being a reason anyone chooses you. It becomes table stakes you're paying a premium to rent.&lt;/p&gt;

&lt;p&gt;None of this makes buying wrong. It makes buying a strategic decision about where your product's value actually lives. If documentation isn't your edge, rent it and move on. If it is—or could be—renting your core is a strange way to build a company.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What "build" actually requires&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;An ambient scribe is a pipeline, and it's shorter than it looks. Four layers.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Medical speech-to-text.&lt;/strong&gt; The audio-to-text layer. Multi-speaker clinical audio, drug names, procedures, dosages, accents, background noise. This is the input to everything downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speaker diarization.&lt;/strong&gt; Who said what. You need clinician and patient separated cleanly, including the rapid back-and-forth and interruptions of a real visit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note generation.&lt;/strong&gt; An LLM turns the diarized transcript into a structured note—SOAP, H&amp;amp;P, whatever your specialty needs. This is where teams love to spend their time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance.&lt;/strong&gt; A BAA, PHI handling, access controls, audit trails.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the part that surprises people. The LLM layer, the part everyone's excited about, is the most commoditized. Strong models are a few API calls away, and prompt engineering a good SOAP note is tractable. The layer that quietly decides whether your scribe is trustworthy is the first one—the &lt;a href="https://www.assemblyai.com/products/speech-to-text" rel="noopener noreferrer"&gt;speech-to-text&lt;/a&gt; foundation. Garbage transcript in, confident-sounding garbage note out. An LLM will happily summarize a hallucinated dosage into a clean, plausible sentence a clinician might sign.&lt;/p&gt;

&lt;p&gt;So the build decision is really a decision about your transcription layer. Get that right and the rest of the pipeline has something solid to stand on.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The speech-to-text layer is make-or-break&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is the input that determines everything, so it's worth being specific about what "good enough for clinical" means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Medical accuracy that isn't generic.&lt;/strong&gt; General speech-to-text mangles clinical vocabulary—that's the whole problem. AssemblyAI's &lt;a href="https://www.assemblyai.com/blog/universal-3-5-pro-async" rel="noopener noreferrer"&gt;Universal-3.5 Pro&lt;/a&gt; is the flagship async model at $0.21/hr, and you turn on medical accuracy by adding one parameter: "domain": "medical-v1". That's Medical Mode, +$0.15/hr, so the flagship plus Medical Mode runs $0.36/hr combined. It reduces the Missed Entity Rate on drugs, conditions, procedures, and clinical terms by roughly 20%. No model switch, no separate pipeline—one param on the model you're already calling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context that adapts to the patient.&lt;/strong&gt; Medical Mode gets you baseline clinical accuracy. Contextual prompting gets you the last mile. Prime the model with a patient's prior-visit note and it knows what to listen for. In an internal healthcare test, feeding a prior-visit note cut missed medical terms by 31%—even when the note was from an earlier visit. That's the kind of gain you can't buy off a shelf because it depends on data only your application has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diarization built in.&lt;/strong&gt; Universal-3.5 Pro produces the transcript and the speaker turns together—the most accurate diarization we've shipped, optimized for cpWER and tuned for the short turns, rapid exchanges, and overlapped speech of an actual exam room. You're not bolting on a separate speaker model and hoping the timestamps line up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A streaming option when you need it.&lt;/strong&gt; Most ambient scribes are async—record the visit, generate the note. But if you're building live documentation, a real-time assistant, or clinician-facing prompts mid-visit, &lt;a href="https://www.assemblyai.com/blog/universal-3-5-pro-realtime" rel="noopener noreferrer"&gt;Universal-3.5 Pro Realtime&lt;/a&gt; offers Medical Mode too, with live diarization. Same foundation, real-time shape. That flexibility matters when your roadmap outgrows batch processing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Languages.&lt;/strong&gt; Medical Mode supports English, Spanish, German, and French—so a Spanish-language visit gets clinical-grade transcription, not a downgrade.&lt;/p&gt;

&lt;p&gt;Compare that to buying: with DAX or Abridge, the transcription layer is a black box. You can't tune it, you can't prompt it with your data, and you can't fix it when it's wrong on a term that matters to your specialty. Building on a foundation you control means the accuracy ceiling is yours to raise. If you want the deeper argument for why this input dominates outcomes, we made it in &lt;a href="https://www.assemblyai.com/blog/true-cost-of-inaccurate-transcription" rel="noopener noreferrer"&gt;the true cost of inaccurate transcription&lt;/a&gt; and in why &lt;a href="https://www.assemblyai.com/blog/transcription-accuracy-vs-transcription-quality" rel="noopener noreferrer"&gt;transcription accuracy and transcription quality&lt;/a&gt; aren't the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Compliance, done right&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Compliance is where "build" teams get nervous, and fair enough—PHI is not the place to improvise. The good news is that the transcription layer of your stack can carry a clean posture.&lt;/p&gt;

&lt;p&gt;AssemblyAI enables covered entities and their business associates subject to HIPAA to use the AssemblyAI services to process protected health information (PHI). AssemblyAI is considered a business associate under HIPAA, and we offer a Business Associate Addendum (BAA) that is required under HIPAA to ensure that AssemblyAI appropriately safeguards PHI.&lt;/p&gt;

&lt;p&gt;Practically, that BAA is available to sign without booking a sales call—so a compliance review doesn't turn into a procurement saga before you've written a line of code. For teams with data-residency requirements, EU processing is available too. Compliance is table stakes, but it shouldn't be the thing that slows your build to a crawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Build vs buy: the decision at a glance&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the comparison that actually matters—not a spec smackdown, but the strategic trade-offs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision criterion&lt;/th&gt;
&lt;th&gt;Buy (DAX / Abridge)&lt;/th&gt;
&lt;th&gt;Build (on AssemblyAI)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Control over note format &amp;amp; UX&lt;/td&gt;
&lt;td&gt;Vendor-defined; feature requests&lt;/td&gt;
&lt;td&gt;Fully yours — tune per specialty and workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gross margin on documentation&lt;/td&gt;
&lt;td&gt;Capped by per-seat pricing&lt;/td&gt;
&lt;td&gt;Yours to engineer; usage-based inputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-to-market&lt;/td&gt;
&lt;td&gt;Fastest — finished product&lt;/td&gt;
&lt;td&gt;Longer, but the STT layer is an API call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Differentiation&lt;/td&gt;
&lt;td&gt;Shared with everyone on the same scribe&lt;/td&gt;
&lt;td&gt;Documentation becomes a product edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance ownership&lt;/td&gt;
&lt;td&gt;Vendor's posture&lt;/td&gt;
&lt;td&gt;Your posture; BAA available on the STT layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost model&lt;/td&gt;
&lt;td&gt;Per-seat, per-provider, recurring&lt;/td&gt;
&lt;td&gt;Pay-as-you-go on transcription ($0.36/hr with Medical Mode)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roadmap &amp;amp; pace of improvement&lt;/td&gt;
&lt;td&gt;Vendor's priorities&lt;/td&gt;
&lt;td&gt;Yours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Orgs that won't differentiate on docs&lt;/td&gt;
&lt;td&gt;Product teams selling or owning the experience&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Who each path is for&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Buy DAX or Abridge if:&lt;/strong&gt; you're a health system or clinic that needs a working scribe in clinicians' hands fast, documentation isn't part of your competitive story, and you'd rather pay per seat than staff an engineering team against it. That's a legitimate, common, correct choice. These are mature products for exactly this buyer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build if:&lt;/strong&gt; documentation is part of what you sell, you need note formats or specialty coverage a general scribe won't give you, per-seat economics break your margin at scale, or you're already a software company and owning the stack is how you compete. In that case, the smart move isn't building transcription from scratch—it's building on a transcription layer that's already solved medical accuracy, diarization, and compliance, so your team spends its time on the note logic and product experience that actually differentiate you.&lt;/p&gt;

&lt;p&gt;Healthcare teams building their own documentation experiences tend to land in the second camp for the same reason: they want the margin and the control, and they don't want to reinvent speech-to-text to get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A quick decision checklist&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Run your project through these before committing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is documentation a feature we sell, or a chore we want handled? If we sell it, lean build.&lt;/li&gt;
&lt;li&gt;Do we need note formats or specialties a general scribe won't support? If yes, lean build.&lt;/li&gt;
&lt;li&gt;What does per-seat pricing do to our margin at 1,000 and 10,000 providers? If it breaks, lean build.&lt;/li&gt;
&lt;li&gt;Do we have (or want) engineering ownership of this workflow? If not, lean buy.&lt;/li&gt;
&lt;li&gt;How fast do we need to ship? If it's this quarter and docs aren't our edge, lean buy.&lt;/li&gt;
&lt;li&gt;If we build, is our transcription layer clinically accurate, diarized, and BAA-backed on day one? If not, fix that first—it's the foundation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're weighing the broader landscape, our guides on &lt;a href="https://www.assemblyai.com/blog/how-to-use-speech-ai-for-healthcare-market-research" rel="noopener noreferrer"&gt;Voice AI for healthcare market research&lt;/a&gt; and &lt;a href="https://www.assemblyai.com/blog/conversation-intelligence" rel="noopener noreferrer"&gt;conversation intelligence&lt;/a&gt; show how the same speech foundation extends well past the exam room. For the numbers behind the models, the &lt;a href="https://www.assemblyai.com/benchmarks" rel="noopener noreferrer"&gt;benchmarks&lt;/a&gt; and our take on &lt;a href="https://www.assemblyai.com/blog/how-accurate-speech-to-text" rel="noopener noreferrer"&gt;how accurate speech-to-text is in 2026&lt;/a&gt; are the receipts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.assemblyai.com/products" rel="noopener noreferrer"&gt;&lt;strong&gt;Explore Voice AI solutions&lt;/strong&gt;&lt;/a&gt; to see the full platform under a scribe you own—or dig into the &lt;a href="https://www.assemblyai.com/solutions/medical" rel="noopener noreferrer"&gt;medical solution&lt;/a&gt; directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The insight most teams learn too late&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody tells you at the start. Build vs buy isn't a one-time decision—it's a decision that gets more expensive to reverse the longer you wait. The team that buys "to move fast" and plans to build later usually can't—by then their workflows, integrations, and clinician habits are welded to a vendor's format. And the team that builds on a weak transcription layer finds the ceiling only after shipping, when clinicians stop trusting the notes.&lt;/p&gt;

&lt;p&gt;So the real move is to decouple the decision. Even if you buy a finished scribe today, own the layer that's hardest to swap later—your speech-to-text foundation—so building stays a live option instead of a fantasy. Accuracy compounds. Lock-in compounds. Pick the one you want on your side.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Should I build my own AI medical scribe or buy Nuance DAX or Abridge?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Buy if you need a finished scribe fast and documentation isn't your competitive edge—DAX and Abridge are mature products built for that. Build if documentation is part of what you sell, you need custom note formats or specialty coverage, per-seat pricing breaks your margin at scale, or you want to own the experience. If you build, don't build transcription from scratch—build on a medical-grade speech-to-text layer and spend your effort on note logic and UX.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Can I use AssemblyAI with PHI under HIPAA, and is a BAA available?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;AssemblyAI enables covered entities and their business associates subject to HIPAA to use the AssemblyAI services to process protected health information (PHI). AssemblyAI is considered a business associate under HIPAA, and we offer a Business Associate Addendum (BAA) that is required under HIPAA to ensure that AssemblyAI appropriately safeguards PHI. The BAA is available to sign without a sales call.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How accurate is AssemblyAI on medical terminology?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Universal-3.5 Pro with Medical Mode ("domain": "medical-v1") reduces the Missed Entity Rate on drugs, conditions, procedures, and clinical terms by roughly 20%. Contextual prompting goes further—feeding a patient's prior-visit note cut missed medical terms by 31% in an internal healthcare test, even when the note came from an earlier visit.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What does it cost to build a scribe on AssemblyAI?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Transcription is pay-as-you-go, billed per second with no minimums. Universal-3.5 Pro async is $0.21/hr, and Medical Mode adds $0.15/hr—so the flagship plus Medical Mode is $0.36/hr combined. That's a usage-based input cost you control, versus the recurring per-seat pricing of a finished scribe.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Which languages does Medical Mode support?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;English, Spanish, German, and French. Medical Mode is available on Universal-3.5 Pro (async) and on Universal-3.5 Pro Realtime for streaming use cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I get started?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Add "domain": "medical-v1" to a Universal-3.5 Pro request and run it against your own clinical audio. Sign the BAA when you're ready to process PHI, and check the &lt;a href="https://www.assemblyai.com/solutions/medical" rel="noopener noreferrer"&gt;medical solution page&lt;/a&gt; for the full picture.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>healthcare</category>
      <category>api</category>
    </item>
    <item>
      <title>Best Voice Agent API for Startups: A First-Build Guide</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:54:57 +0000</pubDate>
      <link>https://dev.to/martschweiger/best-voice-agent-api-for-startups-a-first-build-guide-3eik</link>
      <guid>https://dev.to/martschweiger/best-voice-agent-api-for-startups-a-first-build-guide-3eik</guid>
      <description>&lt;p&gt;You've decided to build a voice product. Maybe it's a support agent, maybe an AI companion, maybe a voice layer on the app you already shipped. Either way, you're staring at a blank editor and a very fast-moving landscape, and the question in front of you isn't "which model is most accurate." It's "how do I get a working demo by Friday?"&lt;/p&gt;

&lt;p&gt;If that's you, this guide is for you. Most people building their first voice product aren't voice agent power users. Roughly 80% are net-new to this. You're demo-driven, you're code-forward, and you don't want to spend your first week learning someone's proprietary conversation-design UI. So let's skip the theory and talk about the decision you're actually making.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What a voice agent API bundles, and why the pipeline is the hard part&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A voice agent is a loop. Someone talks, your system transcribes it (speech-to-text), an LLM decides what to say, and a text-to-speech engine says it back. STT to LLM to TTS. That's the whole pipeline.&lt;/p&gt;

&lt;p&gt;Sounds simple. It isn't.&lt;/p&gt;

&lt;p&gt;The hard part isn't any single box in that diagram. It's the seams between them. When does the user stop talking? (Harder than it sounds - people pause mid-thought all the time.) What happens when they interrupt the agent? How do you keep latency low enough that the conversation doesn't feel like a walkie-talkie? And here's the one that quietly sinks most first builds: if the transcription is wrong, the LLM responds to the wrong thing. Garbage in, confident garbage out.&lt;/p&gt;

&lt;p&gt;That last point matters more than founders expect. Your LLM prompt can be perfect, your TTS voice can sound gorgeous, but if the STT hears "Karen" as "Aaron" or drops the last four digits of an account number, your agent is now confidently wrong. Accuracy at the input is the foundation everything else sits on. If you want the deeper version of this argument, we wrote about &lt;a href="https://www.assemblyai.com/blog/new-2026-insights-report-what-actually-makes-a-good-voice-agent" rel="noopener noreferrer"&gt;what actually makes a good voice agent&lt;/a&gt; - the short version is that the listening layer is where good agents are won or lost.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://www.assemblyai.com/blog/ai-voice-agents" rel="noopener noreferrer"&gt;voice agent API&lt;/a&gt; bundles that whole pipeline - plus turn detection, interruption handling, and voice activity detection - so you don't have to hand-build the seams. That's the category. The question is how you get one.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The three paths for a startup&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;There are basically three ways to ship your first voice product. Each has a real trade-off, and the "right" one depends less on your budget than on how much of the plumbing you want to own.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;What you own&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DIY multi-vendor&lt;/td&gt;
&lt;td&gt;Separate STT, LLM, and TTS providers wired together yourself&lt;/td&gt;
&lt;td&gt;Maximum control, but three vendors, three invoices, and you build turn detection, barge-in, and streaming orchestration&lt;/td&gt;
&lt;td&gt;Teams whose differentiator is the pipeline itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform (Retell, Vapi, etc.)&lt;/td&gt;
&lt;td&gt;A managed environment, often no-code, with built-in telephony&lt;/td&gt;
&lt;td&gt;Fast to launch, but opinionated conversation design, awkward custom integrations, and a real ceiling&lt;/td&gt;
&lt;td&gt;No-code builders and telephony-first products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One API&lt;/td&gt;
&lt;td&gt;Your system prompt, tools, and conversation logic — over a single connection&lt;/td&gt;
&lt;td&gt;More control than a platform, far less overhead than DIY; you write code&lt;/td&gt;
&lt;td&gt;Code-forward startups shipping a first voice product&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Path 1: DIY multi-vendor.&lt;/strong&gt; You pick an STT provider, an LLM provider, and a TTS provider, and you wire them together yourself. Maximum control. But you're now managing three vendors, three invoices, three sets of rate limits, and three places to debug when a call goes sideways. You also own the hard middle - turn detection, barge-in, streaming orchestration. For a first product, that's a lot of undifferentiated engineering before you've validated that anyone wants the thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path 2: A platform (Retell, Vapi, and similar).&lt;/strong&gt; These give you a managed environment, often with a no-code builder and built-in telephony. Great for getting something live fast without much code. The catch is the ceiling. Platforms are opinionated about conversation design, custom integrations can get awkward, and - candidly - a lot of platform-built agents end up sounding the same because they're built on the same rails. Vapi in particular is orchestration middleware, which means your agent is only as good as the weakest third-party link in its chain. We wrote more about &lt;a href="https://www.assemblyai.com/blog/where-voice-agent-stacks-start-showing-their-limits" rel="noopener noreferrer"&gt;where voice agent stacks start showing their limits&lt;/a&gt; if you want the honest version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path 3: One API.&lt;/strong&gt; A single API that handles STT to LLM to TTS through one connection. You still write your own system prompt, define your own tools, and control the conversation - but you don't manage three vendors or build the streaming plumbing. It's the middle path: more control than a platform, far less overhead than DIY.&lt;/p&gt;

&lt;p&gt;For a first voice product, path 3 is usually the sweet spot. You get to spend your time on product logic - the thing that makes your agent yours - instead of on infrastructure nobody will ever thank you for.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What to actually evaluate&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before you pick, here's the short list of things that genuinely move the needle for a first build. Ignore the spec-sheet noise; these five are what you'll feel in production.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;th&gt;AssemblyAI Voice Agent API&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;Wrong transcription means the LLM answers the wrong thing — names, numbers, and addresses break flows&lt;/td&gt;
&lt;td&gt;Built on Universal-3.5 Pro Realtime: 6.99% pooled WER and 15.31% entity error rate on Pipecat's open STT benchmark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;End-to-end round-trip is what makes a conversation feel human&lt;/td&gt;
&lt;td&gt;Around one second end-to-end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer experience&lt;/td&gt;
&lt;td&gt;Time from "read the docs" to "talking to an agent" is the whole game for a first build&lt;/td&gt;
&lt;td&gt;Standard JSON over one WebSocket, no SDK required; readable in ~10 minutes and works natively with Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pricing&lt;/td&gt;
&lt;td&gt;Predictable pricing lets you model unit economics before the product exists&lt;/td&gt;
&lt;td&gt;Flat $4.50/hr billed by the minute — STT, LLM, and TTS included; roughly 4x cheaper than OpenAI's Realtime API (~$18/hr)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock-in&lt;/td&gt;
&lt;td&gt;If you outgrow the tool, how painful is the exit?&lt;/td&gt;
&lt;td&gt;A raw API over a standard WebSocket — you keep full control of conversation design, tools, VAD, and turn timing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Speech accuracy.&lt;/strong&gt; This is the garbage-in problem from earlier. Look at word error rate on &lt;em&gt;real conversational audio&lt;/em&gt;, not clean read-aloud benchmarks, and pay attention to entity accuracy - names, numbers, addresses - because that's what breaks support and booking flows. If you want to go deeper on why the usual metric is shaky, &lt;a href="https://www.assemblyai.com/blog/word-error-rate-is-broken" rel="noopener noreferrer"&gt;word error rate is broken&lt;/a&gt; is a good read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; End-to-end round-trip time is what makes a conversation feel human or robotic. You want something around a second. Don't obsess over shaving milliseconds off one stage; obsess over the full loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developer experience and time-to-ship.&lt;/strong&gt; How long from "read the docs" to "talking to a working agent"? For a first-timer this is the whole ballgame. SDK sprawl, dozens of event types, and framework lock-in all add days. A clean JSON API you can read in ten minutes does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing model.&lt;/strong&gt; Is it flat and predictable, or are you doing token math across three invoices plus per-minute STT plus TTS characters? Unpredictable pricing is its own kind of tax when you're trying to model unit economics for a product that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in.&lt;/strong&gt; If you outgrow the tool, how painful is the exit? No-code platforms can be sticky in ways that hurt later. An API you call over a standard WebSocket is a lot easier to walk away from.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How AssemblyAI's Voice Agent API maps to that list&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Full disclosure: this is our product. I'm going to be specific so you can check the claims, not just take my word.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.assemblyai.com/products/voice-agent-api" rel="noopener noreferrer"&gt;Voice Agent API&lt;/a&gt; is one WebSocket API that runs the entire STT to LLM to TTS pipeline. You connect to a socket, stream audio in, and get audio back. You write the system prompt. That's the mental model.&lt;/p&gt;

&lt;p&gt;On the five criteria:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accuracy.&lt;/strong&gt; It's built on &lt;a href="https://www.assemblyai.com/blog/universal-3-5-pro-realtime" rel="noopener noreferrer"&gt;Universal-3.5 Pro Realtime&lt;/a&gt;, our flagship streaming model, which posts a 6.99% pooled word error rate on Pipecat's open STT benchmark of real agent conversations. For comparison on that same benchmark, entity error rate lands at 15.31% - names, places, phone numbers, the stuff that actually breaks agents. It also takes your agent's question as context, so a mumbled one-word reply or a spelled-out account ID resolves correctly instead of getting mangled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; Around one second end-to-end. Fast enough to feel like a conversation, not a transaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developer experience.&lt;/strong&gt; Standard JSON over a WebSocket. No SDK required, no framework to learn. You can read the &lt;a href="https://www.assemblyai.com/docs/api-reference/overview" rel="noopener noreferrer"&gt;full API reference&lt;/a&gt; in about ten minutes, and most developers have something working the same afternoon. It also works natively with Claude Code - copy the docs, paste them in, and build - which is exactly why we &lt;a href="https://www.assemblyai.com/blog/why-assemblyais-voice-agent-api-is-designed-for-coding-agents" rel="noopener noreferrer"&gt;designed it for coding agents&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's roughly what connecting looks like - one socket, JSON messages, no ceremony:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss://agents.assemblyai.com/v1/ws&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;session.update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are a friendly booking assistant for a dental clinic.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// stream your audio frames in from here&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also update the system prompt, tools, and settings mid-conversation without reconnecting - handy when the flow branches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing.&lt;/strong&gt; Flat $4.50/hr, billed by the minute, with STT, LLM, and TTS all included. One number, one bill, one set of logs. No reconciling three invoices. For context, that's roughly 4x cheaper than OpenAI's Realtime API (around $18/hr) and comes with a cleaner developer experience - their API has 30-plus event types to reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock-in.&lt;/strong&gt; It's a raw API over a standard WebSocket. We call it "invisible infrastructure" for a reason - your customers should feel like you built the thing from scratch, and you keep full control over conversation design, tools, VAD, and turn timing. We're not the agent. We're what you build the agent on.&lt;/p&gt;

&lt;p&gt;It currently supports six languages - English, Spanish, French, German, Italian, and Portuguese - and ships drop-in plugins for LiveKit and Pipecat if you're already using those. When you're ready to go from "hello world" to something real, &lt;a href="https://www.assemblyai.com/blog/how-to-build-with-voice-agent-api" rel="noopener noreferrer"&gt;how to build with the Voice Agent API&lt;/a&gt; walks the full pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When a platform is the better call&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;I said this would be an honest guide, so here's the honest part. A one-API approach is not always right, and sometimes a platform genuinely is the better choice.&lt;/p&gt;

&lt;p&gt;Reach for a platform like Retell or Vapi if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want no-code.&lt;/strong&gt; If you're a non-technical founder or you need business users to build and tweak agents without touching a WebSocket, a visual builder is worth a lot. Our API assumes you can write code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need built-in telephony today.&lt;/strong&gt; If your product is fundamentally about placing and receiving phone calls and you want that handled out of the box right now, platforms have mature telephony baked in. We're shipping telephony as a fast-follow, but "today" matters when you're shipping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call routing and hosted orchestration are the product.&lt;/strong&gt; If the routing, queueing, and call-center-style workflow &lt;em&gt;is&lt;/em&gt; your value, a platform gives you that scaffolding for free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no shame in starting on a platform to validate demand and moving to an API when you hit the ceiling. Just go in knowing where that ceiling is.&lt;/p&gt;

&lt;p&gt;And if the real question is DIY versus one API? Unless stitching STT, LLM, and TTS together is itself your differentiator, one API replaces three vendors, three invoices, and three debugging surfaces. For a first product, that's almost always the right trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Quickstart and a live demo to close&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the thing about voice products: reading about them tells you very little. Talking to one tells you everything. Accuracy, latency, turn-taking, the feel of an interruption landing cleanly - you catch all of it in about thirty seconds of conversation.&lt;/p&gt;

&lt;p&gt;So the move is simple. Grab a key, wire up the socket, point it at a system prompt, and talk to it. If you can talk to it and it feels right, you're most of the way to a demo.&lt;/p&gt;

&lt;p&gt;The best voice agent API for your first product is the one that gets you to that working demo fastest without boxing you in later. For most code-forward startups, that's a single API with strong accuracy at the input, predictable pricing, and an exit that isn't a trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What's the best voice agent API for a startup building its first voice product?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The best one is whatever gets you to a working demo fastest without locking you in. For code-forward startups, that usually means a single API that bundles STT, LLM, and TTS over one connection - so you write product logic instead of managing three vendors. AssemblyAI's Voice Agent API fits that profile: one WebSocket, standard JSON, flat pricing, and around one-second end-to-end latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What's the cheapest voice agent API?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Watch out for pricing that looks cheap per stage but adds up across three invoices (STT per minute, LLM per token, TTS per character). A flat rate is easier to model. AssemblyAI's Voice Agent API is $4.50/hr all-in - STT, LLM, and TTS included - which is roughly 4x cheaper than OpenAI's Realtime API at around $18/hr. Cheapest on paper isn't always cheapest in practice; predictability matters when you're modeling unit economics.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;One API or a platform like Retell or Vapi - which should I choose?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Choose a platform if you want no-code building, need telephony out of the box today, or if hosted call routing is your actual product. Choose a single API if you want full control over conversation design and integrations without hitting a platform ceiling, and you're comfortable writing code. Platforms trade complexity for constraints; an API keeps you in control but expects you to build.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How good does the developer experience really need to be?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For a first voice product, it's the whole game. The gap between an API you can read in ten minutes and one with dozens of event types and required SDKs is measured in days of your life. AssemblyAI's Voice Agent API uses standard JSON over a WebSocket with no SDK required, works natively with Claude Code, and lets you update prompts and tools mid-conversation without reconnecting.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Which languages does the Voice Agent API support?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Six today: English, Spanish, French, German, Italian, and Portuguese. If you need broader coverage for pre-recorded workflows, &lt;a href="https://www.assemblyai.com/blog/universal-3-5-pro-async" rel="noopener noreferrer"&gt;Universal-3.5 Pro&lt;/a&gt; handles native code-switching across 18 languages on the async side.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I get started?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Open the &lt;a href="https://www.assemblyai.com/docs/api-reference/overview" rel="noopener noreferrer"&gt;API reference&lt;/a&gt;, connect to the WebSocket, and stream audio at a system prompt. Most developers have a working agent the same afternoon.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceassistant</category>
      <category>api</category>
      <category>startup</category>
    </item>
    <item>
      <title>Speech-to-Text API Fundamentals: Auth, Polling &amp; JSON</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:54:50 +0000</pubDate>
      <link>https://dev.to/martschweiger/speech-to-text-api-fundamentals-auth-polling-json-11h6</link>
      <guid>https://dev.to/martschweiger/speech-to-text-api-fundamentals-auth-polling-json-11h6</guid>
      <description>&lt;p&gt;Every integration with a speech-to-text API comes down to three questions. How do I authenticate? How do I get results out of an async job? And what does the response actually contain? Get those right and everything else — speaker labels, PII redaction, language detection — is just another request option.&lt;/p&gt;

&lt;p&gt;This is the canonical walkthrough. We'll go end-to-end: your first authenticated request, the status lifecycle of a transcription job, a correct polling loop, and a field-by-field tour of the JSON that comes back. Code in curl, Python, and JavaScript throughout.&lt;/p&gt;

&lt;p&gt;If you're new to the space, &lt;a href="https://www.assemblyai.com/blog/speech-to-text" rel="noopener noreferrer"&gt;What is speech-to-text?&lt;/a&gt; and &lt;a href="https://www.assemblyai.com/blog/what-is-asr" rel="noopener noreferrer"&gt;What is Automatic Speech Recognition (ASR)?&lt;/a&gt; give you the conceptual grounding. Here, we're shipping a first successful call.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Overview&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;AssemblyAI's REST API lives at &lt;a href="https://api.assemblyai.com" rel="noopener noreferrer"&gt;https://api.assemblyai.com&lt;/a&gt;. You submit audio, the API transcribes it asynchronously, and you read the result as JSON. There's no streaming to manage and no SDK required for the core flow — though we ship official SDKs that make it a two-liner.&lt;/p&gt;

&lt;p&gt;The whole loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authenticate&lt;/strong&gt; — send your API key in the authorization header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Submit&lt;/strong&gt; — POST /v2/transcript with an audio_url. You get back an id and a status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poll&lt;/strong&gt; — GET /v2/transcript/{id} every few seconds until status is completed or error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parse&lt;/strong&gt; — read text, confidence, words[], and friends from the JSON.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Prerequisites&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An AssemblyAI API key. &lt;a href="https://www.assemblyai.com/dashboard/signup" rel="noopener noreferrer"&gt;Get your free API key&lt;/a&gt; from the &lt;a href="https://www.assemblyai.com/dashboard/home" rel="noopener noreferrer"&gt;dashboard&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A publicly reachable audio file URL (or one you upload — that's a separate endpoint; here we'll use a URL).&lt;/li&gt;
&lt;li&gt;Optionally, one of the official SDKs:&lt;/li&gt;
&lt;li&gt;Python: pip install assemblyai&lt;/li&gt;
&lt;li&gt;JavaScript/TypeScript: npm install assemblyai&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set your key as an environment variable so it never lands in source control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_api_key_here"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every example below reads the key from ASSEMBLYAI_API_KEY.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Quick start&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The SDKs collapse submit-and-poll into a single call. Here's the entire flow in each.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assemblyai&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;aai&lt;/span&gt;

&lt;span class="n"&gt;aai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Transcriber&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://assembly.ai/wildfires.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;AssemblyAI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assemblyai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AssemblyAI&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;const&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transcripts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://assembly.ai/wildfires.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the destination. Now let's take apart what's happening underneath, because understanding it is what lets you debug, add features, and use webhooks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. Authentication&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;AssemblyAI authenticates with a single header on every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;authorization: &amp;lt;YOUR_API_KEY&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things trip people up here, so let's be explicit. There's &lt;strong&gt;no Bearer prefix&lt;/strong&gt; — the header value is your raw key, nothing else. And the header goes on &lt;em&gt;every&lt;/em&gt; request, both the POST that submits a job and the GETs that poll it.&lt;/p&gt;

&lt;p&gt;Here's a first authenticated request that lists your account's transcripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;curl&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//api.assemblyai.com/v2/transcript \&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization: $ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your key is valid, you get a 200 and a JSON body. If it's missing or wrong, you get a 401. That's your authentication smoke test before you send any audio.&lt;/p&gt;

&lt;p&gt;Never hardcode the key. Read it from the environment, a secrets manager, or your platform's config store.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;2. Submitting a job and the status lifecycle&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Transcription is asynchronous. You hand the API an audio URL, it returns immediately with a job id, and the work happens in the background. You don't get the transcript in the POST response — you get a receipt.&lt;/p&gt;

&lt;p&gt;Submit with POST /v2/transcript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;curl&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//api.assemblyai.com/v2/transcript \&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization: $ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type: application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"audio_url": "https://assembly.ai/wildfires.mp3"}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is a transcript object. The two fields that matter right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;106993b6-...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Persist that id.&lt;/strong&gt; It's how you retrieve the result, and it's how the transcript is addressed for the rest of its life.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The status lifecycle&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A job moves through a small, fixed set of status values. These are the exact strings the API returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;queued&lt;/strong&gt; — waiting to start. In practice you'll typically only see this when you're being rate-limited; otherwise jobs go straight to processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;processing&lt;/strong&gt; — the model is transcribing the audio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;completed&lt;/strong&gt; — success. The text field and all the other output fields are now populated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;error&lt;/strong&gt; — the job failed. The object carries an error string explaining why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;completed and error are the two &lt;strong&gt;terminal&lt;/strong&gt; states. Your job is to poll until you hit one of them. Everything about handling a transcription job is really just handling this state machine correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;3. Polling correctly&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Since the result isn't in the POST response, you fetch it with GET /v2/transcript/{id} and check the status. Loop, sleep, repeat until terminal.&lt;/p&gt;

&lt;p&gt;The convention: &lt;strong&gt;poll every 3 seconds&lt;/strong&gt;, break on completed (read text), break on error (read error). Three seconds is a sane interval — frequent enough to feel responsive, relaxed enough that you're not hammering the endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;curl&lt;/strong&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;TRANSCRIPT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;106993b6-...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;curl&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;silent&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//api.assemblyai.com/v2/transcript/$TRANSCRIPT_ID \&lt;/span&gt;
    &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization: $ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;echo&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;jq&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt;
    &lt;span class="nx"&gt;echo&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;jq&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;
  &lt;span class="nx"&gt;elif&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt;
    &lt;span class="nx"&gt;echo&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;jq&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;
  &lt;span class="nx"&gt;fi&lt;/span&gt;

  &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="nx"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;Python&lt;/strong&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.assemblyai.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Submit the job
&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v2/transcript&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://assembly.ai/wildfires.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;transcript_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Submitted: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Poll until terminal
&lt;/span&gt;&lt;span class="n"&gt;polling_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v2/transcript/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;polling_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Transcription failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;strong&gt;JavaScript&lt;/strong&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.assemblyai.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Submit the job&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v2/transcript`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;audio_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://assembly.ai/wildfires.mp3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Submitted: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Poll until terminal&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pollingUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v2/transcript/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pollingUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Transcription failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three implementations, one pattern: submit, grab the id, poll on a 3-second interval, branch on the terminal states. When you're ready to skip the loop entirely, &lt;a href="https://www.assemblyai.com/docs/api-reference/overview" rel="noopener noreferrer"&gt;webhooks&lt;/a&gt; push the result to you instead — more on that below.&lt;/p&gt;

&lt;p&gt;For the full endpoint contracts, see &lt;a href="https://www.assemblyai.com/docs/pre-recorded-audio/getting-started/transcribe-an-audio-file" rel="noopener noreferrer"&gt;Transcribe an audio file&lt;/a&gt; and &lt;a href="https://www.assemblyai.com/docs/pre-recorded-audio/check-transcript-status" rel="noopener noreferrer"&gt;Check transcript status&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;4. Parsing the JSON response&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once status is completed, the transcript object is fully populated. Here's the canonical shape, trimmed to the essentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;106993b6-...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Smoke from hundreds of wildfires...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;language_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio_duration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;282&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confidence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;words&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Smoke&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confidence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speaker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's walk the fields you'll actually use.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;id&lt;/strong&gt; — the job identifier. Persist it; you can GET the transcript again anytime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;status&lt;/strong&gt; — where the job is in its lifecycle. On a completed job, completed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;text&lt;/strong&gt; — the full transcript as one string. null until the job completes, so always gate on status first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;confidence&lt;/strong&gt; — an overall confidence score as a float from 0.0 to 1.0. In the example, 0.95.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;audio_duration&lt;/strong&gt; — the length of the audio in &lt;strong&gt;seconds&lt;/strong&gt; (an integer). 282 here means the file is 282 seconds long. This is what you meter usage against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;language_code&lt;/strong&gt; — the detected or specified language, e.g. "en". When you use language detection, you'll also get a language_confidence between 0 and 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;words[]&lt;/strong&gt; — every word as its own object: text, start, end, confidence, and speaker. &lt;strong&gt;The start and end timestamps are in milliseconds.&lt;/strong&gt; So start: 100, end: 640 means that word runs from 0.1s to 0.64s. This is what you build captions, search, and clip-to-timestamp features on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Watch the units — this is the single most common parsing bug. audio_duration is in &lt;strong&gt;seconds&lt;/strong&gt;; word timestamps are in &lt;strong&gt;milliseconds&lt;/strong&gt;. Mix them up and your captions drift by three orders of magnitude.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Utterances and speakers&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;When you enable speaker_labels or multichannel, the response gains an &lt;strong&gt;utterances[]&lt;/strong&gt; array. Each utterance groups contiguous speech by a single speaker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utterances&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speaker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Smoke from hundreds of wildfires...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confidence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.94&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;words&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Smoke&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;confidence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speaker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same millisecond timestamps, plus a speaker label and a nested words[]. If you didn't request speaker labels or multichannel, utterances won't be present — so check before you iterate.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;5. Error handling&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Two failure modes, two different responses. Handle them differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP-level errors&lt;/strong&gt; show up as status codes on the request itself. A 400 means the request was malformed — bad JSON, a missing required field, an invalid option. Fix the request; retrying the same payload won't help. A 401 means your authorization header is missing or wrong. Server-side (5xx) errors can be transient — those are safe to resubmit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Job-level errors&lt;/strong&gt; show up as status: "error" on a job you already submitted successfully. The request was accepted (200), but transcription failed. Read the &lt;strong&gt;error&lt;/strong&gt; field for the reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;106993b6-...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Download error, unable to download the audio file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common causes of an error status: an unsupported audio format, missing audio, or a URL the API couldn't reach. These are usually input problems — check that your audio_url is public and points at valid audio before resubmitting.&lt;/p&gt;

&lt;p&gt;A resilient loop distinguishes the two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;Job&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="nx"&gt;failure&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;inspect&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;likely&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;
    &lt;span class="nx"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap the POST in retry-with-backoff for 5xxs, and never blind-retry a 400.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Next steps&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;You've got the fundamentals. The same submit-poll-parse flow carries every feature — you just add request options to the POST body:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;speaker_labels&lt;/strong&gt; — who spoke when, via the utterances[] array.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;language_detection&lt;/strong&gt; and &lt;strong&gt;language_code&lt;/strong&gt; — auto-detect or pin a language.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;punctuate&lt;/strong&gt; and &lt;strong&gt;format_text&lt;/strong&gt; — control formatting of the output text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;multichannel&lt;/strong&gt; — transcribe each audio channel separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redact_pii&lt;/strong&gt; and &lt;strong&gt;filter_profanity&lt;/strong&gt; — clean the transcript for compliance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;webhook_url&lt;/strong&gt; — skip polling entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the natural upgrade. Instead of a GET loop, set webhook_url on submission and AssemblyAI POSTs the finished result to your endpoint when the job hits a terminal state. It's the right pattern for production and for high volume. The &lt;a href="https://www.assemblyai.com/docs/api-reference/overview" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; covers the webhook payload and every option in detail.&lt;/p&gt;

&lt;p&gt;From here, explore the full &lt;a href="https://www.assemblyai.com/products/speech-to-text" rel="noopener noreferrer"&gt;speech-to-text product&lt;/a&gt; and the deep dive on &lt;a href="https://www.assemblyai.com/blog/how-accurate-speech-to-text" rel="noopener noreferrer"&gt;how accurate speech-to-text is in 2026&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;View the full API reference&lt;/strong&gt; → &lt;a href="https://www.assemblyai.com/docs/api-reference/overview" rel="noopener noreferrer"&gt;assemblyai.com/docs/api-reference/overview&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I authenticate with the AssemblyAI API?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Send your API key in the authorization header on every request — authorization: . There's no Bearer prefix; the value is your raw key. Store it in an environment variable like ASSEMBLYAI_API_KEY and get your key from the &lt;a href="https://www.assemblyai.com/dashboard/home" rel="noopener noreferrer"&gt;dashboard&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Do I poll for results or get them back immediately?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Transcription is asynchronous, so you poll. POST /v2/transcript returns an id and a status immediately, but not the transcript. Fetch the result with GET /v2/transcript/{id} every 3 seconds until status is completed or error. To avoid polling entirely, set webhook_url on submission and AssemblyAI pushes the result to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What do the transcript status values mean?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;There are four: queued (waiting — usually only when rate-limited), processing (actively transcribing), completed (success, read text), and error (failed, read the error string). completed and error are the terminal states you poll toward.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I read confidence scores and word timestamps?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;confidence is an overall float from 0.0 to 1.0. Each object in words[] has its own confidence plus start and end timestamps in &lt;strong&gt;milliseconds&lt;/strong&gt;. Note that audio_duration is in &lt;strong&gt;seconds&lt;/strong&gt; while word timestamps are in milliseconds — don't mix the units.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Why is my transcript status error?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The job was accepted but transcription failed. Read the error field for the reason. Common causes are an unsupported audio format, missing audio, or a URL the API couldn't reach. That's different from a 400, which means the request itself was malformed and shouldn't be blindly retried; server (5xx) errors, by contrast, can be resubmitted.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What can I do beyond a basic transcript?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&amp;nbsp;Add request options to the POST body — speaker_labels, language_detection, punctuate, format_text, multichannel, redact_pii, filter_profanity, and webhook_url. The submit-poll-parse flow stays identical. See &lt;a href="https://www.assemblyai.com/docs/api-reference/transcripts/submit" rel="noopener noreferrer"&gt;Submit a transcript&lt;/a&gt; and &lt;a href="https://www.assemblyai.com/docs/api-reference/transcripts/get" rel="noopener noreferrer"&gt;Get a transcript&lt;/a&gt; for full field references.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>speechtotext</category>
      <category>api</category>
    </item>
    <item>
      <title>Fast ASR for Voice Agents: Bring Your Own Turn Detection</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:48:04 +0000</pubDate>
      <link>https://dev.to/martschweiger/fast-asr-for-voice-agents-bring-your-own-turn-detection-44j6</link>
      <guid>https://dev.to/martschweiger/fast-asr-for-voice-agents-bring-your-own-turn-detection-44j6</guid>
      <description>&lt;p&gt;There's a school of voice-agent development that treats turn detection as something you buy, not something you build. Pick a streaming STT provider, let its end-of-turn logic decide when the user is done, and move on. For a lot of teams that's the right move — and if you're weighing the options, our breakdown of &lt;a href="https://www.assemblyai.com/blog/voice-agent-turn-detection" rel="noopener noreferrer"&gt;turn detection vs forced endpoints&lt;/a&gt; is the place to start.&lt;/p&gt;

&lt;p&gt;But some teams have already solved turn detection. They've tuned their own voice-activity detection over thousands of calls, they know their audio, and they trust their endpointing more than any default. For those teams, a streaming model's built-in turn logic isn't a feature — it's something to work around. What they want is narrower and faster: hand over a finished chunk of speech, get accurate text back, get out of the way.&lt;/p&gt;

&lt;p&gt;That's the case for bringing your own turn detection and pairing it with fast ASR over HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Turn detection is an architectural decision, not a default&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the framing that matters. In a streaming setup, the STT model is a participant in the conversation — it's watching the audio and deciding, continuously, whether the user has finished. That's genuinely useful when you want the provider to own that judgment.&lt;/p&gt;

&lt;p&gt;But it means the model is inserting its own decision between "user stopped talking" and "you get the transcript." If you already &lt;em&gt;know&lt;/em&gt; the turn is over — because your VAD just fired — you don't want the model deliberating. You want it transcribing. Every millisecond the STT layer spends re-deciding a question you've already answered is latency you're adding for no benefit.&lt;/p&gt;

&lt;p&gt;So the decision isn't "which provider has the best turn detection." For these teams it's "who owns the turn boundary?" If the answer is you, then the ideal STT layer is one that does exactly one thing: turn a finished clip into accurate text, fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Built-in vs. bring-your-own&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Built-in (streaming).&lt;/strong&gt; The model reads tonality, pacing, and rhythm to detect end-of-turn — with Universal-3.5 Pro Realtime, around 300ms — then commits the transcript. Great when you want conversation-aware endpointing handled for you, and when you value partial transcripts as the user speaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bring-your-own (sync HTTP).&lt;/strong&gt; Your VAD decides the turn is over and hands the completed clip to the &lt;a href="https://www.assemblyai.com/products/sync-speech-to-text" rel="noopener noreferrer"&gt;Sync API&lt;/a&gt;, which returns the transcript in a single response at roughly 134ms p50, for clips up to 2 minutes (40 MB max), at $0.45/hr. No end-of-turn step on the model's side, because you already made that call. You trade mid-utterance streaming for a tighter, you-controlled loop.&lt;/p&gt;

&lt;p&gt;The distinction isn't fast versus slow — both are fast. It's about &lt;em&gt;who decides when the turn ends&lt;/em&gt;, and whether you want that decision inside or outside the STT layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The sub-500ms budget, with you owning the turn&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Teams building responsive agents often target something under 500ms from end-of-speech to the start of the agent's reasoning. Here's roughly where the time goes when you own turn detection and use sync ASR:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your VAD end-of-turn decision:&lt;/strong&gt; however tight you've tuned it — often the biggest lever you control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync transcription:&lt;/strong&gt; ~134ms p50 for the clip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network overhead:&lt;/strong&gt; tens of milliseconds, minimized by reusing a warm HTTP connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because you're not waiting on the model to detect end-of-turn, the transcription step is close to pure processing time. That's the appeal: when you've already done the hard work of knowing when the user stopped, sync ASR adds very little on top before the LLM can start. The budget math gets a lot friendlier when the STT layer isn't also trying to be the turn detector.&lt;/p&gt;

&lt;p&gt;One practical note that pays off here: keep the HTTP connection warm between turns. Reuse a single session so DNS, TCP, and TLS setup don't land on the critical path of every utterance. It's a small change that protects the latency you worked to win.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;You don't trade accuracy for control&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The reason this pattern works for serious teams is that owning turn detection doesn't cost you transcript quality. Sync returns the same accuracy as async — the same handling of names, numbers, and domain terms that you'd get from the batch path. In an agent, that's the number that matters most: if the transcript is wrong, the LLM answers the wrong question, and no amount of speed rescues the interaction.&lt;/p&gt;

&lt;p&gt;So the tradeoff is clean. You give up the model's built-in end-of-turn detection and mid-utterance streaming. You keep full control of the conversation loop and full accuracy on the words. For a team that already trusts its VAD, that's a trade worth making.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When to hand turn detection back to us&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Be honest about which camp you're in. Bring-your-own turn detection is the right pattern when you've genuinely tuned your VAD and you want the STT layer to stay out of the conversation logic. It is the wrong pattern if you're bringing your own turn detection mostly to avoid learning someone else's — in that case you're taking on the hardest part of voice UX to save yourself a config page.&lt;/p&gt;

&lt;p&gt;If you'd rather not own endpointing, interruption handling, and the rest of the orchestration, hand it back. The &lt;a href="https://www.assemblyai.com/products/voice-agent-api" rel="noopener noreferrer"&gt;Voice Agent API&lt;/a&gt; builds conversation-aware turn detection and barge-in directly into Universal-3.5 Pro Realtime, over one WebSocket, at a flat $4.50/hr. Bring your own turn detection when control is the goal; use the Voice Agent API when shipping fast is the goal. Both run on the same speech accuracy underneath — the difference is only how much of the loop you want to hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What does "bring your own turn detection" mean for a voice agent?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Bringing your own turn detection means your application's own voice-activity detection decides when the user has finished speaking, rather than the speech-to-text model. Once your VAD fires, you hand the completed audio clip to a synchronous ASR endpoint and get the transcript back in one response. The STT layer does exactly one thing — turn a finished clip into accurate text — instead of also deliberating about when the turn ended.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;When should I use my own VAD instead of a streaming model's built-in turn detection?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Use your own VAD when you've genuinely tuned it over real calls, you trust your endpointing more than any default, and you want the STT layer to stay out of the conversation logic. In that case a streaming model's built-in end-of-turn detection is redundant — it inserts a decision between "user stopped talking" and "you get the transcript" that you've already made. Stick with built-in turn detection if you value mid-utterance partial transcripts or you'd simply rather not own endpointing.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I build a low-latency voice agent under 500ms with sync ASR?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;When you own turn detection, the sub-500ms budget breaks down into three pieces: your VAD's end-of-turn decision (the biggest lever you control), sync transcription at roughly 134ms p50, and network overhead of tens of milliseconds. Because you're not waiting on the model to detect end-of-turn, the transcription step is close to pure processing time. Keep the HTTP connection warm between turns — reuse one session so DNS, TCP, and TLS setup stay off the critical path.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Is synchronous HTTP ASR as accurate as streaming or batch transcription?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes. The Sync API returns the same accuracy as async transcription — the same handling of names, numbers, and domain terms you'd get from the batch path — because it runs on the same Universal-3.5 Pro model. Owning turn detection costs you the model's built-in endpointing and mid-utterance streaming, not transcript quality. In an agent that's the trade that matters, since a wrong transcript means the LLM answers the wrong question.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Should I bring my own turn detection or use AssemblyAI's Voice Agent API?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Bring your own turn detection when control is the goal — you've tuned your VAD and want to own the conversation loop, pairing it with fast sync ASR. Use the &lt;a href="https://www.assemblyai.com/products/voice-agent-api" rel="noopener noreferrer"&gt;Voice Agent API&lt;/a&gt; when shipping fast is the goal: it builds conversation-aware turn detection and barge-in directly into Universal-3.5 Pro Realtime over one WebSocket at a flat $4.50/hr. Both run on the same speech accuracy underneath — the difference is only how much of the loop you want to hold.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What are the Sync API's limits and pricing?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The Sync API accepts clips from 80 ms up to 2 minutes (40 MB max) across 18 languages, runs on the flagship Universal-3.5 Pro model, and returns a finished transcript in a single HTTP response at roughly 134 ms p50. It's priced at $0.45/hr of audio, with keyterms prompting and conversation context included and no rate limits. For audio longer than two minutes, use the pre-recorded (async) API instead.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceassistant</category>
      <category>speechtotext</category>
      <category>python</category>
    </item>
    <item>
      <title>Sync HTTP for Voice Agents: Skip the WebSocket (2026)</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:47:39 +0000</pubDate>
      <link>https://dev.to/martschweiger/sync-http-for-voice-agents-skip-the-websocket-2026-3nen</link>
      <guid>https://dev.to/martschweiger/sync-http-for-voice-agents-skip-the-websocket-2026-3nen</guid>
      <description>&lt;p&gt;Most voice agent tutorials — including &lt;a href="https://www.assemblyai.com/blog/voice-agent-architecture" rel="noopener noreferrer"&gt;our own guide to building a chained STT-LLM-TTS architecture&lt;/a&gt; — assume WebSockets. You open a streaming connection to your speech-to-text provider, manage it for the life of the call, and handle a live event model. For a lot of agents, that's the right design.&lt;/p&gt;

&lt;p&gt;But it's not the only one. Some of the most sophisticated voice-agent teams deliberately don't stream their transcription. They send audio to STT as plain HTTP requests — one request per turn — and they do it on purpose. This post is about that pattern: when a sync HTTP call beats a WebSocket, and how to build an agent around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why anyone would skip streaming&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Streaming feels like the obvious choice for live conversation, so the instinct is to treat "not streaming" as cutting a corner. It isn't. It's a different set of tradeoffs, and for the right team the HTTP path is the simpler, more scalable one.&lt;/p&gt;

&lt;p&gt;Several large voice-agent teams asked us for exactly this — a sync HTTP API for real-time transcripts — for a specific reason: they already run their LLM and text-to-speech as HTTP services, and they want their whole stack to look the same. Stateless request/response services are easier to scale, easier to load-balance, and easier to reason about than long-lived stateful connections. They're willing to accept slightly higher per-turn latency to get an architecture where every component is a plain HTTP call.&lt;/p&gt;

&lt;p&gt;The other reason is turn detection. If you already run your own voice-activity detection — deciding when the user has started and stopped talking — a streaming model's built-in end-of-turn logic is redundant. You don't want the STT layer guessing when the turn ends; &lt;em&gt;you&lt;/em&gt; already know, because your VAD just told you. At that point a persistent socket is overhead. You have a discrete chunk of audio and you want text back. That's a request, not a stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How the sync HTTP pattern works&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The shape is simple, and if you've built a cascaded agent it'll look familiar — just with the STT stage swapped from a socket to a call:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your VAD detects the user has started speaking and buffers the audio.&lt;/li&gt;
&lt;li&gt;Your VAD detects end-of-turn and closes the buffer — a clip, usually a few seconds.&lt;/li&gt;
&lt;li&gt;You POST that clip to the &lt;a href="https://www.assemblyai.com/products/sync-speech-to-text" rel="noopener noreferrer"&gt;Sync API&lt;/a&gt; and get the transcript back in the same response (~134ms p50, clips up to 2 minutes).&lt;/li&gt;
&lt;li&gt;You hand the text to your LLM (an HTTP call), then the LLM's reply to your TTS (another HTTP call).&lt;/li&gt;
&lt;li&gt;Repeat for the next turn.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every stage is a stateless HTTP request. There's no connection to keep alive between turns, no streaming state to manage, no reconnection logic. The whole agent is a loop of request/response calls, which is exactly the property those teams were after. See the &lt;a href="https://www.assemblyai.com/docs/sync-stt/getting-started/transcribe-a-short-audio-file" rel="noopener noreferrer"&gt;Sync API docs&lt;/a&gt; for the full request and response format.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;One&lt;/span&gt; &lt;span class="nx"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;once&lt;/span&gt; &lt;span class="nx"&gt;your&lt;/span&gt; &lt;span class="nx"&gt;VAD&lt;/span&gt; &lt;span class="nx"&gt;has&lt;/span&gt; &lt;span class="nx"&gt;handed&lt;/span&gt; &lt;span class="nx"&gt;you&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;completed&lt;/span&gt; &lt;span class="nx"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;Send&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;multipart&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="nx"&gt;an&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="kd"&gt;set&lt;/span&gt; &lt;span class="nx"&gt;via&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;requests&lt;/span&gt;

&lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;reuse&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="nx"&gt;across&lt;/span&gt; &lt;span class="nx"&gt;turns&lt;/span&gt;
&lt;span class="nx"&gt;SYNC_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://sync.assemblyai.com/transcribe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transcribe_turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wav_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;SYNC_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-AAI-Model&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;u3-sync-pro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clip.wav&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wav_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio/wav&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
        &lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nx"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wav_bytes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;transcribe_turn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wav_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;STT&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;HTTP&lt;/span&gt;
    &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;LLM&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;HTTP&lt;/span&gt;
    &lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;synthesize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="nx"&gt;TTS&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;HTTP&lt;/span&gt;
    &lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reusing a single requests.Session across turns keeps the TCP/TLS connection warm, so you're not paying handshake cost on every utterance — a small detail that matters when you're chaining three HTTP calls per turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The tradeoffs, honestly&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This pattern isn't free. Here's what you're trading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You give up mid-utterance transcription.&lt;/strong&gt; A streaming model can start feeding partial text to your LLM before the user finishes talking. The sync pattern waits for the complete clip, so the LLM starts a beat later. For many agents that's imperceptible; for latency-obsessed ones it's the reason to stream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You own turn detection — which is the point, but also the work.&lt;/strong&gt; The whole pattern assumes your VAD is good. If it isn't, you'll clip users off or leave dead air, and no fast transcription will save you. If you'd rather not own that logic, a streaming model with built-in end-of-turn detection is the better fit — and if you specifically want to keep owning it while getting fast ASR back, that's its own design worth reading up on separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You accept slightly higher per-turn latency for architectural simplicity.&lt;/strong&gt; That's the deal the teams who asked for this made knowingly. If your product can absorb it, you get a stack that's dramatically easier to scale and debug.&lt;/p&gt;

&lt;p&gt;What you keep is accuracy. The Sync API returns the same transcript quality as async, so choosing the HTTP path costs you nothing on the words themselves — which, in an agent, is the thing you least want to compromise. Get the input wrong and the LLM answers the wrong question.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When to use it — and when to reach for the Voice Agent API instead&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Use the sync HTTP pattern when you already run your own orchestration and VAD, you want a uniform stateless stack, and your latency budget has room for per-turn request/response. It's a builder's pattern for teams who want maximum control over how the pieces fit together.&lt;/p&gt;

&lt;p&gt;If you &lt;em&gt;don't&lt;/em&gt; want to wire STT, LLM, TTS, turn detection, and interruption handling together yourself, that's a different job — and it's what the &lt;a href="https://www.assemblyai.com/products/voice-agent-api" rel="noopener noreferrer"&gt;Voice Agent API&lt;/a&gt; is for. One WebSocket connection handles the full pipeline at a flat $4.50/hr, built on Universal-3.5 Pro Realtime, with turn detection and barge-in included. The sync HTTP pattern is for teams who want to own the orchestration; the Voice Agent API is for teams who want it handled. Both are valid — the right call depends on how much of the stack you want in your hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is the sync HTTP pattern for voice agents?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The sync HTTP pattern is a voice-agent design where each conversational turn is transcribed with a single stateless HTTP request instead of a persistent WebSocket stream. Your own voice-activity detection decides when a turn ends, you POST that completed audio clip to a synchronous speech-to-text endpoint, and the transcript comes back in the same response. Every stage of the agent — STT, LLM, TTS — becomes a plain request/response call, so there's no streaming connection to keep alive between turns.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;When should a voice agent use a sync HTTP API instead of a streaming WebSocket?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Use the sync HTTP pattern when you already run your own turn detection and want your whole stack to be uniform, stateless HTTP services that are easy to scale, load-balance, and debug. It's the better fit when your latency budget can absorb waiting for a complete clip per turn in exchange for architectural simplicity. Choose streaming instead when you need mid-utterance partial transcripts or you'd rather the STT model handle end-of-turn detection for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does the sync HTTP pattern add latency compared to streaming?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;It adds a small amount. A streaming model can feed partial text to your LLM before the user stops talking, while the sync pattern waits for the complete clip and then returns a transcript in roughly 134 ms at p50. For many agents that difference is imperceptible; for latency-obsessed applications, streaming's mid-utterance head start is the reason to stream. The tradeoff buys you a dramatically simpler, stateless architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Do I need my own voice-activity detection (VAD) to use the sync pattern?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Yes — the pattern assumes you own turn detection. Your VAD decides when the user has started and stopped talking, then hands a completed clip to the Sync API for transcription. That's the point of the pattern for teams who already run their own VAD, but it's also the work: if your turn detection is weak, you'll clip users off or leave dead air, and fast transcription won't fix that. If you'd rather not own that logic, a streaming model or the Voice Agent API with built-in turn detection is the better choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Should I use the sync HTTP pattern or AssemblyAI's Voice Agent API?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Use the sync HTTP pattern if you want to own the orchestration — wiring STT, LLM, TTS, turn detection, and interruption handling together yourself for maximum control. Use the &lt;a href="https://www.assemblyai.com/products/voice-agent-api" rel="noopener noreferrer"&gt;Voice Agent API&lt;/a&gt; if you want that pipeline handled for you: one WebSocket connection at a flat $4.50/hr, built on Universal-3.5 Pro Realtime, with turn detection, barge-in, and tool calling included. Both are valid — the right call depends on how much of the stack you want in your hands.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I get started with the Sync API, and what are its limits?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Create a free AssemblyAI account, then POST a completed audio clip with your API key and read the transcript straight off the response — no WebSocket, no polling. The Sync API accepts clips from 80 ms up to 2 minutes (40 MB max) across 18 languages, runs on Universal-3.5 Pro, and is priced at $0.45/hr of audio with keyterms prompting and conversation context included. See the &lt;a href="https://www.assemblyai.com/docs/sync-stt/getting-started/transcribe-a-short-audio-file" rel="noopener noreferrer"&gt;Sync API docs&lt;/a&gt; for the full request and response format.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceassistant</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Time to First Token: The Voice Agent Latency Metric</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:47:32 +0000</pubDate>
      <link>https://dev.to/martschweiger/time-to-first-token-the-voice-agent-latency-metric-25nk</link>
      <guid>https://dev.to/martschweiger/time-to-first-token-the-voice-agent-latency-metric-25nk</guid>
      <description>&lt;p&gt;Ask an STT vendor how fast they are and you'll get a word error rate and an average latency number. Both are real. Neither tells you whether a conversation with your voice agent will feel alive or feel broken.&lt;/p&gt;

&lt;p&gt;Word error rate tells you how &lt;em&gt;accurate&lt;/em&gt; the transcript is. Average latency tells you how fast the pipeline runs &lt;em&gt;on average&lt;/em&gt;. But the thing a user actually experiences — the silence between "…and that's my question" and the agent's first sound back — isn't captured well by either. That gap is where voice agents win or lose, and the metric that governs it is &lt;strong&gt;time to first token&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you're evaluating speech-to-text for a voice agent, this is the number to build your test around. Here's what it is, why the usual metrics miss it, and how to measure it on your own audio.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What time to first token actually means&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Time to first token (TTFT) is the elapsed time from the moment the user stops speaking to the moment your system produces the first usable piece of output it can act on.&lt;/p&gt;

&lt;p&gt;The phrase comes from the LLM world, where it means the delay before a model streams its first token. In a voice pipeline it's more useful to think of it end to end: the clock starts when the user finishes their turn, and stops when the first token the &lt;em&gt;agent&lt;/em&gt; can use appears — the first chunk of finalized transcript that the LLM can start reasoning on.&lt;/p&gt;

&lt;p&gt;That framing matters because a voice agent is a chain — speech-to-text, then an LLM, then text-to-speech — and each link has its own "first token" moment. But they're not independent. The STT stage gates everything downstream: the LLM can't produce a first token until it has text to read, and the TTS can't produce a first sound until the LLM has generated something to say. So the responsiveness the user feels is anchored on how quickly transcription commits to a usable result after the turn ends.&lt;/p&gt;

&lt;p&gt;This is why TTFT is the metric that decides voice agents. It's the first domino.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why WER and average latency don't tell you this&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Two numbers dominate STT marketing, and both are genuinely useful for the questions they answer. They just don't answer this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Word error rate measures the wrong axis.&lt;/strong&gt; WER tells you how many words the model got wrong across a whole transcript. It says nothing about &lt;em&gt;when&lt;/em&gt; those words arrived. A model can post a beautiful WER and still feel sluggish in conversation because it waits for extra audio context before committing to a result. Accuracy and responsiveness are different axes, and a voice agent needs both. For the accuracy side of this story — and why WER itself is a slippery benchmark — see &lt;a href="https://www.assemblyai.com/blog/new-word-error-rate-wer-benchmark" rel="noopener noreferrer"&gt;why your WER benchmark might be lying to you&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Average latency hides the moments that hurt.&lt;/strong&gt; An average folds together the fast responses and the slow ones. But conversation is unforgiving about the slow ones — a single two-second stall in an otherwise snappy exchange is the thing the user remembers. If a vendor quotes you one latency number, ask which percentile it is. A p50 (median) and a p90 (the slow tail) can be worlds apart, and your users live in the tail as much as the middle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;End-of-turn detection is the hidden clock.&lt;/strong&gt; Here's the subtle one. Before a streaming model can hand you a final transcript, it has to decide the user is &lt;em&gt;done talking&lt;/em&gt;. Wait too long and you've added dead air before the LLM ever sees a word — latency the user blames on your agent. Decide too early and you cut them off. So turn detection isn't separate from latency; it's part of TTFT. A model that reads tonality, pacing, and rhythm to land an end-of-turn decision quickly is buying you responsiveness that no average-latency number will show. We go deep on this in our breakdown of &lt;a href="https://www.assemblyai.com/blog/voice-agent-turn-detection" rel="noopener noreferrer"&gt;turn detection vs forced endpoints&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The numbers, by transcription path&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;TTFT isn't a single value — it depends on how you're getting text back. The three transcription paths have very different profiles, and picking the right one for the interaction is half the battle.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming (real-time).&lt;/strong&gt; The model transcribes as audio arrives and commits final text shortly after the turn ends. With Universal-3.5 Pro Realtime, end-of-turn detection lands around 300ms by reading tonality, pacing, and rhythm rather than waiting on a fixed silence timer — so the first usable token reaches your LLM fast. This is the path built for live, back-and-forth conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync (short-clip HTTP).&lt;/strong&gt; For a short utterance sent as a single HTTP request, the Sync API returns the full transcript in one response at roughly 134ms p50. There's no turn-detection step to wait on because &lt;em&gt;you&lt;/em&gt; decide when the clip ends. That makes TTFT and time-to-complete-transcript essentially the same moment — which is exactly why it's a strong fit for structured, turn-by-turn agents that own their own voice-activity detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async (batch).&lt;/strong&gt; Submit a recording, get it back in seconds to minutes. TTFT as a concept barely applies, because async isn't trying to be interactive — it optimizes for accuracy and full-file speech understanding, not first-token speed. If you're reaching for async in a live agent, you're using the wrong tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the full tradeoff across these three paths — pricing, accuracy, and when to use each — see our guide to &lt;a href="https://www.assemblyai.com/blog/real-time-vs-batch-transcription" rel="noopener noreferrer"&gt;real-time vs batch transcription&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Zoom out to the whole agent and the STT number rolls up into an end-to-end budget. A well-tuned cascaded pipeline — STT, LLM, TTS — targets roughly one second from end of user speech to first agent audio, and AssemblyAI's &lt;a href="https://www.assemblyai.com/products/voice-agent-api" rel="noopener noreferrer"&gt;Voice Agent API&lt;/a&gt; is built to that ~1s end-to-end target. Every one of those milliseconds starts with how fast transcription hands off the first token.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How to measure it yourself&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Don't take anyone's quoted number — including ours — at face value. TTFT is easy to measure on your own audio, and your audio is the only benchmark that matters. A simple protocol:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use realistic clips.&lt;/strong&gt; Record utterances that sound like your actual users — the accents, the background noise, the domain terms, the short "yes"/"next" answers as well as the long ones. Clean read-aloud audio will flatter every vendor equally and tell you nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamp the two events.&lt;/strong&gt; Mark the sample where the user stops speaking, and the timestamp when your system receives the first finalized token it can act on. The difference is your TTFT for that utterance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report percentiles, not an average.&lt;/strong&gt; Collect a few hundred utterances and look at p50 &lt;em&gt;and&lt;/em&gt; p90. The median tells you the typical experience; the tail tells you how often the agent feels stuck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate the stages.&lt;/strong&gt; Log first-token time for STT, LLM, and TTS independently. When something feels slow, you want to know which link to fix — and more often than not, the fix isn't where teams assume it is.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do this once and the conversation with any vendor changes. You stop comparing marketing numbers and start comparing the only thing your users will ever feel.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why this is the metric that matters&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Accuracy is table stakes now — streaming models have closed most of the gap that used to make real-time feel like a compromise. What separates a voice agent that people happily talk to from one they hang up on is responsiveness, and responsiveness is governed by how fast the first token arrives after someone stops speaking.&lt;/p&gt;

&lt;p&gt;So when you're evaluating speech-to-text for anything conversational, put TTFT at the center of the test. Ask for percentiles. Measure end-of-turn behavior, not just processing speed. And run it on audio that sounds like your users, because the felt experience of a conversation is decided in a window too small for an average to see.&lt;/p&gt;

&lt;p&gt;Get the first token fast and everything downstream has room to work. Get it wrong and no amount of accuracy will make the agent feel alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is time to first token (TTFT) in a voice agent?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Time to first token is the elapsed time from the moment a user stops speaking to the moment the system produces the first usable piece of output it can act on. In a voice pipeline, that first token is the initial chunk of finalized transcript the LLM can start reasoning on — the domino that gates the LLM and text-to-speech stages downstream. It's the metric that most directly governs whether a conversation feels responsive.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Why isn't word error rate or average latency enough to evaluate voice-agent speed?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Word error rate measures how accurate a transcript is, not when the words arrived, so a model can post a great WER and still feel sluggish because it waits for extra context before committing. Average latency folds fast and slow responses together and hides the tail — and a single two-second stall is what a user remembers. To evaluate voice-agent responsiveness you need time to first token, reported as percentiles (p50 and p90), plus end-of-turn behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is a good time to first token, and what latency should a speech-to-text API hit for voice agents?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For live conversation, you want the first usable token within a few hundred milliseconds of the user finishing their turn. With streaming Universal-3.5 Pro Realtime, end-of-turn detection lands around 300 ms; a short clip sent to the Sync API returns a full transcript at roughly 134 ms p50. At the whole-agent level, a well-tuned STT-LLM-TTS pipeline targets about one second end-to-end from end of speech to first agent audio.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How does time to first token differ across streaming, sync, and async transcription?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Streaming transcribes as audio arrives and commits final text shortly after the turn ends (~300 ms end-of-turn with Universal-3.5 Pro Realtime), making it the path built for live conversation. Sync returns a full short-clip transcript in a single HTTP response at ~134 ms p50, with no turn-detection wait because you decide when the clip ends. Async optimizes for accuracy and full-file speech understanding over seconds to minutes, so TTFT barely applies — it's the wrong tool for a live agent.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I measure time to first token on my own audio?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Record clips that sound like your real users — accents, background noise, domain terms, and both short and long answers — then timestamp two events per utterance: when the user stops speaking and when your system receives the first finalized token. The difference is your TTFT. Collect a few hundred utterances, report p50 and p90 rather than an average, and log first-token time for STT, LLM, and TTS separately so you know which stage to fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How is turn detection related to time to first token?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Turn detection is part of TTFT, not separate from it. Before a streaming model can hand you a final transcript, it has to decide the user is done talking — wait too long and you add dead air the user blames on your agent; decide too early and you cut them off. A model that reads tonality, pacing, and rhythm to land an end-of-turn decision quickly (rather than waiting on a fixed silence timer) directly improves responsiveness that no average-latency number will reveal.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceassistant</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Build a Dictation App With the Sync API (Tutorial)</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:47:06 +0000</pubDate>
      <link>https://dev.to/martschweiger/build-a-dictation-app-with-the-sync-api-tutorial-45ei</link>
      <guid>https://dev.to/martschweiger/build-a-dictation-app-with-the-sync-api-tutorial-45ei</guid>
      <description>&lt;p&gt;Dictation feels like it should be a solved problem. You press a key, you talk, the words show up. But if you've ever tried to build it, you know the first version is almost always disappointing — you speak, then you wait, and the text lands a beat too late to feel like your own voice.&lt;/p&gt;

&lt;p&gt;That lag is almost never the model. It's the plumbing around it.&lt;/p&gt;

&lt;p&gt;In this tutorial we'll build a small dictation app — we're calling it Blurt — on top of AssemblyAI's Sync API. You hold a hotkey, speak a sentence or two, release, and the transcript drops straight into whatever text field you're in. Then we'll do the part most tutorials skip: cut the perceived latency roughly in half with a connection pre-warm trick, so "test, test, test" comes back in around 300ms instead of feeling like a round trip to the moon.&lt;/p&gt;

&lt;p&gt;By the end you'll have a working app and a clear mental model for why the Sync API is the right tool for short, interactive speech — and where it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why dictation doesn't want streaming&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for streaming. Dictation is real-time-ish, streaming is real-time, case closed.&lt;/p&gt;

&lt;p&gt;But look at what dictation actually is: short bursts of speech, one utterance at a time, with a hard start and a hard stop that the &lt;em&gt;user&lt;/em&gt; controls by pressing and releasing a key. You don't need partial transcripts scrolling across the screen. You don't need the app guessing when you've finished talking — you told it, when you let go of the key. And you don't want to manage a persistent WebSocket, reconnection logic, and streaming state for what is, functionally, a series of two-second clips.&lt;/p&gt;

&lt;p&gt;Streaming solves a problem dictation doesn't have (deciding when the speaker is done) and adds infrastructure dictation doesn't want (a stateful connection). What you actually want is dead simple: send a short clip over HTTP, get the text back in one response, right now.&lt;/p&gt;

&lt;p&gt;That's the &lt;a href="https://www.assemblyai.com/products/sync-speech-to-text" rel="noopener noreferrer"&gt;Sync API&lt;/a&gt;. One HTTP request, one clip up to 2 minutes, one response with the transcript — no polling, no webhooks, ~134ms p50 latency, and the same accuracy you already get from async transcription. It sits between async (submit a job, poll or wait for a webhook) and streaming (open a socket, manage a live session): the speed of real-time with the simplicity of a single request.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What we're building&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Blurt is a desktop dictation utility. The flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hold a global hotkey to start recording.&lt;/li&gt;
&lt;li&gt;Speak.&lt;/li&gt;
&lt;li&gt;Release the key to stop.&lt;/li&gt;
&lt;li&gt;The clip goes to the Sync API over HTTP.&lt;/li&gt;
&lt;li&gt;The transcript is typed into the active application.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We'll build it in Python so the whole thing stays readable in one file. The same pattern ports cleanly to a browser extension, an Electron app, or a native menu-bar tool — the interesting part is the request pattern, not the UI shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt; Python 3.9+, an AssemblyAI API key (&lt;a href="https://www.assemblyai.com/dashboard/signup" rel="noopener noreferrer"&gt;grab one free&lt;/a&gt;), and a microphone. We'll use sounddevice to capture audio, pynput to listen for the hotkey and type the result, and requests for the API call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;sounddevice pynput requests numpy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Step 1: Capture audio while a key is held&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;First, record audio for exactly as long as the hotkey is down. We buffer raw PCM frames and stop the moment the key comes up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sounddevice&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;

&lt;span class="n"&gt;SAMPLE_RATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16000&lt;/span&gt;  &lt;span class="c1"&gt;# 16 kHz mono is plenty for speech
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Recorder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_frames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InputStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;samplerate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SAMPLE_RATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int16&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;callback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concatenate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_frames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_frames&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([],&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;int16&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tobytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing exotic here — we grab 16 kHz mono int16 audio, which is the sweet spot for speech recognition: small payloads, no meaningful accuracy loss versus higher sample rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Step 2: Send the clip to the Sync API&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the core of the app. We take the recorded bytes, wrap them as a WAV, and POST them in a single request. Because it's the Sync API, the transcript comes back in that same response — there's no transcript ID to poll. See the &lt;a href="https://www.assemblyai.com/docs/sync-stt/getting-started/transcribe-a-short-audio-file" rel="noopener noreferrer"&gt;Sync API docs&lt;/a&gt; for the full request and response format.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;wave&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_ASSEMBLYAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Send audio as multipart/form-data with an "audio" part; model set via header.
&lt;/span&gt;&lt;span class="n"&gt;SYNC_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://sync.assemblyai.com/transcribe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pcm_to_wav&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_RATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;wave&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setnchannels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setsampwidth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# int16 = 2 bytes
&lt;/span&gt;        &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setframerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;wf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeframes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transcribe_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;wav_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pcm_to_wav&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;SYNC_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-AAI-Model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;u3-sync-pro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clip.wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wav_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio/wav&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request in, one transcript out. No while transcript.status != "completed" loop, no webhook receiver to stand up. For clips up to 2 minutes, this is the whole integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Step 3: Wire up the hotkey and type the result&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Now connect recording to the key and push the transcript into whatever app has focus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pynput&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;keyboard&lt;/span&gt;

&lt;span class="n"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Recorder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;kb_controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# reused — this matters in Step 4
&lt;/span&gt;
&lt;span class="n"&gt;HOTKEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;f9&lt;/span&gt;
&lt;span class="n"&gt;is_recording&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;is_recording&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HOTKEY&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_recording&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;is_recording&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;is_recording&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HOTKEY&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;is_recording&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;is_recording&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="n"&gt;pcm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;transcribe_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;kb_controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;keyboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Listener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;on_press&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;on_press&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_release&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;on_release&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Blurt is running. Hold F9 to dictate.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a working dictation app. Hold F9, say "the quick brown fox," release, and it types into your editor. But run it a few times and you'll feel it: there's a small hitch between releasing the key and seeing text. Let's kill it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Step 4: The pre-warm trick that halves perceived latency&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the thing about a single HTTP request: the model isn't your only cost. Before the audio even reaches the transcription step, the client has to do DNS resolution, open a TCP connection, and complete the TLS handshake. On a cold connection that setup can rival the transcription itself — and it all happens &lt;em&gt;after&lt;/em&gt; the user has finished speaking, which is exactly when they're waiting.&lt;/p&gt;

&lt;p&gt;So move it earlier. The moment the user presses the hotkey — before they've said a word — fire a tiny warm-up request. That opens the connection and completes the handshake while the user is still talking. By the time they release the key and the real clip is ready, you're reusing a hot connection and paying only for transcription.&lt;/p&gt;

&lt;p&gt;We already created a persistent requests.Session(), which reuses the underlying TCP/TLS connection across requests. We just need to warm it at key-down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prewarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Open the connection while the user is still speaking.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SYNC_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# warm-up is best-effort; never block dictation on it
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;is_recording&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;HOTKEY&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_recording&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;is_recording&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prewarm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In informal testing across a handful of runs, this roughly halved the end-to-end time for a short phrase — dictating "test, test, test" landed around 300ms instead of noticeably longer on a cold connection. These aren't formal benchmark numbers, but the effect is easy to feel: the transcript shows up while your finger is still lifting off the key.&lt;/p&gt;

&lt;p&gt;The lesson generalizes. When you're optimizing an interactive speech feature, don't just look at model latency. Look at everything that happens between "user stops talking" and "text appears," and move as much of it as you can to &lt;em&gt;before&lt;/em&gt; the user stops talking.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Step 5: Test it&lt;/strong&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short clips first.&lt;/strong&gt; Try one- and two-word phrases ("yes," "next slide") and a full sentence. Short utterances are where latency is most noticeable, so they're the real test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Punctuation and casing.&lt;/strong&gt; The Sync API returns formatted text, so "email me at sam at example dot com" should come back cleanly. Dictation lives or dies on this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold vs. warm.&lt;/strong&gt; Comment out the prewarm call and feel the difference. This is the whole point of the tutorial — make sure you can feel it too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clips over 2 minutes.&lt;/strong&gt; Sync is built for short audio. If your users might hold the key for minutes, that's a signal you want async or streaming instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;[VIDEO: embed Blurt demo screencast here]&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Where to go from here&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Blurt is intentionally small, but it's a real foundation. A few natural next steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Post-processing.&lt;/strong&gt; Pipe the transcript through an LLM for light cleanup — fix "um"s, apply a formatting style, or translate on the fly. Keep it optional and off the hot path so latency-sensitive users aren't paying for a feature they didn't ask for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom vocabulary.&lt;/strong&gt; If your users dictate names, product terms, or medical language, prime the model so those come back right the first time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know when to switch tiers.&lt;/strong&gt; Sync is the right call for short, interactive, user-triggered speech. If you find yourself wanting live partial transcripts as the user speaks, or handling clips longer than two minutes, that's your cue to look at streaming or async instead. We break down that decision in our guide to &lt;a href="https://www.assemblyai.com/blog/real-time-vs-batch-transcription" rel="noopener noreferrer"&gt;real-time vs batch transcription&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason dictation is worth getting right is that it's one of the few places where users feel your speech-to-text directly, with no interface in between. When the text lands the instant they stop talking, it feels like the app is reading their mind. When it lags, it feels broken. The model was never the hard part — the request pattern was, and now you have it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is the best API for building a dictation app?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For dictation, a synchronous HTTP transcription API is usually the best fit because dictation is short, user-triggered speech with a clear start and stop. AssemblyAI's Sync API takes one short clip in a single HTTP request and returns a finished, formatted transcript in the same response at roughly 134 ms p50 — no WebSocket, no polling. Streaming and async APIs solve different problems (live sessions and long files, respectively), which is why they add plumbing a dictation app doesn't need.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Why use a sync HTTP API instead of streaming for dictation?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Dictation gives you a hard start and stop that the user controls with a key, so you don't need streaming's partial transcripts or its end-of-turn detection — the two things streaming is built for. A sync HTTP call avoids managing a persistent WebSocket, reconnection logic, and streaming state for what is functionally a series of two-second clips. You send the clip and get the text back in one request, which is both simpler to build and fast enough to feel instant.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I reduce latency in a dictation app?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The biggest hidden cost isn't the model — it's the DNS lookup, TCP connection, and TLS handshake that happen after the user stops speaking. Move that work earlier: the moment the user presses the hotkey, fire a small warm-up request over a reused HTTP session so the connection is already open by the time the clip is ready. In informal testing this roughly halved end-to-end time on short phrases, landing "test, test, test" around 300 ms.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How fast is the Sync API for short dictation clips?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The Sync API returns a finished transcript for a short clip in roughly 134 ms at the median (p50), in a single request/response with no polling or webhooks. Because you control when the clip ends, there's no turn-detection step to wait on — the transcript comes back essentially as fast as the model can produce it. Pairing that with a connection pre-warm makes the text appear the instant the user releases the key.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What are the Sync API's limits, and what does it cost?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The Sync API accepts clips from 80 ms up to 2 minutes (40 MB max) across 18 languages, runs on the flagship Universal-3.5 Pro model, and returns formatted text with punctuation and casing. It's priced at $0.45/hr of audio, with keyterms prompting and conversation context included and no rate limits. For anything longer than two minutes, use the pre-recorded (async) API instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;When should I use streaming or async instead of sync for voice input?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Use streaming when you need live partial transcripts as the user speaks — an open-microphone experience or a real-time voice agent that reacts mid-sentence. Use async (batch) when you're transcribing long recordings where completeness and full-file speech understanding matter more than instant turnaround. Use sync for short, user-triggered clips like dictation, voice commands, and push-to-talk, where you want a finished transcript back immediately over one HTTP request.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>speechtotext</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Sync vs. Async Transcription: Which to Use (2026)</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:46:58 +0000</pubDate>
      <link>https://dev.to/martschweiger/sync-vs-async-transcription-which-to-use-2026-52om</link>
      <guid>https://dev.to/martschweiger/sync-vs-async-transcription-which-to-use-2026-52om</guid>
      <description>&lt;p&gt;You've got a recording and you want text back. For years that meant one thing at AssemblyAI: submit the file, wait for the job to finish, get a transcript. Async. It's reliable, it's cheap, and for a huge range of workloads it's exactly right.&lt;/p&gt;

&lt;p&gt;But "wait for the job to finish" is doing a lot of work in that sentence. If your file is two minutes long and your user is staring at a spinner, waiting is the whole problem. That's the gap the &lt;a href="https://www.assemblyai.com/products/sync-speech-to-text" rel="noopener noreferrer"&gt;Sync API&lt;/a&gt; fills — and it's why "which transcription path" is no longer a two-way question.&lt;/p&gt;

&lt;p&gt;This post is about the two ways to transcribe a &lt;em&gt;recording&lt;/em&gt;: async and sync. (If you're deciding between recorded and live audio in the first place — streaming versus the rest — start with our guide to &lt;a href="https://www.assemblyai.com/blog/real-time-vs-batch-transcription" rel="noopener noreferrer"&gt;real-time vs batch transcription&lt;/a&gt;, then come back here to choose between the two non-streaming paths.)&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The one-sentence difference&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Async transcription hands you a job: you submit audio, the work happens in the background, and you collect the result later by polling or via a webhook. Sync transcription hands you an answer: you POST a short clip and the transcript comes back in the same HTTP response — no job to track, no callback to wait for.&lt;/p&gt;

&lt;p&gt;Everything else follows from that. Async is built for throughput and depth on files of any length. Sync is built for speed on short files, when a person or an agent is waiting on the other end.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How fast can each actually go?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is the question that usually settles it, so let's be concrete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async&lt;/strong&gt; processes the whole file and returns a single complete transcript, typically in seconds to a few minutes depending on file length and load. Crucially, it bills on audio duration ($0.21/hr on Universal-3.5 Pro), so a 30-minute file costs the same whether it comes back in 20 seconds or two minutes. You're optimizing for cost and completeness, not for the clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sync&lt;/strong&gt; is built to return a transcript for a short clip almost immediately — roughly 134ms p50 — in one request/response, with no polling and no webhooks. It's priced at $0.45/hr, the same rate as Universal-3.5 Pro Realtime, and handles clips up to 2 minutes (40 MB max). That cap is the point: it's a fast path for short audio, not a replacement for batch processing a podcast archive. Keyterms prompting and conversation context are included, and you can read the full request/response format in the &lt;a href="https://www.assemblyai.com/docs/sync-stt/getting-started/transcribe-a-short-audio-file" rel="noopener noreferrer"&gt;Sync API docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's the part that matters for interactive apps: for a clip under two minutes, sync returns the full transcript faster than the clip took to record. That's "faster than real time" in the literal sense — you speak for eight seconds, you get the text back in a fraction of a second. Async gets there eventually; sync gets there before your user has looked up from the screen.&lt;/p&gt;

&lt;p&gt;Here's how the two recorded-audio paths compare at a glance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Async (pre-recorded)&lt;/th&gt;
&lt;th&gt;Sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;How you get the transcript&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Submit a job, then poll or receive a webhook&lt;/td&gt;
&lt;td&gt;One HTTP POST, transcript in the same response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Speed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Seconds to a few minutes, by file length and load&lt;/td&gt;
&lt;td&gt;~134 ms p50 to a finished transcript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audio length&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Any length&lt;/td&gt;
&lt;td&gt;Up to 2 minutes (max 40 MB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Universal-3.5 Pro&lt;/td&gt;
&lt;td&gt;Universal-3.5 Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Speaker diarization &amp;amp; deep speech understanding&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Price&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.21/hr&lt;/td&gt;
&lt;td&gt;$0.45/hr&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Long files, batch volume, latency-tolerant workloads&lt;/td&gt;
&lt;td&gt;Short clips a person or agent is waiting on&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Same accuracy, different delivery&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A fair worry: does the fast path cut corners on quality? It doesn't. Sync returns the same accuracy you already get from async — formatted text, punctuation, casing — because the tradeoff isn't accuracy, it's scope. Sync trades &lt;em&gt;file length and depth of speech understanding&lt;/em&gt; for speed, not correctness on the words themselves.&lt;/p&gt;

&lt;p&gt;What you give up with sync isn't accuracy — it's the heavy, full-file analysis that async is built for. Sync doesn't include speaker diarization, PII redaction, or the Speech Understanding models. Diarization across a long meeting, summarization, entity and topic detection over an hour of audio: those want the complete recording and the processing time to match. On a two-minute clip headed to a live UI, you don't need any of that. You need the words, now.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A decision you can make in one read&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Reach for &lt;strong&gt;async&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Files are long — meetings, podcasts, calls, media libraries.&lt;/li&gt;
&lt;li&gt;You want the deepest speech understanding: full-file diarization, summaries, entity and topic detection.&lt;/li&gt;
&lt;li&gt;Nobody's waiting on the result in real time — the transcript is consumed after the fact.&lt;/li&gt;
&lt;li&gt;You're processing at volume and want to bill on audio duration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reach for &lt;strong&gt;sync&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clips are short (up to 2 minutes) — dictation, voice commands, one utterance at a time.&lt;/li&gt;
&lt;li&gt;A person or an agent is waiting on the transcript right now.&lt;/li&gt;
&lt;li&gt;You'd rather make one HTTP call than stand up a webhook receiver or poll a job.&lt;/li&gt;
&lt;li&gt;You already control when the audio starts and stops, so you don't need a live stream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick gut check: if you can't tolerate a spinner, and the clip is short, that's sync. If the file is long and the transcript is read later, that's async. And if you need live partial transcripts &lt;em&gt;as someone speaks&lt;/em&gt; — a voice agent listening mid-sentence — that's neither; that's streaming, and the &lt;a href="https://www.assemblyai.com/blog/real-time-vs-batch-transcription" rel="noopener noreferrer"&gt;real-time vs batch guide&lt;/a&gt; covers where that line falls.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The bigger shift&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;For a long time the transcription decision was binary: fast-but-live (streaming) or accurate-but-delayed (async). Sync quietly breaks that binary. It says you can have a recorded-audio workflow — the simplicity of a request/response API, no streaming infrastructure — &lt;em&gt;and&lt;/em&gt; get the answer back fast enough for a person to wait on it.&lt;/p&gt;

&lt;p&gt;That's worth internalizing when you're architecting a product. The question isn't just "live or recorded" anymore. For recorded audio, it's "do I need this back while someone's waiting?" If yes and the clip is short, you no longer have to choose between the fast path and the simple one. Sync is both.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is the difference between sync and async transcription?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Sync transcription returns a finished transcript in the same HTTP response as your request, while async transcription submits a background job you collect later by polling or webhook. Sync is built for short clips (up to 2 minutes) where speed matters; async is built for files of any length where completeness and the deepest speech understanding matter. Both run on the same flagship model, so the words themselves are equally accurate — the difference is delivery, not quality.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How fast is a synchronous transcription API, and what response time can I expect?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;AssemblyAI's Sync API returns a finished transcript in roughly 134 ms at the median (p50) for a short clip, in a single request/response with no polling or webhooks. For any clip under two minutes, that means the transcript comes back faster than the audio took to record — "faster than real time" in the literal sense. Async, by contrast, returns a complete transcript in seconds to a few minutes depending on file length and load.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;When should I use sync instead of async batch transcription?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Use sync when the clip is short and a person or agent is waiting on the result right now — dictation, voice commands, IVR prompts, voicemail, or voice-agent turns where you handle turn detection yourself. Use async when files are long, nobody's waiting in real time, or you need full-file speaker diarization, summarization, and entity or topic detection. A quick gut check: if you can't tolerate a spinner and the clip is short, that's sync; if the file is long and the transcript is read later, that's async.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Is the Sync API the same as real-time streaming transcription?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. Streaming transcribes live audio over a persistent WebSocket, returning partial words &lt;em&gt;as someone speaks&lt;/em&gt; — the right tool for open-ended live sessions like captions and real-time voice agents. Sync is for recorded clips you already have in hand: you send one complete short clip over a single HTTP POST and get the full transcript back at once. If you need transcription mid-sentence during an open microphone, that's streaming, not sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Does synchronous transcription sacrifice accuracy for speed?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No. Sync returns the same transcription accuracy as async — the same formatted text, punctuation, and casing — because it runs on the same Universal-3.5 Pro model. The tradeoff is scope, not correctness: sync is capped at short clips and does not include speaker diarization, PII redaction, or the Speech Understanding models. For those heavier, full-file features, use the pre-recorded (async) or realtime APIs.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How do I get started with the Sync API, and what are its limits and pricing?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Create a free AssemblyAI account, then send a short audio clip in a single HTTP POST with your API key and read the transcript straight off the response — no job to track. The Sync API accepts clips from 80 ms up to 2 minutes (40 MB max) across 18 languages, and is priced at $0.45/hr of audio, the same rate as Universal-3.5 Pro Realtime, with keyterms prompting and conversation context included. See the &lt;a href="https://www.assemblyai.com/docs/sync-stt/getting-started/transcribe-a-short-audio-file" rel="noopener noreferrer"&gt;Sync API docs&lt;/a&gt; for the full request and response format.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>speechtotext</category>
      <category>api</category>
    </item>
    <item>
      <title>Voice Agent Architectures Explained</title>
      <dc:creator>Mart Schweiger</dc:creator>
      <pubDate>Wed, 08 Jul 2026 16:14:28 +0000</pubDate>
      <link>https://dev.to/martschweiger/voice-agent-architectures-explained-35e0</link>
      <guid>https://dev.to/martschweiger/voice-agent-architectures-explained-35e0</guid>
      <description>&lt;p&gt;Every voice agent does the same three things. It listens, it thinks, and it talks back. The interesting part isn't the what — it's the how. And the "how" splits into three very different architectures, each with its own trade-offs on latency, control, cost, and the one thing that quietly decides whether your agent is any good: accuracy.&lt;/p&gt;

&lt;p&gt;So before you wire anything together, it's worth understanding the three patterns you can choose from — and why the step everyone treats as plumbing is actually the ceiling on how smart your agent can be.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The job of a voice agent: hear, think, speak&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Strip away the framework names and a voice agent is a loop. A person says something. The agent turns that audio into text (speech-to-text, or STT). A language model reads the text and decides what to say (the LLM). That response gets turned back into audio (text-to-speech, or TTS). Then it waits for the next thing the person says and does it all again — ideally in about a second, because anything slower feels like a bad phone connection.&lt;/p&gt;

&lt;p&gt;Here's the thing most architecture debates miss: the model that thinks is only ever reacting to what the first step heard. If the STT layer mishears "my account ends in 4-0-1-5" as "4-0-1-8," the LLM confidently helps with the wrong account. Get the input wrong and everything downstream is wrong too. Keep that in mind — it's the theme that runs through all three architectures below.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Architecture 1 — the chained STT→LLM→TTS pipeline&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is the classic approach, sometimes called the cascading or chained architecture. You pick a best-in-class model for each stage and connect them: a streaming speech-to-text API feeds transcripts to an LLM, and the LLM's response goes to a text-to-speech engine. Turn detection decides when the user is done talking so the LLM knows when to respond.&lt;/p&gt;

&lt;p&gt;The upside is control. You choose the most accurate STT model, the LLM that best fits your use case, and the voice you want. You can tune each stage independently — swap the LLM without touching transcription, add domain vocabulary at the STT layer, change voices at the TTS layer. For teams that need a genuinely differentiated product, that granularity matters.&lt;/p&gt;

&lt;p&gt;The cost is plumbing. You're now integrating three providers, reconciling three sets of latency, and debugging across three surfaces. Every millisecond of end-to-end latency is the sum of each hop plus the handoffs between them. It's the most flexible architecture and the most work.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Architecture 2 — a single speech-to-speech model&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The newer approach collapses the pipeline into one multimodal model that takes audio in and emits audio out, reasoning over speech directly. It's elegant, and it's simple to start with — one API, one round trip.&lt;/p&gt;

&lt;p&gt;But simplicity has a price, and it's control. When a single model does everything, you can't swap the "ears" for a more accurate transcription model, you can't inspect the transcript to see what it actually heard, and you can't tune turn-taking or entity recognition stage by stage. These models are also built primarily around conversational fluency rather than getting a spelled-out email address or a 16-digit card number exactly right. When the same black box handles listening and speaking, an input error is invisible until the agent says something wrong.&lt;/p&gt;

&lt;p&gt;Speech-to-speech is a great fit for casual, low-stakes conversation. It's a harder sell when your agent has to capture names, numbers, and identifiers correctly on the first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Architecture 3 — a unified voice agent API&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;There's a third option that's easy to miss because it looks like the simple one from the outside and behaves like the flexible one on the inside: a unified voice agent API that runs the full STT→LLM→TTS pipeline for you behind a single connection.&lt;/p&gt;

&lt;p&gt;AssemblyAI's &lt;a href="https://www.assemblyai.com/products/voice-agent-api" rel="noopener noreferrer"&gt;Voice Agent API&lt;/a&gt; is one example. You connect to a single WebSocket, stream audio in, and get audio back. Under the hood it's still a pipeline — real speech-to-text, a real LLM, real text-to-speech — but you get one connection, one bill measured in hours instead of token math across three invoices, and one set of logs instead of three dashboards. It's a flat $4.50/hr for all three stages, with roughly one-second end-to-end latency, and it works natively with tools like Claude Code so you can go from docs to a working agent the same afternoon.&lt;/p&gt;

&lt;p&gt;The framing that matters here is "invisible infrastructure." You're not adopting an opinionated agent platform where every agent ends up sounding the same. You keep control over conversation design, tool calling, and turn timing — you just skip the plumbing. It's built on Universal-3.5 Pro Streaming, so the listening step is handled by a model designed for exactly the hard tokens that trip agents up.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Choosing an architecture&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;There's no single right answer — there's a right answer for your product. Here's how the three patterns compare on the dimensions that usually decide it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Chained STT→LLM→TTS&lt;/th&gt;
&lt;th&gt;Speech-to-speech&lt;/th&gt;
&lt;th&gt;Unified voice agent API&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Control over each stage&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first working agent&lt;/td&gt;
&lt;td&gt;Slowest (three integrations)&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transcript transparency&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing &amp;amp; debugging surfaces&lt;/td&gt;
&lt;td&gt;Three&lt;/td&gt;
&lt;td&gt;One&lt;/td&gt;
&lt;td&gt;One&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entity accuracy on hard tokens&lt;/td&gt;
&lt;td&gt;Depends on chosen STT&lt;/td&gt;
&lt;td&gt;Weaker&lt;/td&gt;
&lt;td&gt;Strong (Universal-3.5 Pro Streaming)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Strong (Universal-3.5 Pro Streaming)&lt;/p&gt;

&lt;p&gt;If you're a large team building a deeply custom product and you want to own every layer, the chained pipeline earns its complexity. If you're prototyping something casual, a speech-to-speech model gets you talking fast. And if you want production-grade accuracy and control without integrating three vendors, a unified API is the pragmatic middle path — which is why it's where most teams building their first serious voice product end up.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why the listening step is the real ceiling&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Notice what every row in that table has in common: none of them can fix a transcription mistake after the fact. The LLM can only reason over the words it was handed. So the accuracy of the speech-to-text layer sets the ceiling for the entire agent, no matter which architecture you pick.&lt;/p&gt;

&lt;p&gt;That's why the input model matters more than its "plumbing" reputation suggests. &lt;a href="https://www.assemblyai.com/universal-3-pro-streaming" rel="noopener noreferrer"&gt;Universal-3.5 Pro Streaming&lt;/a&gt; is built for the tokens agents get wrong most often — emails, names, phone numbers, account IDs — with sub-300-millisecond latency and native code-switching across 18 languages. It uses punctuation-based turn detection so the agent knows the difference between a pause and a finished thought. In a chained pipeline you'd choose it as your STT layer; in the Voice Agent API it's already there. Either way, it's doing the job that decides whether the rest of your stack has a chance.&lt;/p&gt;

&lt;p&gt;For a closer look at where DIY stacks tend to strain under real traffic, our write-up on &lt;a href="https://www.assemblyai.com/blog/where-voice-agent-stacks-start-showing-their-limits" rel="noopener noreferrer"&gt;where voice agent stacks start showing their limits&lt;/a&gt; is a useful companion read.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The takeaway&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Architecture is a series of trade-offs, but they all bottom out in the same place. Control versus simplicity, three bills versus one, transparency versus a black box — those are real decisions. What isn't up for debate is that the model doing the listening determines how good everything after it can be. Pick the architecture that fits your team, then give it the best possible ears. That's the part your customers will actually hear.&lt;/p&gt;

&lt;p&gt;Want the hands-on version? Our guide to &lt;a href="https://www.assemblyai.com/blog/how-to-build-with-voice-agent-api" rel="noopener noreferrer"&gt;building with the Voice Agent API&lt;/a&gt; walks through a working agent, and &lt;a href="https://www.assemblyai.com/blog/ai-voice-agents" rel="noopener noreferrer"&gt;what are AI voice agents&lt;/a&gt; covers the fundamentals if you're just getting oriented.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Frequently asked questions&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is a voice agent architecture?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A voice agent architecture is the way you connect the three core steps of a spoken conversation: speech-to-text (listening), a language model (thinking), and text-to-speech (speaking). The three common patterns are a chained STT→LLM→TTS pipeline, a single speech-to-speech model, and a unified voice agent API that runs the full pipeline behind one connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is the difference between a cascading (STT→LLM→TTS) pipeline and a speech-to-speech model?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A cascading pipeline uses a separate, best-in-class model for each stage, giving you full control and a visible transcript but requiring you to integrate and debug three providers. A speech-to-speech model handles audio-in to audio-out in one model, which is simpler to start with but hides the transcript and gives you less control over per-stage accuracy and turn-taking.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Which voice agent architecture has the lowest latency?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;All three can reach roughly one-second end-to-end latency when tuned well. A single speech-to-speech model removes handoffs between stages, while a well-built unified API like AssemblyAI's Voice Agent API reaches about one second end-to-end because the pipeline is optimized as a whole rather than assembled from three separate vendors.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Why does speech-to-text accuracy matter so much for voice agents?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The language model can only respond to the words the speech-to-text layer produces, so any transcription error propagates through the entire agent. This is why entity accuracy on emails, names, phone numbers, and account IDs is the ceiling on agent quality — and why models like Universal-3.5 Pro Streaming are built specifically for those hard tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What is the best API for building a voice agent?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The best choice depends on how much control you need. Teams that want production accuracy without integrating three providers often choose a unified voice agent API; AssemblyAI's Voice Agent API runs STT, an LLM, and TTS through one WebSocket at a flat $4.50/hr, built on Universal-3.5 Pro Streaming for high entity accuracy.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>voiceassistant</category>
      <category>python</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
