> ## Documentation Index
> Fetch the complete documentation index at: https://assemblyai.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get a signed HTTPS callback when a session or phone call starts, connects, or ends, with the recording and transcript URLs for calls.

Webhooks let your backend react to what your agents do without polling. Register a URL and the events you care about, and AssemblyAI sends a signed `POST` to that URL whenever one fires. For phone calls, the payload includes ready-to-download recording and transcript URLs.

```
Session or call ends ──► AssemblyAI ──POST──► https://your-app.com/webhooks/assemblyai
                                                  │
                                          verify X-AAI-Signature,
                                          then act on the event
```

**Base URL:** `https://agents.assemblyai.com`. Send your API key in the `Authorization` header (a `Bearer ` prefix is accepted), same as the rest of the API.

## Set up a webhook handler

The [reference](#reference) section below covers every field and rule.

<Steps>
  <Step title="Generate a signing secret">
    Every delivery is signed with a secret you choose. Generate one, store it somewhere your handler can read it, and keep it. The API never returns it.

    ```bash theme={null}
    export AAI_WEBHOOK_SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
    echo $AAI_WEBHOOK_SECRET
    ```

    It must be 32 to 256 printable ASCII characters with no whitespace.
  </Step>

  <Step title="Write the handler">
    Your handler does two things: verify the signature, then acknowledge with a `2xx` as fast as possible. Do any slow work after responding.

    <CodeGroup>
      ```python Python theme={null}
      import hashlib
      import hmac
      import os
      import time

      from fastapi import FastAPI, Header, HTTPException, Request

      SECRET = os.environ["AAI_WEBHOOK_SECRET"]
      TOLERANCE_SECONDS = 300

      app = FastAPI()


      def verify(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
          if not signature_header:
              return False
          fields = {}
          for pair in signature_header.split(","):
              key, sep, value = pair.partition("=")
              if sep:
                  fields[key.strip()] = value.strip()
          raw_t, v1 = fields.get("t"), fields.get("v1")
          if not raw_t or not v1:
              return False
          try:
              timestamp = int(raw_t)
          except ValueError:
              return False

          expected = hmac.new(
              secret.encode("utf-8"),
              f"{timestamp}.".encode("utf-8") + raw_body,
              hashlib.sha256,
          ).hexdigest()

          if not hmac.compare_digest(expected, v1):
              return False
          return abs(int(time.time()) - timestamp) <= TOLERANCE_SECONDS


      @app.post("/webhooks/assemblyai")
      async def handle(request: Request, x_aai_signature: str | None = Header(default=None)):
          raw_body = await request.body()
          if not verify(raw_body, x_aai_signature, SECRET):
              raise HTTPException(status_code=401, detail="invalid signature")

          event = await request.json()
          process(event)
          return {"ok": True}


      def process(event: dict) -> None:
          if event["event"].startswith("call."):
              call = event["call"]
              print(event["event"], call["call_id"], call.get("recording_url"))
          else:
              print(event["event"], event["session"]["session_id"])
      ```

      ```javascript Node.js theme={null}
      import crypto from "node:crypto";
      import express from "express";

      const SECRET = process.env.AAI_WEBHOOK_SECRET;
      const TOLERANCE_SECONDS = 300;

      function verify(rawBody, signatureHeader, secret) {
        if (!signatureHeader) return false;
        const fields = Object.fromEntries(
          signatureHeader.split(",").map((pair) => {
            const i = pair.indexOf("=");
            return [pair.slice(0, i).trim(), pair.slice(i + 1).trim()];
          })
        );
        const t = Number.parseInt(fields.t, 10);
        if (!Number.isFinite(t) || !fields.v1) return false;

        const expected = crypto
          .createHmac("sha256", secret)
          .update(`${t}.`)
          .update(rawBody)
          .digest("hex");

        const a = Buffer.from(expected);
        const b = Buffer.from(fields.v1);
        if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
        return Math.abs(Math.floor(Date.now() / 1000) - t) <= TOLERANCE_SECONDS;
      }

      const app = express();

      // express.raw keeps the body as the exact bytes that were signed.
      app.post("/webhooks/assemblyai", express.raw({ type: "application/json" }), (req, res) => {
        if (!verify(req.body, req.get("X-AAI-Signature"), SECRET)) {
          return res.sendStatus(401);
        }
        const event = JSON.parse(req.body.toString("utf8"));
        console.log(event.event, event.call?.call_id ?? event.session?.session_id);
        res.sendStatus(200);
      });

      app.listen(8000);
      ```
    </CodeGroup>

    Run it: `uvicorn receiver:app --port 8000` or `node receiver.mjs`.

    <Warning>
      Verify the **raw request body bytes**, exactly as shown above. If you parse the JSON and re-serialise it before hashing, the bytes differ and every signature fails with `401`. A `401` is not retried, so those events are lost.
    </Warning>
  </Step>

  <Step title="Expose it over HTTPS">
    The subscription URL must be `https://` and publicly reachable; it's checked when you create the subscription. For local development, put [ngrok](https://ngrok.com/docs/getting-started/) in front of your handler:

    ```bash theme={null}
    ngrok http 8000
    ```

    Copy the `https://….ngrok-free.app` URL it prints. In production, use your own HTTPS host.
  </Step>

  <Step title="Create the subscription">
    Register your URL, the events you want, and the secret from step 1. Pass `agent_id` to receive events for one agent, or leave it out for every agent on your account.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://agents.assemblyai.com/v1/webhook-subscriptions \
        -H "Authorization: $ASSEMBLYAI_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "url": "https://your-app.com/webhooks/assemblyai",
          "events": ["call.connected", "call.ended", "call.failed"],
          "secret": "'"$AAI_WEBHOOK_SECRET"'",
          "agent_id": "7ad24396-b822-4dca-871a-be9cc4781cf9"
        }'
      ```

      ```python Python theme={null}
      # pip install requests
      import os
      import requests

      resp = requests.post(
          "https://agents.assemblyai.com/v1/webhook-subscriptions",
          headers={"Authorization": os.environ["ASSEMBLYAI_API_KEY"]},
          json={
              "url": "https://your-app.com/webhooks/assemblyai",
              "events": ["call.connected", "call.ended", "call.failed"],
              "secret": os.environ["AAI_WEBHOOK_SECRET"],
              "agent_id": "7ad24396-b822-4dca-871a-be9cc4781cf9",
          },
      )
      resp.raise_for_status()
      print(resp.json()["id"])
      ```

      ```javascript Node.js theme={null}
      // Node 18+ has fetch built in
      const res = await fetch("https://agents.assemblyai.com/v1/webhook-subscriptions", {
        method: "POST",
        headers: {
          Authorization: process.env.ASSEMBLYAI_API_KEY,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          url: "https://your-app.com/webhooks/assemblyai",
          events: ["call.connected", "call.ended", "call.failed"],
          secret: process.env.AAI_WEBHOOK_SECRET,
          agent_id: "7ad24396-b822-4dca-871a-be9cc4781cf9",
        }),
      });
      const data = await res.json();
      console.log(data.id);
      ```
    </CodeGroup>

    Save the `id` from the response. You need it to update or delete the subscription later.

    <Warning>
      **Phone calls emit `call.*` events only, never `session.*`.** If your agent answers phone calls, subscribe to `call.connected`, `call.ended`, and `call.failed`. A subscription to session events alone receives nothing for telephony. Use `session.started` and `session.completed` for WebSocket sessions.
    </Warning>
  </Step>

  <Step title="Trigger an event and watch it arrive">
    Call your agent's phone number, or start a WebSocket session against it, then end it. Within a few seconds your handler logs the delivery.

    If nothing arrives, check the delivery history. Every attempt is recorded against the session (for a call, the session id is the `call_id`):

    ```bash theme={null}
    curl https://agents.assemblyai.com/v1/sessions/$SESSION_ID/webhook-deliveries \
      -H "Authorization: $ASSEMBLYAI_API_KEY"
    ```

    ```json theme={null}
    {
      "deliveries": [
        {
          "id": "9a641d63-3ecf-497d-8ffa-07bf10c5f009",
          "subscription_id": "cb1f65e4-056e-4075-b3db-311677ce04d8",
          "session_id": "sess_2139906f39a34c598627e8fbb6fb5fe5",
          "status": "delivered",
          "attempt_count": 1,
          "http_status_code": 200,
          "error": null,
          "delivered_at": "2026-09-16T10:27:28.104000Z",
          "next_retry_at": null,
          "created_at": "2026-09-16T10:27:27.887017Z"
        }
      ],
      "has_more": false
    }
    ```

    A `status` of `failed` with `"error": "HTTP 401: invalid signature"` means your handler rejected it, usually because of the raw-bytes problem from step 2 or a secret mismatch. See [Troubleshooting](#troubleshooting) for the rest.
  </Step>

  <Step title="Use the event">
    What you do next depends on the channel:

    * **Phone calls:** `call.ended` includes `recording_url` and `transcript_url`. They're presigned, work immediately, and **expire one hour after the event**, so download the files rather than storing the URLs. `call.connected` has both set to `null`.
    * **WebSocket sessions:** `session.completed` fires when the session ends, *before* the recording and transcript are written (`s3_prefix` is `null` in the payload). They typically appear within about 90 seconds. Fetch them with `GET /v1/sessions/{session_id}` from the [Sessions API](/docs/voice-agents/voice-agent-api/session-history), polling every few seconds until `artifacts` is non-empty.

    <Tip>
      If you only need metadata (duration, close reason, agent, phone numbers), it is already in the webhook body. No follow-up request is needed.
    </Tip>
  </Step>
</Steps>

## Before you go to production

* **Deduplicate on `event_id`.** Delivery is at-least-once, and the same event can arrive more than once. `event_id` is stable across retries; `X-AAI-Delivery-Id` changes per attempt, so don't dedupe on it.
* **Respond within 15 seconds.** Return `2xx` first, do slow work after. A slow handler gets retried, which means duplicates.
* **Know what isn't retried.** Any `4xx` other than `408`, `425`, and `429` is treated as your endpoint rejecting the event and is not retried. `5xx`, timeouts, and connection errors are.
* **Don't rely on ordering.** `call.connected` is not guaranteed to arrive before `call.ended`.
* **Register the final URL.** Redirects are not followed; a `301` counts as a failure.
* **Rotate secrets with `PATCH`.** Sending a new `secret` bumps `secret_version`. Deliveries already in flight were signed with the old one, so accept both during the changeover.

## Reference

### Events

| Event | Fires when | Payload object | Channel |
| - | - | - | - |
| `session.started` | A WebSocket session begins. | `session` | WebSocket |
| `session.completed` | A WebSocket session ends. | `session` | WebSocket |
| `call.connected` | A phone call is answered by an agent. | `call` | Telephony |
| `call.ended` | A phone call finishes. | `call` | Telephony |
| `call.failed` | A phone call could not be placed or answered. | `call` | Telephony |

### Delivery headers

| Header | Example | Purpose |
| - | - | - |
| `X-AAI-Signature` | `t=1789554447,v1=b209cd2c…` | Timestamped HMAC. See [Signature](#signature). |
| `X-AAI-Event` | `call.ended` | The event type, for routing without parsing the body. |
| `X-AAI-Delivery-Id` | `2f45e3eb-c70e-4dff-8ae1-a6e7560acdf9` | Identifies this delivery **attempt**. Changes on every retry. |
| `User-Agent` | `AssemblyAI-Webhook-Delivery/1.0` | |

### Signature

```
X-AAI-Signature: t=1789554447,v1=b209cd2cb55dc9afa6bb182fa165cfc299c71669b867fe2f1507f3ff11082a16
```

* `t`: Unix timestamp in seconds when the delivery was signed.
* `v1`: hex `HMAC-SHA256`, keyed with your subscription secret, over the string `"{t}."` followed by the raw request body bytes.

Recompute `v1`, compare in constant time, and reject any delivery whose `t` is more than 300 seconds from your clock. The timestamp is what stops a captured request being replayed.

### Payloads

**Session events** carry a `session` object:

```json theme={null}
{
  "event_id": "bf36882c-71ac-41b8-839d-48a24bd3169e",
  "event": "session.completed",
  "timestamp": "2026-09-16T10:27:27.887017+00:00",
  "session": {
    "session_id": "sess_2139906f39a34c598627e8fbb6fb5fe5",
    "account_id": 123456,
    "agent_id": "3ee4f5fa-0a5a-41f8-abb1-3fa50fe253fd",
    "status": "completed",
    "duration_seconds": 10.150365,
    "public_close_reason": "client_end",
    "s3_prefix": null,
    "created_at": "2026-09-16T10:27:17.391140Z",
    "ended_at": "2026-09-16T10:27:27.541000Z"
  }
}
```

`session.started` has the same shape with `"status": "created"` and `duration_seconds`, `ended_at`, and `public_close_reason` set to `null`.

**Call events** carry a `call` object:

```json theme={null}
{
  "event_id": "df5cb35c-b02d-49f2-8420-f77fee999c8f",
  "event": "call.ended",
  "timestamp": "2026-09-16T10:46:41.770690+00:00",
  "call": {
    "call_id": "call_c03900346e334c7c8420fd7aac3ce29c",
    "session_id": "call_c03900346e334c7c8420fd7aac3ce29c",
    "account_id": 123456,
    "agent_id": "7ad24396-b822-4dca-871a-be9cc4781cf9",
    "status": "ended",
    "direction": "inbound",
    "from_number": "+15555550123",
    "to_number": "+15555550199",
    "created_at": "2026-09-16T10:45:45.169858",
    "ended_at": "2026-09-16T10:46:41.397419",
    "recording_url": "https://…/recording/audio.ogg?…&Expires=1789559201",
    "transcript_url": "https://…/timeline/timeline.json?…&Expires=1789559201"
  }
}
```

For a phone call, `call_id` and `session_id` are the same value. `from_number` is the caller's real number, so treat the payload as personal data.

### Subscription fields

| Field | Required | Notes |
| - | - | - |
| `url` | Yes | Where deliveries are POSTed. Must be `https://` and resolve in public DNS. Private and loopback addresses are rejected. |
| `events` | Yes | One or more of the [events](#events) above. No duplicates. |
| `secret` | Yes | 32 to 256 printable ASCII characters, no whitespace. Never returned; the response carries a `secret_version` integer that increments on rotation. |
| `agent_id` | No | Scope to one agent. Omit for account-wide events. |
| `enabled` | No | Defaults to `true`. Set `false` to pause deliveries without deleting the subscription. |

### Manage subscriptions

<CodeGroup>
  ```bash cURL expandable theme={null}
  # List (paginated, newest first). Paused subscriptions are hidden unless you add ?include_disabled=true.
  curl "https://agents.assemblyai.com/v1/webhook-subscriptions?include_disabled=true" -H "Authorization: $ASSEMBLYAI_API_KEY"

  # Retrieve one
  curl https://agents.assemblyai.com/v1/webhook-subscriptions/$SUBSCRIPTION_ID -H "Authorization: $ASSEMBLYAI_API_KEY"

  # Update: change the URL or events, pause, or rotate the secret (bumps secret_version)
  curl -X PATCH https://agents.assemblyai.com/v1/webhook-subscriptions/$SUBSCRIPTION_ID \
    -H "Authorization: $ASSEMBLYAI_API_KEY" -H "Content-Type: application/json" \
    -d '{ "enabled": false }'

  # Delete (also removes its delivery history)
  curl -X DELETE https://agents.assemblyai.com/v1/webhook-subscriptions/$SUBSCRIPTION_ID -H "Authorization: $ASSEMBLYAI_API_KEY"
  ```

  ```python Python expandable theme={null}
  # pip install requests
  import os
  import requests

  base = "https://agents.assemblyai.com/v1/webhook-subscriptions"
  headers = {"Authorization": os.environ["ASSEMBLYAI_API_KEY"]}
  subscription_id = os.environ["SUBSCRIPTION_ID"]

  # List (paginated, newest first). Paused subscriptions are hidden unless include_disabled=true.
  resp = requests.get(base, headers=headers, params={"include_disabled": "true"})
  resp.raise_for_status()
  print(resp.json()["subscriptions"])

  # Retrieve one
  resp = requests.get(f"{base}/{subscription_id}", headers=headers)
  resp.raise_for_status()
  print(resp.json())

  # Update: change the URL or events, pause, or rotate the secret (bumps secret_version)
  resp = requests.patch(f"{base}/{subscription_id}", headers=headers, json={"enabled": False})
  resp.raise_for_status()
  print(resp.json())

  # Delete (also removes its delivery history)
  resp = requests.delete(f"{base}/{subscription_id}", headers=headers)
  resp.raise_for_status()
  ```

  ```javascript Node.js expandable theme={null}
  // Node 18+ has fetch built in
  const base = "https://agents.assemblyai.com/v1/webhook-subscriptions";
  const auth = { Authorization: process.env.ASSEMBLYAI_API_KEY };
  const subscriptionId = process.env.SUBSCRIPTION_ID;

  // List (paginated, newest first). Paused subscriptions are hidden unless include_disabled=true.
  let res = await fetch(`${base}?include_disabled=true`, { headers: auth });
  console.log((await res.json()).subscriptions);

  // Retrieve one
  res = await fetch(`${base}/${subscriptionId}`, { headers: auth });
  console.log(await res.json());

  // Update: change the URL or events, pause, or rotate the secret (bumps secret_version)
  res = await fetch(`${base}/${subscriptionId}`, {
    method: "PATCH",
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({ enabled: false }),
  });
  console.log(await res.json());

  // Delete (also removes its delivery history)
  res = await fetch(`${base}/${subscriptionId}`, { method: "DELETE", headers: auth });
  ```
</CodeGroup>

`PATCH` accepts any of `url`, `events`, `secret`, and `enabled`.

### Delivery history

```bash theme={null}
# Every attempt for a session (for a call, use the call_id)
curl https://agents.assemblyai.com/v1/sessions/$SESSION_ID/webhook-deliveries \
  -H "Authorization: $ASSEMBLYAI_API_KEY"

# Only the most recent attempt per subscription
curl https://agents.assemblyai.com/v1/sessions/$SESSION_ID/webhook-deliveries/latest \
  -H "Authorization: $ASSEMBLYAI_API_KEY"
```

Each entry has `status` (`pending`, `delivered`, or `failed`), `attempt_count`, `http_status_code`, `error`, `delivered_at`, and `next_retry_at`. Deleting a subscription removes its entries.

### Troubleshooting

| Symptom | Likely cause |
| - | - |
| Every delivery `failed` with `HTTP 401: invalid signature` | Hashing a re-serialised body instead of the raw bytes, verifying without the `t.` prefix, or a secret mismatch between the subscription and your handler |
| Intermittent `401` | Handler clock skew beyond the 300-second tolerance. Check NTP. |
| Nothing arrives for phone calls | Subscribed to `session.*` only. Telephony emits `call.*`. |
| Nothing arrives at all | Subscription paused, scoped to a different `agent_id`, or the URL redirects |
| Duplicate events in your system | Not deduplicating on `event_id` |
| Occasional failures under load | Handler exceeding the 15-second read timeout |

### Endpoints

| Method | Path | Description |
| - | - | - |
| `POST` | `/v1/webhook-subscriptions` | [Create a subscription](/docs/api-reference/voice-agent-api/create-webhook-subscription). |
| `GET` | `/v1/webhook-subscriptions` | [List your subscriptions](/docs/api-reference/voice-agent-api/list-webhook-subscriptions). |
| `GET` | `/v1/webhook-subscriptions/{id}` | [Retrieve one](/docs/api-reference/voice-agent-api/get-webhook-subscription). |
| `PATCH` | `/v1/webhook-subscriptions/{id}` | [Update](/docs/api-reference/voice-agent-api/update-webhook-subscription) `url`, `events`, `secret`, or `enabled`. |
| `DELETE` | `/v1/webhook-subscriptions/{id}` | [Delete](/docs/api-reference/voice-agent-api/delete-webhook-subscription). |
| `GET` | `/v1/sessions/{session_id}/webhook-deliveries` | Every delivery attempt for a session. |
| `GET` | `/v1/sessions/{session_id}/webhook-deliveries/latest` | The most recent attempt per subscription. |
