---
name: unified-meeting-bots
description: >-
  Build a meeting bot or AI notetaker on Zoom, Microsoft Teams, Webex and Google
  Meet through the Unified.to Calendar & Meetings API: pick the meeting-bot
  integration (zoommeetingbot, microsoftteamsmeetingbot, webexmeetingbot,
  googlemeetmeetingbot), connect it,
  create a calendar_bot from a meeting_url or a calendar_event (event_id),
  subscribe to calendar_botevent webhooks (status, participant, transcript,
  chat and recording-ready events), verify sig256, and read the recording via
  recording_id on the same connection. Covers each platform's lifecycle and
  statuses, the 501 matrix, the Zoom recurring-meeting rule, limits, and
  troubleshooting. Use when a task mentions a meeting bot, notetaker, live
  captions, calendar_bot or calendar_botevent. For registering the vendor app
  itself, load zoom-meeting-bot-app, microsoft-teams-meeting-bot-app,
  webex-meeting-bot-app or google-meet-meeting-bot-app.
license: MIT
metadata:
  category: calendar
  api_base: https://api.unified.to
  docs: https://docs.unified.to/calendar/bot/model
---

# Build a meeting bot / AI notetaker with Unified.to

Unified.to's meeting bots give you **one object for "follow this meeting"** (`calendar_bot`) and **one event stream
for what happens in it** (`calendar_botevent`), across Zoom, Microsoft Teams, Webex and Google Meet, delivered to
your webhook.

Three things about this API cost more than they look:

- **Meeting bots are separate integrations.** `zoommeetingbot`, `microsoftteamsmeetingbot` and `webexmeetingbot` are
  not the `zoom`, `microsoftteams` and `webex` integrations, and `googlemeetmeetingbot` is not `googlemeet`. A
  regular Zoom, Teams, Webex or Google Meet connection **cannot
  create bots**; the end user connects the meeting-bot integration on its own, and existing connections are not
  touched.
- **No bot appears in the call, and Unified records nothing itself.** Zoom follows the meeting live through Zoom's
  Realtime Media Streams (RTMS); Teams is reported from Microsoft Graph **after** the meeting; Webex and Google
  Meet are event monitors (Webex webhooks; Google Workspace Events via Pub/Sub). Recordings and transcripts come from the meeting platform.
- **Bot events are webhook-only.** There is no REST endpoint to list `calendar_botevent`s. If the native
  `calendar_botevent` webhook isn't there before the meeting, the events are gone.

Most meeting features don't need a bot at all: if customers already record meetings, read them with
`calendar_recording` on the regular integrations (see `unified-calendar`). Use a bot when you need live captions and
activity (Zoom), or a per-meeting record of status, participants and recording readiness.

## When to use this skill

- Build an AI notetaker, live-caption feature, or meeting-activity tracker on Zoom, Teams, Webex or Google Meet
- Create, list, rename or delete `calendar_bot`s, or handle `calendar_botevent` webhooks
- Get the recording and transcript of a meeting a bot followed
- Work out why a bot is stuck in `READY` or `CALL_ENDED`, or why a create returns `400`, `404`, `409`, `422` or `501`

## Built on

- **`unified-quickstart`** — workspace API key, activating integrations, the Authorization component, storing
  `connection_id`s. This skill doesn't repeat it.
- **`unified-calendar`** — `calendar_event` and `calendar_recording`, which the bot connection also exposes.
- **`unified-debug-webhooks-connections`** — webhook health, retries, and `sig256` debugging.
- **The vendor-app skill for each platform** — `zoom-meeting-bot-app`, `microsoft-teams-meeting-bot-app`,
  `webex-meeting-bot-app`, `google-meet-meeting-bot-app`. Every meeting-bot integration needs **your own** vendor app; Unified has no shared
  credentials for them.

## Inputs to collect before you start

Ask in one batch. Never invent.

| Input | Notes |
| --- | --- |
| **Which platforms** (Zoom, Teams, Webex, Google Meet) | Each is its own integration, vendor app and connection (§1) |
| **Live or after the meeting?** | Only Zoom delivers captions and activity during the meeting (§1, §6) |
| **Is the vendor app set up?** | If not, run the platform's setup skill first (§2) |
| **Workspace API key and workspace secret** | Secret verifies `sig256` on every delivery (§7) |
| **Region** | `api.unified.to` (US), `api-eu.unified.to` (EU), `api-au.unified.to` (AU) |
| **Public webhook URL** | ngrok / Cloudflare Tunnel in development (§5) |
| **Bot from a join link or from calendar events?** | `meeting_url` or `event_id`; listing events to find the id needs `calendar_event_read` (§3, §4) |
| **Need the recording?** | Needs `calendar_recording_read`, and recording turned on in the platform (§8) |
| **Recurring meetings?** | One bot per occurrence; Zoom has an ordering rule (§10) |

