# Guide to AssemblyAI Webhooks: Features and Best Practices

AssemblyAI webhooks tell your application when a transcription job has finished. If you transcribe recorded calls, meetings or podcasts with AssemblyAI, a webhook lets you submit the audio, move on, and get notified when the transcript is ready, instead of polling the API. Streaming sessions can send a webhook too, with the complete transcript once the session ends.

This guide covers how AssemblyAI's per-request webhooks work, what the two payloads contain, how to authenticate deliveries with a header you define, AssemblyAI's retry rules, and the best practices for running a receiver in production.

## What are AssemblyAI webhooks?

AssemblyAI webhooks are callbacks attached to individual requests. There is no dashboard where you register an endpoint and no list of event types. You add a `webhook_url` to a transcription request, and AssemblyAI sends one `POST` to that URL when the job is done.

For pre-recorded audio, the webhook is deliberately small. The body holds only the transcript ID and its status, and you fetch the transcript itself from the API. For streaming, the webhook arrives after the session terminates and carries the finalized turns.

AssemblyAI does not sign webhooks. To authenticate them, you set `webhook_auth_header_name` and `webhook_auth_header_value` on the request, and AssemblyAI includes that header, exactly as given, on the delivery. Your endpoint compares it with the value it expects.

## AssemblyAI webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per request: `webhook_url` on `POST /v2/transcript`, or a query parameter on the streaming WebSocket URL |
| Deliveries | One `POST` per transcript or streaming session |
| Pre-recorded body | `{ "transcript_id", "status" }`, with `status` `completed` or `error` |
| Streaming body | `{ "session_id", "messages" }`, the finalized turns |
| Event type | None. The body shape and `status` tell you what happened |
| Authentication | A header whose name and value you set per request. No signature |
| Metadata | Your own query parameters on `webhook_url` |
| Success response | A `2xx` within 10 seconds |
| Retries | Up to 10 times, 10 seconds apart |
| 4xx responses | Treated as a permanent failure, with no retry |
| Delivery status | `webhook_status_code` on the transcript records your endpoint's response |
| Source IPs | `44.238.19.20` (US) and `54.220.25.36` (EU) |

## Common events

AssemblyAI does not name events. There are two kinds of delivery.

**Pre-recorded transcription.** When a transcript finishes, the body is:

```json
{
  "transcript_id": "5552493-16d8-42d8-8feb-c2a16b56f6e8",
  "status": "completed"
}

```

`status` is `completed` or `error`, and that is all the body contains: no text and no error message. Call `GET https://api.assemblyai.com/v2/transcript/{transcript_id}` to get the transcript, or its `error` field when the status is `error`.

**Streaming sessions.** When a streaming session with a `webhook_url` ends, the body carries the session ID and its finalized turns, trimmed here:

```json
{
  "session_id": "273e79fd-99e9-4e1d-91da-90f56a132d01",
  "messages": [
    {
      "type": "Turn",
      "turn_order": 0,
      "end_of_turn": true,
      "turn_is_formatted": true,
      "transcript": "Smoke from hundreds of wildfires in Canada is triggering air quality alerts throughout the US...",
      "words": [
        { "start": 4880, "end": 5040, "text": "Smoke", "confidence": 0.76054, "word_is_final": true }
      ]
    }
  ]
}

```

The two shapes are easy to tell apart: a pre-recorded delivery has `transcript_id`, and a streaming delivery has `session_id`.

To tie a delivery back to your own records, add query parameters to the `webhook_url`, for example `https://example.com/webhooks/assemblyai?customer_id=1234&order_id=5678`. They come back on the request URL.

## Setting up AssemblyAI webhooks

1. Choose a header name, such as `X-Webhook-Secret`, and generate a long random value, for example with `openssl rand -hex 32`. Store both server-side.
2. Send them with every transcription request that should call back:

```bash
curl https://api.assemblyai.com/v2/transcript \
  -H "authorization: $ASSEMBLYAI_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "audio_url": "https://assembly.ai/wildfires.mp3",
    "webhook_url": "https://example.com/webhooks/assemblyai",
    "webhook_auth_header_name": "X-Webhook-Secret",
    "webhook_auth_header_value": "'"$ASSEMBLYAI_WEBHOOK_SECRET"'"
  }'

```

The SDKs take the same fields. In the Python SDK, use `set_webhook()` on the transcription config, and use `submit()` rather than `transcribe()` so the call returns without waiting for the result.

For streaming, add the same parameters to the WebSocket URL:

```
wss://streaming.assemblyai.com/v3/ws?sample_rate=16000&webhook_url=https://example.com/webhooks/assemblyai&webhook_auth_header_name=X-Webhook-Secret&webhook_auth_header_value=<value>

```

The webhook URL must be reachable from AssemblyAI's servers, so `localhost` will not work. For local development, `hookdeck listen 3000 assemblyai --path /webhooks/assemblyai` with the [Hookdeck CLI](/docs/cli) gives you a public URL that forwards to your machine.

If you receive AssemblyAI webhooks through Hookdeck, enter the header name and value in the Hookdeck source's **Header Name** and **Header Value** fields. Because the header is set per request, every transcription request that targets the Hookdeck URL must send the same name and value. A request sent without them cannot pass.

## Securing AssemblyAI webhooks

