Insights & Use Cases
July 22, 2026

Speech-to-text API fundamentals: authenticate, poll status, and parse the JSON response

Every speech-to-text integration comes down to three questions: how do I authenticate, how do I get results from an async job, and what does the response contain? This canonical walkthrough covers your first authenticated request, the status lifecycle, a correct polling loop, and a field-by-field JSON tour.

Kelsey Foster
Growth
Reviewed by
No items found.
Table of contents

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.

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.

If you're new to the space, What is speech-to-text? and What is Automatic Speech Recognition (ASR)? give you the conceptual grounding. Here, we're shipping a first successful call.

Overview

AssemblyAI's REST API lives at https://api.assemblyai.com. 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.

The whole loop:

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

Let's build it.

Prerequisites

  • An AssemblyAI API key. Get your free API key from the dashboard.
  • A publicly reachable audio file URL (or one you upload — that's a separate endpoint; here we'll use a URL).
  • Optionally, one of the official SDKs:
  • Python: pip install assemblyai
  • JavaScript/TypeScript: npm install assemblyai

Set your key as an environment variable so it never lands in source control:

export ASSEMBLYAI_API_KEY="your_api_key_here"

Every example below reads the key from ASSEMBLYAI_API_KEY.

Quick start

The SDKs collapse submit-and-poll into a single call. Here's the entire flow in each.

import os, assemblyai as aai

aai.settings.api_key = os.environ["ASSEMBLYAI_API_KEY"]

transcript = aai.Transcriber().transcribe(
    "https://assembly.ai/wildfires.mp3"
)

print(transcript.text)


import { AssemblyAI } from "assemblyai";

const client = new AssemblyAI({ apiKey: process.env.ASSEMBLYAI_API_KEY });

const transcript = await client.transcripts.transcribe({
  audio: "https://assembly.ai/wildfires.mp3",
});

console.log(transcript.text);

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.

Ship Your First Authenticated Call

One header, one POST, one poll — that's the whole loop. Grab a free API key and run submit-poll-parse against your own audio in a few minutes.

Sign up free

1. Authentication

AssemblyAI authenticates with a single header on every request:

authorization: <YOUR_API_KEY>

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

Here's a first authenticated request that lists your account's transcripts:

curl https://api.assemblyai.com/v2/transcript \
  --header "authorization: $ASSEMBLYAI_API_KEY"

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.

Never hardcode the key. Read it from the environment, a secrets manager, or your platform's config store.

2. Submitting a job and the status lifecycle

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.

Submit with POST /v2/transcript:

curl https://api.assemblyai.com/v2/transcript \
  --header "authorization: $ASSEMBLYAI_API_KEY" \
  --header "content-type: application/json" \
  --data '{"audio_url": "https://assembly.ai/wildfires.mp3"}'

The response is a transcript object. The two fields that matter right now:

{
  "id": "106993b6-...",
  "status": "queued"
}

Persist that id. It's how you retrieve the result, and it's how the transcript is addressed for the rest of its life.

The status lifecycle

A job moves through a small, fixed set of status values. These are the exact strings the API returns:

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

completed and error are the two terminal 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.

3. Polling correctly

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.

The convention: poll every 3 seconds, 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.

curl

TRANSCRIPT_ID="106993b6-..."

while true; do
  response=$(curl --silent https://api.assemblyai.com/v2/transcript/$TRANSCRIPT_ID \
    --header "authorization: $ASSEMBLYAI_API_KEY")
  status=$(echo "$response" | jq -r '.status')

  if [ "$status" = "completed" ]; then
    echo "$response" | jq -r '.text'
    break
  elif [ "$status" = "error" ]; then
    echo "$response" | jq -r '.error'
    break
  fi

  sleep 3
done

Python

import os
import time
import requests

API_KEY = os.environ["ASSEMBLYAI_API_KEY"]
BASE_URL = "https://api.assemblyai.com"
headers = {"authorization": API_KEY}

# Submit the job
resp = requests.post(
    f"{BASE_URL}/v2/transcript",
    json={"audio_url": "https://assembly.ai/wildfires.mp3"},
    headers=headers,
)
resp.raise_for_status()
transcript_id = resp.json()["id"]
print(f"Submitted: {transcript_id}")

# Poll until terminal
polling_url = f"{BASE_URL}/v2/transcript/{transcript_id}"
while True:
    transcript = requests.get(polling_url, headers=headers).json()
    status = transcript["status"]

    if status == "completed":
        print(transcript["text"])
        break
    elif status == "error":
        raise RuntimeError(f"Transcription failed: {transcript['error']}")

    time.sleep(3)