## Quick Start

1. Choose the meeting-bot integration(s) and set up each vendor app with its setup skill (§1, §2).
2. Activate the integration in the workspace and select the bot permissions (§3).
3. Have the end user authorize it and store the `connection_id` (§3).
4. Create a **native** `calendar_botevent` / `created` webhook on the connection **before the meeting** (§5).
5. Create a `calendar_bot` with `meeting_url` or `event_id` (§4).
6. Handle events: verify `sig256`, respond fast, and make handlers idempotent (§6, §7).
7. On `RECORDING_READY`, read the bot's `recording_id` and get the `calendar_recording` on the **same connection**
   (§8).
8. Test end to end against a real meeting on each platform (§11).

## Product state (from the TICKET-3338 integration PRs — re-verify before trusting)

- **Not launched yet.** `calendar_bot` and `calendar_botevent` are in the docs with a "coming soon" note; the three
  integrations ship with the meeting-bot launch.
- **Integrations:** `zoommeetingbot` (Zoom RTMS session runner), `microsoftteamsmeetingbot` (post-meeting Graph
  monitor), `webexmeetingbot` (Webex webhook monitor), `googlemeetmeetingbot` (Google Workspace Events monitor,
  unified-api #10641, reported not yet live-tested).
- **The only transcription provider is `MEETING_CAPTIONS`** (the platform's own captions). There are no audio or
  video frame events and no partial transcripts: every transcript event has `is_final: true`.
- **Unsupported features return `501`** with `Not supported by this integration: …`; invalid input returns `400`, an
  unknown meeting or event `404`, and a meeting Unified can't resolve `422` (§9).

If the API does not behave like this, stop and report what you actually see.

## 1. Choose the platform integration

| | Zoom (meeting bot) | Microsoft Teams (meeting bot) | Webex (meeting bot) | Google Meet (meeting bot) |
| --- | --- | --- | --- | --- |
| Integration type | `zoommeetingbot` | `microsoftteamsmeetingbot` | `webexmeetingbot` | `googlemeetmeetingbot` |
| How it follows the meeting | Live media session (Zoom RTMS) plus Zoom recording events | Microsoft Graph, after the meeting | Webex webhooks | Google Workspace Events subscription on the user, via Pub/Sub in your project |
| When data arrives | During the meeting; recording after | After the meeting (call records ~30 min on average, up to 150; recordings and transcripts up to 240 min) | Participants live; recording and transcript after | Start/end, participants and recording start/stop live; recording and transcript after |
| Events | Status, participants (joined, left, speaking, webcam, screen share), captions, chat, recording ready | Status, participants joined/left, recording ready, transcript | Status, participants joined/left, recording ready, transcript | Status, participants joined/left, recording started/stopped, recording ready, transcript |
| Statuses reached | `READY`, `IN_CALL_RECORDING`, `CALL_ENDED`, `DONE`, `FATAL` | `READY`, `CALL_ENDED`, `DONE` | `READY`, `IN_CALL_NOT_RECORDING`, `CALL_ENDED` | `READY`, `IN_CALL_NOT_RECORDING`, `IN_CALL_RECORDING`, `CALL_ENDED` |
| `recording_config` | `record_transcript`, `record_chat`, `MEETING_CAPTIONS` | `501` | `501` | `501` |
| Updatable fields | `name`, `event_id`, `recording_config` | `name` | `name` | `name` |
| Your own vendor app | Zoom General app with RTMS, plus its **Secret Token** | Multitenant Entra app with tenant admin consent (`CallRecords.Read.All` and admin-only delegated permissions) | Webex integration | Google OAuth client, plus a service account with Pub/Sub admin rights on the client's project |
| Setup skill | `zoom-meeting-bot-app` | `microsoft-teams-meeting-bot-app` | `webex-meeting-bot-app` | `google-meet-meeting-bot-app` |

**Live captions during the meeting → Zoom only.** On Teams nothing arrives until the meeting is over; on Webex and
Google Meet only meeting activity (start/end, participants, and on Meet recording start/stop) arrives live.
**Google Meet bots only follow meetings the connected user owns**; others stay `READY`.

### Objects on the same connection

A bot connection also inherits its parent integration's meeting objects, so `event_id` and `recording_id` resolve on
the **same** `connection_id`; you never need a second connection:

| Integration | Also on the bot connection |
| --- | --- |
| Zoom (meeting bot) | `calendar_calendar`, `calendar_event`, `calendar_recording`, `calendar_webinar`, `calendar_link`, `calendar_location`, `calendar_room`, `uc_contact` |
| Microsoft Teams (meeting bot) | `calendar_calendar`, `calendar_event`, `calendar_busy`, `calendar_webinar`, `calendar_recording`, `uc_call`, `uc_recording` |
| Webex (meeting bot) | `calendar_calendar`, `calendar_event`, `calendar_recording`, `calendar_webinar` |
| Google Meet (meeting bot) | `calendar_calendar`, `calendar_event`, `calendar_busy`, `calendar_recording` |

They behave as on the parent integration, and their scopes are requested only when you select their permissions. Their
webhooks work alongside the bot webhook: Webex `calendar_event` and `calendar_recording` have native webhooks; Teams
`calendar_event` and `uc_call` have native webhooks and `calendar_recording` has none (use `RECORDING_READY`); Zoom's inherited
objects have virtual webhooks; Webex `calendar_webinar` has virtual webhooks only. Google Meet's inherited objects'
webhooks are handled exactly as on the Google Meet integration.

## 2. Set up the vendor app (once per workspace)

Each integration needs your own app in the meeting platform. Load the platform skill and finish it before you write
bot code:

- **Zoom** → `zoom-meeting-bot-app`: user-managed General app with RTMS scopes, the **Event
  Subscription** (RTMS started in Meeting, `recording.completed`, `recording.transcript_completed`) pointed at
  `https://api.unified.to/webhook/workspace/zoommeetingbot?workspace_id=<WORKSPACE_ID>`, and the app's **Secret
  Token** entered on the integration in Unified. Without the Secret Token, creating a bot returns `400`.
- **Teams** → `microsoft-teams-meeting-bot-app`: your own multitenant Entra app (Unified has no shared credentials),
  delegated Graph permissions, plus the **application** permission `CallRecords.Read.All`. A tenant admin must
  consent in **every customer tenant**: to `CallRecords.Read.All`, and to the recording, transcript and AI-insight
  delegated permissions, which users can't approve alone.
- **Webex** → `webex-meeting-bot-app`: your own Webex Integration with the four `meeting:*_read` scopes.
- **Google Meet** → `google-meet-meeting-bot-app`: your own Google OAuth client, the Meet REST, Workspace Events and
  Pub/Sub APIs in the OAuth client's Cloud project (Google requires the topic and the OAuth client to share a
  project), and a service account with Pub/Sub admin permissions there. The project ID, the service-account email and
  the private key each have their own field on the integration in Unified.

## 3. Activate, choose permissions, connect

1. In `app.unified.to` → **Integrations**, activate the meeting-bot integration and enter the vendor app's client ID
   and secret (plus the Secret Token for Zoom).
2. Request these Unified permissions in the Authorization component or authorization link:

| Permission | Needed for |
| --- | --- |
| `calendar_bot_read` | List and get bots |
| `calendar_bot_write` | Create, update, delete bots |
| `calendar_botevent_read` | Receive bot events (the webhook) |
| `calendar_event_read` | List events to find an `event_id` (creating from an `event_id` only needs `calendar_bot_write`, which carries the event-read scope) |
| `calendar_recording_read` | Read the recording a bot points to |

3. The end user authorizes **Zoom (meeting bot)**, **Microsoft Teams (meeting bot)** or **Webex (meeting bot)** or **Google Meet (meeting bot)** —
   not the regular integration. Store the `connection_id`. Teams needs a delegated work or school account;
   app-only connections aren't supported.

## 4. Create the bot

`POST /calendar/{connection_id}/bot` with **either** `meeting_url` **or** `event_id`:

```bash
# From a join link
curl -X POST "https://api.unified.to/calendar/$CONNECTION_ID/bot" \
  -H "Authorization: Bearer $UNIFIED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Notetaker", "meeting_url": "https://us02web.zoom.us/j/81234567890" }'

# From a calendar event on the same connection
curl "https://api.unified.to/calendar/$CONNECTION_ID/event?start_gte=2026-11-07T00:00:00Z" \
  -H "Authorization: Bearer $UNIFIED_API_KEY"
curl -X POST "https://api.unified.to/calendar/$CONNECTION_ID/bot" \
  -H "Authorization: Bearer $UNIFIED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_id": "<EVENT_ID>" }'
```

| Integration | `calendar_event` ids are | `meeting_url` must be |
| --- | --- | --- |
| Zoom (meeting bot) | Zoom meeting IDs | a Zoom join link (`https://…zoom.us/j/…`) |
| Microsoft Teams (meeting bot) | Outlook calendar event IDs; the event must have a Teams join link | a Teams join link (`https://teams.microsoft.com/…`) |
| Webex (meeting bot) | Webex meeting IDs | the meeting's Webex link |
| Google Meet (meeting bot) | Google Calendar event IDs; the event must have a Meet link (`conferenceData` or `hangoutLink`) | a `https://meet.google.com/…` link (looked up by meeting code) |

The response is the bot, usually `READY`. A Webex bot created from an `event_id` that is a running meeting instance
(`meetingType` `meeting`, state `inProgress`), or a Google Meet bot created during a live conference, starts in
`IN_CALL_NOT_RECORDING`. A Webex bot created from a `meeting_url` always starts in `READY`. **One unfinished bot per meeting link per connection**: a create returns `409` while an
earlier bot for exactly the same link is still active (not `CALL_ENDED`, `DONE` or `FATAL`).

Google Meet `event_id`s are this connection's `calendar_event` ids (an encoded calendar + event id); a bare Google
event id is looked up on the `primary` calendar only.

### `calendar_bot` fields

| Field | Input / read-only | Notes |
| --- | --- | --- |
| `name` | input | Updatable on every integration (see §9 for what else each accepts) |
| `meeting_url` | input | Can't change after creation (Zoom and Webex return `400`; Teams ignores it) |
| `event_id` | input | A `calendar_event` id on the same connection |
| `recording_config` | input, **Zoom only** | `record_transcript` (default on), `record_chat` (default off), `transcription.provider: "MEETING_CAPTIONS"` |
| `status`, `status_changes[]` | read-only | `status_changes` items: `status`, `changed_at`, `sub_code`, `message` |
| `started_at`, `ended_at` | read-only | When the bot started / stopped following |
| `meeting_metadata` | read-only | `title`, `platform`, `host_name`, `host_email`, `meeting_identifier`, `started_at`, `ended_at`, `participant_count` (as the platform provides) |
| `participants[]` | read-only | `id`, `name`, `email`, `is_host`, `joined_at`, `left_at`, … |
| `recording_id` | read-only | The `calendar_recording` id, once ready |

Read-only fields you send are ignored. Model: https://docs.unified.to/calendar/bot/model

### Managing bots

```bash
# List (filters: meeting_url, event_id, status, limit, offset)
curl "https://api.unified.to/calendar/$CONNECTION_ID/bot?status=READY" -H "Authorization: Bearer $UNIFIED_API_KEY"
# Rename
curl -X PUT "https://api.unified.to/calendar/$CONNECTION_ID/bot/$BOT_ID" \
  -H "Authorization: Bearer $UNIFIED_API_KEY" -H "Content-Type: application/json" -d '{ "name": "Sales notetaker" }'
# Delete (stops following the meeting)
curl -X DELETE "https://api.unified.to/calendar/$CONNECTION_ID/bot/$BOT_ID" -H "Authorization: Bearer $UNIFIED_API_KEY"
```

Deleting a Zoom bot closes the live stream and sends `BOT_STATUS_CHANGE` → `FATAL` (`sub_code` `user_cancelled`)
first. Deleting a Teams, Webex or Google Meet bot sends no event. Later reads return `404`.

## 5. Subscribe to bot events

One **native** webhook per connection, object `calendar_botevent`, event `created` (each bot event is a new object, so
`created` is the only event). Create it **before the meeting starts**, on every platform: on Teams, Graph sends a
transcript notification only if the subscription existed before transcription started. On Teams, also create the bot
before the meeting ends: only `READY` bots created before the call record's end time are matched.

```ts
await fetch('https://api.unified.to/unified/webhook', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.UNIFIED_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
        connection_id: process.env.CONNECTION_ID,
        hook_url: process.env.PUBLIC_WEBHOOK_URL + '/bot-events',
        object_type: 'calendar_botevent',
        event: 'created',
        webhook_type: 'native',
    }),
});
```

Every event for every bot on that connection goes to this webhook. Creating it is what wires up the platform side:
Teams gets three Graph subscriptions (call records app-only, recordings and transcripts delegated) renewed
automatically (Unified gives them a 58-minute expiry, under Graph's 1-hour limit without lifecycle notifications); Webex gets six Webex webhooks, each with its own random
signing secret; Google Meet gets a Pub/Sub topic, publisher IAM binding, push subscription and a Workspace Events subscription
on the user in your project (renewed every 7 days by deleting and recreating them, and events in that gap are lost);
Zoom uses the app's Event Subscription from §2. Webhook creation is all-or-nothing: if a platform subscription fails
(for example Teams without `CallRecords.Read.All` admin consent), creating the webhook fails. Google Meet returns
`409` for a second `calendar_botevent` webhook on a connection, or when the same Google user already has an events
subscription through another connection.

You cannot poll for bot events. You **can** read a bot's current `status`, `status_changes` and `participants` at any
time.

## 6. Lifecycle and events

| Status | Meaning |
| --- | --- |
| `READY` | Created, waiting for the meeting |
| `IN_CALL_NOT_RECORDING` | Meeting running; bot following without capture (Webex, Google Meet while not recording) |
| `IN_CALL_RECORDING` | Connected to the live stream, receiving captions (Zoom); the meeting is being recorded (Google Meet) |
| `CALL_ENDED` | The meeting ended |
| `DONE` | Ended and its recording is ready (Zoom, Teams) |
| `FATAL` | Stopped on an error or because you deleted it; `sub_code` says why |

- **Zoom:** `READY` → `IN_CALL_RECORDING` (RTMS starts) → `CALL_ENDED` (stream ends) → `DONE` (`sub_code`
  `recording_ready`). No cloud recording → stays `CALL_ENDED`. `FATAL` sub-codes: `rtms_connection_lost`,
  `session_failed`, `user_cancelled`.
- **Teams:** `READY` → `CALL_ENDED` (call record arrives) → `DONE` (once it has both call record and recording, in
  either order). No recording → stays `CALL_ENDED`.
- **Webex:** `READY` → `IN_CALL_NOT_RECORDING` → `CALL_ENDED`. **Never `DONE`**: the `meetings` ended notification
  doesn't say whether a recording will follow, so watch for `RECORDING_READY`.
- **Google Meet:** `READY` → `IN_CALL_NOT_RECORDING` (conference started) ⇄ `IN_CALL_RECORDING` (recording started /
  stopped) → `CALL_ENDED`. **Never `DONE`**, as on Webex. A bot binds to the first conference in its meeting space.

`JOINING_CALL`, `IN_WAITING_ROOM`, `RECORDING_PERMISSION_ALLOWED` and `RECORDING_PERMISSION_DENIED` are in the model
but unused at launch. Every transition is also a `BOT_STATUS_CHANGE` event and appended to `status_changes`.

| Event type | Context field | Zoom | Teams | Webex | Google Meet |
| --- | --- | --- | --- | --- | --- |
| `BOT_STATUS_CHANGE` | `status_change` | live | after | live | live |
| `PARTICIPANT_JOINED` / `PARTICIPANT_LEFT` | `participant` | live | after | live | live |
| `PARTICIPANT_SPEECH_ON` | `participant.speaking` | live | — | — | — |
| `PARTICIPANT_WEBCAM_ON` / `_OFF` | `participant.webcam_on` | live | — | — | — |
| `PARTICIPANT_SCREENSHARE_ON` / `_OFF` | `participant.screenshare_on` | live | — | — | — |
| `TRANSCRIPT_RECEIVED` | `transcript` | live captions | after | after (one per snippet) | after (one per entry) |
| `CHAT_MESSAGE_RECEIVED` | `chat_message` | live, with `record_chat` | — | — | — |
| `RECORDING_READY` | none — read the bot's `recording_id` | after (again when the transcript file completes) | after | after | after |
| `RECORDING_STARTED` / `RECORDING_STOPPED` | none | — | — | — | live |

`PARTICIPANT_UPDATED`, `PARTICIPANT_SPEECH_OFF` and `TRANSCRIPT_PARTIAL` are in the model but not sent at launch;
`RECORDING_STARTED` / `RECORDING_STOPPED` are sent only by Google Meet. Event fields: `id`, `created_at`, `bot_id`, `type`, the context object, and
`raw` (the platform's payload). Event `id`s can repeat for distinct events — Google Meet status-change ids are
`{bot}:status:{STATUS}`, so a second recording start or stop reuses an id — so don't drop events on `id` alone; make
handlers idempotent on what they change (for example the bot's current `status`).

Model: https://docs.unified.to/calendar/botevent/model

## 7. Receive and verify events

```json
{
  "data": [{ "id": "evt_123", "bot_id": "6730f1c2a9e4b5d1c0a1b2c3", "type": "TRANSCRIPT_RECEIVED",
             "transcript": { "text": "Let's start with the roadmap.", "participant": { "name": "Jane Doe" }, "is_final": true } }],
  "webhook": { "object_type": "calendar_botevent", "event": "created" },
  "type": "NATIVE",
  "nonce": "...",
  "sig256": "..."
}
```

`sig256 = base64(HMAC-SHA256(workspace_secret, serialized(data) + nonce))`. Verify only `data + nonce`, compare
timing-safely, and ignore the legacy `sig` field.

```ts
import express from 'express';
import crypto from 'crypto';

const app = express();
app.use(express.json());

function verify(env: { data: unknown; nonce: string; sig256: string }, secret: string) {
    if (!env?.sig256 || !env?.nonce || env?.data == null) return false;
    const want = crypto.createHmac('sha256', secret)
        .update(JSON.stringify(env.data), 'utf8').update(String(env.nonce), 'utf8').digest('base64');
    const a = Buffer.from(want), b = Buffer.from(env.sig256);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/bot-events', (req, res) => {
    if (!verify(req.body, process.env.UNIFIED_WORKSPACE_SECRET!)) return res.status(401).send('bad signature');
    res.send('ok'); // respond first: deliveries time out after 5 seconds
    for (const ev of req.body.data ?? []) {
        // Don't drop events on ev.id alone: ids can repeat (§6). Make the work idempotent instead.
        queue.push(ev); // BOT_STATUS_CHANGE, PARTICIPANT_*, TRANSCRIPT_RECEIVED, CHAT_MESSAGE_RECEIVED, RECORDING_READY
    }
});
```

**Respond fast.** A delivery times out after 5 seconds, and a webhook that keeps failing is marked unhealthy and
skipped — events sent meanwhile are missed. Do summaries and CRM writes after responding. See
`unified-debug-webhooks-connections` for webhook health.

## 8. Read the recording and transcript

On `RECORDING_READY`, get the bot, then the recording **on the same connection** (needs `calendar_recording_read`):

```bash
curl "https://api.unified.to/calendar/$CONNECTION_ID/bot/$BOT_ID" -H "Authorization: Bearer $UNIFIED_API_KEY"
curl "https://api.unified.to/calendar/$CONNECTION_ID/recording/$RECORDING_ID" -H "Authorization: Bearer $UNIFIED_API_KEY"
```

- **Zoom:** a second `RECORDING_READY` arrives when Zoom finishes the transcript file — read the recording again then.
  Recordings need cloud recording on the host's account (Pro or higher). Getting a Zoom recording by id depends on
  unified-api #10639; until it ships, list recordings by date and match the id.
- **Teams:** `TRANSCRIPT_RECEIVED` events carry the meeting transcript; each event's `transcript.id` is the
  `calendar_recording` id. Recordings and transcripts attach only for scheduled meetings the **connected user organizes** (not
  private-channel meetings).
- **Webex:** transcript arrives as one `TRANSCRIPT_RECEIVED` per snippet after the meeting (Unified asks for 100
  snippets per page, for up to 50 pages). Webex returns only recordings of meetings hosted by or shared with the
  connected user; a recording can take up to 24 hours. Cloud recording and transcripts aren't on the Free plan, and
  transcripts aren't available on Webex for Government.
- **Google Meet:** `recording_id` is the Meet **conference record** id; the transcript arrives as one
  `TRANSCRIPT_RECEIVED` per transcript entry (up to 50 pages × 100 entries). Artifacts exist only if someone records/transcribes or the organizer
  has auto-recording on, and depend on the Workspace edition and Drive (`google-meet-oauth-app` §4).

If you also subscribe to `calendar_recording` webhooks (where they exist), expect two notifications for the same
recording: that webhook fires for every meeting, `RECORDING_READY` only for meetings with a bot.

## 9. What returns 501 (and 400, 404, 409, 422)

| Request | Zoom | Teams | Webex | Google Meet |
| --- | --- | --- | --- | --- |
| `join_at` (scheduled join) | `501` | `501` | `501` | `501` |
| Any `recording_config` (even `{}`) | allowed | `501` | `501` | `501` |
| `record_audio`, `record_video`, `record_screenshare` | `501` | `501` | `501` | `501` |
| `transcription.provider` other than `MEETING_CAPTIONS` (fails validation) | `400` | `400` | `400` | `400` |
| `meeting_url` that isn't a link for the platform | `400` | `400` | `400` | `400` |
| Unknown meeting, event or space | `404` | `404` | `404` | `404` |
| Meeting Unified can't resolve | `422` (account can't be resolved) | — | `422` (meeting has no web link) | `422` (Calendar event has no Meet link) |
| Another unfinished bot for exactly the same link on the connection | `409` | `409` | `409` | `409` |
| Update with `meeting_url` / `event_id` | `400` if `meeting_url` changes; `event_id` allowed | ignored (only `name` is written) | `400` | `name` only |
| Update with `join_at` or a capture flag | `501` | `501` | `501` | `501` |

`501` responses read `Not supported by this integration: …`. Teams also needs your own Entra app on the integration:
without it, creating the `calendar_botevent` webhook returns `400` (`Microsoft Teams (meeting bot) needs your own
Entra app: …`); Google Meet returns `400` when the project ID or service-account credentials are missing. `automatic_leave` isn't used by any meeting-bot
integration at launch. Don't retry a `501` — remove the field.

## 10. Recurring meetings and other limits

- **Zoom recurring meetings:** every occurrence has the **same meeting ID**, and a recording goes to the **oldest bot
  that isn't finished**. A `CALL_ENDED` bot is still waiting for its recording, so create the next occurrence's bot
  only after the previous bot reaches `DONE`; if the previous meeting had no cloud recording, delete its bot first.
- **Webex recurring meetings:** a bot for a series follows the first occurrence that starts after the bot is created.
- **Google Meet recurring meetings:** a bot follows one conference — the first in its meeting space — so create a new
  bot per occurrence.
- **Teams recurring meetings:** recordings and transcripts attach only when created between the bot's `created_at`
  and `ended_at`.
- **Zoom:** your Zoom app needs RTMS scopes, with usage metered through a Zoom Developer Pack (`zoom-meeting-bot-app`
  §1), and RTMS must start in the meeting (host auto-start, or your app started in the meeting); chat is messages-to-everyone
  only; `PARTICIPANT_SPEECH_ON` fires on active-speaker change and there's no `_OFF`; if the stream drops Unified
  retries within Zoom's reconnection window (about 60 seconds for signaling, 30 for media), else `FATAL`
  `rtms_connection_lost`.
- **Teams:** after-the-meeting only; only the first page of call-record participants (130) and sessions (60) is
  read; with several bot webhooks on one connection, call-record events go to whichever is processed first while
  recording and transcript events go to every webhook.
- **Webex:** Webex's target is that the API data behind a notification catches up within 10 minutes in 99.5% of
  cases (a lookup can return `404` until then; for transcripts Unified retries for about 11.5 minutes, so
  `TRANSCRIPT_RECEIVED` usually still arrives, later). Webex disables a webhook after repeated delivery failures, or when its
  creator is deleted, and doesn't re-enable it.
- **Google Meet:** only meeting spaces the connected user **owns** (normally the Calendar organizer) produce events;
  no live or partial transcript, chat, media or smart notes. The `meetings.space.readonly` scope is sensitive, so
  external users need Google app verification.

## 11. Test end to end

For each platform, on a real meeting:

1. Create the `calendar_botevent` webhook, then a bot (`meeting_url` or `event_id`) → `READY`.
2. Hold the meeting with recording (and Teams transcription) on. Zoom: start RTMS → `IN_CALL_RECORDING`, captions and
   participant events. Webex: `IN_CALL_NOT_RECORDING`, `PARTICIPANT_JOINED`. Google Meet (meeting the connected
   user organizes): `IN_CALL_NOT_RECORDING`, participants, `RECORDING_STARTED` → `IN_CALL_RECORDING`.
3. End it → `CALL_ENDED` (Teams: up to 150 minutes later).
4. Wait for `RECORDING_READY` (→ `DONE` on Zoom/Teams), then get `calendar_recording` by `recording_id`.
5. Try one `join_at` create and confirm the `501`, so your UI handles it.

## Troubleshooting by error message

| Symptom / message | Cause and fix |
| --- | --- |
| `400` mentioning the Secret Token (Zoom) | Secret Token missing on the Zoom (meeting bot) integration — `zoom-meeting-bot-app` §5 |
| `400` on create | `meeting_url` isn't that platform's join link, or `transcription.provider` isn't `MEETING_CAPTIONS` |
| `404` on create | The meeting, event or space doesn't exist or the connected user can't see it |
| `422` on create | Webex meeting with no web link; Calendar event with no Meet link (Google Meet); Zoom account can't be resolved |
| `409` on create | An unfinished bot already exists for that exact link on this connection — list with `?meeting_url=` and reuse it |
| `409` creating the webhook (Google Meet) | A `calendar_botevent` webhook already exists on the connection, or the same Google user is subscribed through another connection |
| `501 Not supported by this integration: …` | `join_at`, `recording_config` on Teams/Webex/Google Meet, or a Zoom capture flag (§9) — remove the field |
| `403` / missing-scope errors | Permission not selected on the integration or not granted at authorization — add it, have the user reconnect |
| Bot stays `READY` (Zoom) | RTMS didn't start in the meeting (host hasn't chosen the app to auto-start, or the admin hasn't allowed sharing realtime meeting content), the app has no RTMS scopes or Developer Pack coverage, or the RTMS started in Meeting subscription is missing / points elsewhere |
| Creating the webhook fails (Teams) | No Entra app on the integration (`400`), or no tenant admin consent to `CallRecords.Read.All` — the call-records subscription fails and the whole webhook creation fails; `403 GraphAccessToTranscriptsDisabled` if the tenant turned off Graph access to transcripts |
| `AADSTS90094` / `AADSTS50194` at Teams sign-in | Tenant admin hasn't consented to the admin-only permissions; or the Entra app isn't multitenant (`microsoft-teams-meeting-bot-app`) |
| Bot stays `READY` after the meeting (Teams) | Bot or webhook created after the meeting ended; call record not delivered yet (≤150 min) |
| Bot stays `CALL_ENDED` | No cloud recording (Zoom) or no recording (Teams); Zoom `recording.completed` not subscribed. Webex and Google Meet never reach `DONE` — by design |
| No events at all | Webhook missing, not `native`, on another connection, or created after the meeting started (Webex, Google Meet) / ended (Teams); webhook unhealthy. On Teams, a webhook created after the meeting started misses only the transcript notifications |
| Bot stays `READY` (Google Meet) | Connected user doesn't own the meeting space; Pub/Sub or service-account setup broken (`google-meet-meeting-bot-app`) |
| No chat events (Zoom) | `record_chat` not set, or the app has no RTMS chat access |
| No recording/transcript events (Teams) | Connected user isn't the organizer; recording/transcription off; not delivered yet (≤240 min) |
| Wrong Zoom bot got the recording | Recurring-meeting rule (§10) — an older unfinished bot took it |
| `401` on webhook in your logs | You're verifying the wrong bytes or using `sig` — verify `data + nonce` with the workspace secret (§7) |

## Critical rules

- Never use a regular `zoom`, `microsoftteams` or `webex` connection for bots; it can't create them.
- Never claim a bot records audio or video, or joins as a participant — it doesn't.
- Never invent statuses, event types, fields or sub-codes beyond those above; unused model values are not sent.
- Create the `calendar_botevent` webhook before the meeting; there's no way to fetch missed events.
- Never put the API key or workspace secret in client code.

## References

- Calendar & Meetings API overview: https://docs.unified.to/calendar/overview
- `calendar_bot` model: https://docs.unified.to/calendar/bot/model
- `calendar_botevent` model: https://docs.unified.to/calendar/botevent/model
- `calendar_recording` model: https://docs.unified.to/calendar/recording/model
- Webhooks: https://docs.unified.to/reference/webhooks
- Guide — How to Build a Meeting Bot with Unified (published at launch):
  https://docs.unified.to/guides/how_to_build_a_meeting_bot_with_unified_zoom_microsoft_teams_webex