Look up the header you chose, compare its value with the one you expect in constant time, and reject the request if it is missing or different. Node lowercases incoming header names, so look the header up in lowercase. Fail closed if the name or value is not configured.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyAssemblyAIHeader(headers, headerName, expectedValue) {
  if (!headerName || !expectedValue) return false;
  const value = headers[headerName.toLowerCase()];
  if (typeof value !== 'string') return false;
  const received = Buffer.from(value, 'utf8');
  const expected = Buffer.from(expectedValue, 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/assemblyai', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyAssemblyAIHeader(
    req.headers,
    process.env.ASSEMBLYAI_WEBHOOK_HEADER_NAME,
    process.env.ASSEMBLYAI_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(401);

  const payload = JSON.parse(req.body.toString('utf8'));
  // payload.transcript_id (pre-recorded) or payload.session_id (streaming).
  // Store it, return 200, then fetch the transcript asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac

def verify_assemblyai_header(headers, header_name, expected_value) -> bool:
    if not header_name or not expected_value:
        return False
    value = headers.get(header_name)  # Starlette and Flask headers are case-insensitive
    if value is None:
        return False
    return hmac.compare_digest(value.encode("utf-8"), expected_value.encode("utf-8"))

```

Be careful with the status code you return. AssemblyAI treats any `4xx` as a permanent failure and does not retry it. That is right for a forged request, but if your own configuration is wrong (a rotated secret, a renamed environment variable), every genuine delivery is rejected once and lost. Watch for `401`s from your endpoint, and recover by fetching transcripts by ID.

AssemblyAI also sends every webhook from two fixed IP addresses, so you can add an IP allowlist on top of the header check.

> **Make AssemblyAI webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your AssemblyAI auth header, answers within AssemblyAI's 10-second window, and keeps every completion notice until your service has fetched the transcript.

## AssemblyAI webhook limitations and pain points

### A 4xx loses the notification

**The Problem:** A configuration mistake on your side means transcripts finish but your application never hears about them.

**Why It Happens:** AssemblyAI does not retry a `4xx` response. A mismatched secret produces a `401`, and that delivery is finished.

**Workarounds:**

* Alert on any `4xx` your webhook route returns.
* Record every transcript ID you submit, and poll for any that are still pending after an expected time.
* Check `webhook_status_code` on the transcript to see what your endpoint answered.

**How Hookdeck Can Help:** Hookdeck accepts the delivery as soon as it arrives, so AssemblyAI sees success. A request that fails the header check is kept in Hookdeck's request log as rejected with `VERIFICATION_FAILED`, rather than vanishing, and accepted events can be [retried](/docs/retries) or replayed to your service after you fix the problem.

### Ten seconds to respond

**The Problem:** A handler that fetches the transcript before answering times out and receives the same webhook again.

**Why It Happens:** AssemblyAI expects a `2xx` within 10 seconds, and retries up to 10 times, 10 seconds apart, when it does not get one.

**Workarounds:**

* Return `200` straight after the header check, and fetch the transcript in a background job.
* Make the handler idempotent on `transcript_id` or `session_id`.

**How Hookdeck Can Help:** Hookdeck answers AssemblyAI immediately and delivers to your service on its own schedule, with a [maximum delivery rate](/docs/destinations#set-a-max-delivery-rate) if your transcript processing needs one.

### No signature

**The Problem:** Anyone who learns the header value can post a fake completion notice.

**Why It Happens:** AssemblyAI authenticates with a static header you define, not a signature over the body.

**Workarounds:**

* Use HTTPS, and keep the header value out of logs.
* Treat the webhook as a notification only: fetch the transcript from the API with your API key, and check that the ID belongs to a job you submitted.
* Allowlist `44.238.19.20` and `54.220.25.36`.

**How Hookdeck Can Help:** Hookdeck checks the header on every request before anything is queued for your service, so forged requests never reach your handlers.

### No event types

**The Problem:** One endpoint receiving both pre-recorded and streaming callbacks has nothing to switch on.

**Why It Happens:** AssemblyAI names no events. The body shape is the only signal.

**Workarounds:**

* Use separate paths for pre-recorded and streaming webhooks.
* Or branch on whether the body has `transcript_id` or `session_id`.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on the request path or on which body fields are present, sending each kind of callback to its own destination.

## Best practices

### Set the auth header on every request

Authentication only exists if the request that created the job included `webhook_auth_header_name` and `webhook_auth_header_value`. Put them in the one function your code uses to submit transcripts, so no call site forgets them.

### Fetch, do not trust, the body

The pre-recorded body is only an ID and a status. Use it as a signal to fetch the transcript with your API key. See [handling thin events](/docs/guides/handling-thin-events-with-hookdeck).

### Acknowledge fast and process asynchronously

Return `200` within the 10-second window and do the transcript fetch, summarisation or indexing afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks), and for AI pipelines, [Hookdeck for AI agents and LLMs](/docs/use-cases/ai-agents).

### Deduplicate on the job ID

Retries can repeat a delivery. Key processing on `transcript_id` (with `status`) or `session_id`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use query parameters for correlation

Add your own IDs to `webhook_url` so each delivery tells you which customer or job it belongs to without a database lookup.

## Conclusion

AssemblyAI webhooks are one-off callbacks set per request. Pre-recorded deliveries carry only `transcript_id` and `status`, streaming deliveries carry the finalized turns, and neither is signed, so authentication comes from a header you define with `webhook_auth_header_name` and `webhook_auth_header_value`. Answer within 10 seconds, never let a configuration error turn into a `4xx` that AssemblyAI will not retry, and fetch the transcript by ID.

[Hookdeck Event Gateway](https://hookdeck.com) checks your auth header, answers AssemblyAI immediately, and holds each notice until your service has processed it, so a slow fetch or a misconfiguration does not lose a transcript.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle AssemblyAI webhooks reliably in minutes.