JavaScript

const API_KEY = process.env.ASSEMBLYAI_API_KEY;
const BASE_URL = "https://api.assemblyai.com";
const headers = { authorization: API_KEY };

// Submit the job
const submit = await fetch(`${BASE_URL}/v2/transcript`, {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body: JSON.stringify({ audio_url: "https://assembly.ai/wildfires.mp3" }),
});
const { id } = await submit.json();
console.log(`Submitted: ${id}`);

// Poll until terminal
const pollingUrl = `${BASE_URL}/v2/transcript/${id}`;
while (true) {
  const transcript = await (await fetch(pollingUrl, { headers })).json();

  if (transcript.status === "completed") {
    console.log(transcript.text);
    break;
  } else if (transcript.status === "error") {
    throw new Error(`Transcription failed: ${transcript.error}`);
  }

  await new Promise((r) => setTimeout(r, 3000));
}


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, webhooks push the result to you instead — more on that below.

For the full endpoint contracts, see Transcribe an audio file and Check transcript status.

4. Parsing the JSON response

Once status is completed, the transcript object is fully populated. Here's the canonical shape, trimmed to the essentials:

{
  "id": "106993b6-...",
  "status": "completed",
  "text": "Smoke from hundreds of wildfires...",
  "language_code": "en",
  "audio_duration": 282,
  "confidence": 0.95,
  "words": [
    { "text": "Smoke", "start": 100, "end": 640, "confidence": 0.9, "speaker": null }
  ]
}

Let's walk the fields you'll actually use.

  • id — the job identifier. Persist it; you can GET the transcript again anytime.
  • status — where the job is in its lifecycle. On a completed job, completed.
  • text — the full transcript as one string. null until the job completes, so always gate on status first.
  • confidence — an overall confidence score as a float from 0.0 to 1.0. In the example, 0.95.
  • audio_duration — the length of the audio in seconds (an integer). 282 here means the file is 282 seconds long. This is what you meter usage against.
  • language_code — the detected or specified language, e.g. "en". When you use language detection, you'll also get a language_confidence between 0 and 1.
  • words[] — every word as its own object: text, start, end, confidence, and speaker. The start and end timestamps are in milliseconds. 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.

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

Utterances and speakers

When you enable speaker_labels or multichannel, the response gains an utterances[] array. Each utterance groups contiguous speech by a single speaker:

{
  "utterances": [
    {
      "speaker": "A",
      "text": "Smoke from hundreds of wildfires...",
      "confidence": 0.94,
      "start": 100,
      "end": 4200,
      "words": [ { "text": "Smoke", "start": 100, "end": 640, "confidence": 0.9, "speaker": "A" } ]
    }
  ]
}


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.

See the JSON Before You Parse It

Drop in a clip and inspect the full transcript object — text, confidence, word timestamps, and utterances — so you know exactly what your code will receive.

Try playground

5. Error handling

Two failure modes, two different responses. Handle them differently.

HTTP-level errors 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.

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

{
  "id": "106993b6-...",
  "status": "error",
  "error": "Download error, unable to download the audio file"
}

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.

A resilient loop distinguishes the two:

if status == "error":
    # Job-level failure — inspect .error, likely a bad input
    raise RuntimeError(transcript["error"])

Wrap the POST in retry-with-backoff for 5xxs, and never blind-retry a 400.

Next steps

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

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

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 API reference covers the webhook payload and every option in detail.

From here, explore the full speech-to-text product and the deep dive on how accurate speech-to-text is in 2026.

View the full API referenceassemblyai.com/docs/api-reference/overview

Get your free API keyassemblyai.com/dashboard/signup

Build on the Fundamentals

The same submit-poll-parse flow carries every feature — speaker labels, PII redaction, webhooks, and more. Get a free API key and start adding request options.

Get your free API key

Frequently asked questions

How do I authenticate with the AssemblyAI API? 

Send your API key in the authorization header on every request — authorization: <YOUR_API_KEY>. 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 dashboard.

Do I poll for results or get them back immediately? 

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.

What do the transcript status values mean? 

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.

How do I read confidence scores and word timestamps? 

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 milliseconds. Note that audio_duration is in seconds while word timestamps are in milliseconds — don't mix the units.

Why is my transcript status error? 

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.

What can I do beyond a basic transcript?

 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 Submit a transcript and Get a transcript for full field references.

Title goes here

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

Button Text
Tutorial