Transcription webhooks and callbacks: get notified when a transcript is ready
Stop polling for finished transcripts. Set one field, expose one endpoint, and AssemblyAI POSTs you the moment a job completes. This guide covers the whole path — what the payload actually contains, how to verify requests without a signature scheme, and how retries behave when your server hiccups.



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.
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 speech-to-text, read to the end.
Polling vs. webhooks: when to use which
Both approaches answer the same question—"is my transcript done yet?"—but they trade off differently.
Polling 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.
Webhooks 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.
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.
Enabling a webhook
You enable a webhook by setting webhook_url in the body of your POST /v2/transcript request against the base URL https://api.assemblyai.com. Auth is a plain authorization header with your API key—no Bearer prefix.
Here it is with curl:
curl https://api.assemblyai.com/v2/transcript \
--header "authorization: <YOUR_API_KEY>" \
--header "content-type: application/json" \
--data '{
"audio_url": "https://example.com/audio.mp3",
"webhook_url": "https://your-app.com/webhooks/transcript"
}'
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:
import assemblyai as aai
aai.settings.api_key = "<YOUR_API_KEY>"
config = aai.TranscriptionConfig().set_webhook(
"https://your-app.com/webhooks/transcript"
)
transcriber = aai.Transcriber(config=config)
transcript = transcriber.submit("https://example.com/audio.mp3")
print(f"Submitted {transcript.id}. We'll get a callback when it's done.")
And in JavaScript:
import { AssemblyAI } from "assemblyai";
const client = new AssemblyAI({ apiKey: "<YOUR_API_KEY>" });
const transcript = await client.transcripts.submit({
audio: "https://example.com/audio.mp3",
webhook_url: "https://your-app.com/webhooks/transcript",
});
console.log(`Submitted ${transcript.id}`);That's the whole setup. Full field reference lives in the submit endpoint docs.
What the payload actually contains
Here's the gotcha that trips up almost everyone. When transcription finishes, AssemblyAI POSTs to your URL with a minimal payload:
{
"transcript_id": "your-transcript-id",
"status": "completed"
}
status is either completed or error. That's it.
The payload does not contain the transcript text. It does not 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.
So your receiver's job is really two steps: catch the notification, then go fetch the actual transcript:
import requests
def fetch_transcript(transcript_id, api_key):
response = requests.get(
f"https://api.assemblyai.com/v2/transcript/{transcript_id}",
headers={"authorization": api_key},
)
response.raise_for_status()
return response.json()If you internalize one thing from this article, make it this: the webhook tells you when, the GET tells you what.
Building a receiver endpoint
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:
import os
import requests
from flask import Flask, request, abort
app = Flask(__name__)
ASSEMBLYAI_API_KEY = os.environ["ASSEMBLYAI_API_KEY"]
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
def fetch_transcript(transcript_id):
response = requests.get(
f"https://api.assemblyai.com/v2/transcript/{transcript_id}",
headers={"authorization": ASSEMBLYAI_API_KEY},
)
response.raise_for_status()
return response.json()
@app.route("/webhooks/transcript", methods=["POST"])
def handle_webhook():
# 1. Verify the request via your custom auth header.
if request.headers.get("X-My-Webhook-Secret") != WEBHOOK_SECRET:
abort(401)
payload = request.get_json(silent=True) or {}
transcript_id = payload.get("transcript_id")
status = payload.get("status")
if not transcript_id:
# Malformed request—4xx tells AssemblyAI to stop retrying.
abort(400)
# 2. Branch on status. Fetch the real data with a GET.
if status == "completed":
transcript = fetch_transcript(transcript_id)
print(f"Transcript {transcript_id} ready: {transcript['text'][:120]}...")
# ... enqueue downstream work, write to your DB, etc.
elif status == "error":
transcript = fetch_transcript(transcript_id)
print(f"Transcript {transcript_id} failed: {transcript['error']}")
# ... alert, retry the job, flag the customer record, etc.
# 3. Return 2xx fast so AssemblyAI marks delivery successful.
return "", 200Notice 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.
The same structure works in Node/Express:
import express from "express";
const app = express();
app.use(express.json());
const API_KEY = process.env.ASSEMBLYAI_API_KEY;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
app.post("/webhooks/transcript", async (req, res) => {
if (req.header("X-My-Webhook-Secret") !== WEBHOOK_SECRET) {
return res.sendStatus(401);
}
const { transcript_id, status } = req.body;
if (!transcript_id) return res.sendStatus(400);
// Respond first, then do the work off the request path.
res.sendStatus(200);
const result = await fetch(
`https://api.assemblyai.com/v2/transcript/${transcript_id}`,
{ headers: { authorization: API_KEY } }
).then((r) => r.json());
if (status === "completed") {
console.log(`Ready: ${result.text.slice(0, 120)}...`);
} else if (status === "error") {
console.error(`Failed: ${result.error}`);
}
});
app.listen(3000);Securing your webhook
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.
Custom auth header. 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:
curl https://api.assemblyai.com/v2/transcript \
--header "authorization: <YOUR_API_KEY>" \
--header "content-type: application/json" \
--data '{
"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"
}'
In Python, pass the extra arguments to set_webhook:
config = aai.TranscriptionConfig().set_webhook(
"https://your-app.com/webhooks/transcript",
"X-My-Webhook-Secret",
"secret-value",
)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.
Source-IP allow-list. 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.
webhook_status_code. 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).
Use the auth header and the IP allow-list together. Neither is a signature, but combined they're a solid perimeter.
Reliability and retries
Your endpoint has to return a 2xx status within 10 seconds. That's the contract.
If you don't—timeout, 5xx, connection refused—AssemblyAI retries. You get up to 10 total attempts, spaced 10 seconds apart. That gives your service a bit under two minutes to recover from a transient blip without losing the notification.
One sharp edge: a 4xx response marks the delivery as failed and stops retries immediately. 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.
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.
Adding metadata via query params
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:
config = aai.TranscriptionConfig().set_webhook(
"https://your-app.com/webhooks/transcript?customer_id=1234&order_id=5678"
)When the callback lands, read those params off the request:
customer_id = request.args.get("customer_id")
order_id = request.args.get("order_id")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.
Testing locally
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.
ngrok http 3000
# Use the forwarding URL (e.g. https://abc123.ngrok.io/webhooks/transcript)
# as your webhook_url when submitting a transcript.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.
A note on streaming webhooks
Everything above is for pre-recorded (async) transcription. If you're working with real-time audio, streaming has its own webhook mechanism—see the streaming webhooks docs. Don't mix the two; the payloads and lifecycle differ.
Next steps
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.
From here, dig into the pre-recorded audio webhooks guide for the canonical reference, or read up on what speech-to-text is and how accurate it is in 2026 if you're still evaluating models.
Get your free API key and submit your first job with a webhook in a few minutes.
View the full API reference for every field on the transcript object.
Frequently asked questions
Does AssemblyAI support webhooks?
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.
What's in the webhook payload?
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}.
How do I secure and verify webhook requests?
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.
What happens if my endpoint is down when a webhook fires?
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.
Should I use webhooks or polling?
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.
Can I attach my own metadata to a webhook?
Yes. Add query parameters to your webhook_url, like ?customer_id=1234&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.
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.



