> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.meetstream.ai/guides/webhooks/webhooks-and-events/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.meetstream.ai/_mcp/server. > Every MeetStream webhook event from bot.joining to bot.done, with payload examples and how to handle bot.stopped statuses. # Meeting Bot Webhook Events This guide explains how to receive **webhook events** from MeetStream, what each event means, and how to trigger follow-up actions like fetching **audio, video, or transcription**. Applies to: **Google Meet, Zoom, Microsoft Teams**. Support: docs.meetstream.ai • API: api.meetstream.ai --- ## 1) Add your webhook URL while creating the bot When you create a bot, include your webhook endpoint in the payload as: ```json { "callback_url": "{{webhook_url}}" } ``` MeetStream will send HTTP POST requests to your `callback_url` whenever the bot's status changes during its lifecycle and when post-call processing completes. ### Example (Create Bot + callback_url) Docs: https://docs.meetstream.ai/api-reference/api-endpoints/bot-endpoints/create-bot ```bash curl -X POST "https://api.meetstream.ai/api/v1/bots/create_bot" \ -H "Authorization: Token " \ -H "Content-Type: application/json" \ -d '{ "meeting_link": "", "video_required": false, "callback_url": "https://your-domain.com/webhooks/meetstream" }' ``` > Tip: Your webhook should respond quickly with **2xx** (e.g., 200) to acknowledge receipt. --- ## 2) Webhook payload format Every webhook notification follows this structure: ```json { "bot_event": "bot.joining", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Joining", "message": "Bot is joining the meeting", "status_code": 200, "timestamp": "2026-02-27T07:11:51.863543+00:00", "custom_attributes": {} } ``` ### Fields - `bot_event` — the event name. Identifies what just happened (`bot.joining`, `bot.in_waiting_room`, `bot.inmeeting`, `bot.recording`, `bot.leaving`, `bot.stopped`, `bot.kicked`, `bot.denied`, `bot.notallowed`, `bot.failed`, `bot.done`, `audio.processed`, `transcription.processed`, `video.processed`, `data_deletion`, etc.). **Branch on this field.** - `bot_id` — unique identifier for the bot session - `bot_status` — status detail (see **Status Reference**) - `message` — human readable explanation - `status_code` — `200` for success stages; `500` for any failure (`Error`, `Denied`, `NotAllowed`) - `timestamp` — ISO-8601 timestamp - `custom_attributes` — echoed back if you provided it when creating the bot --- ## 3) Event types (what you'll receive) MeetStream sends two categories of webhook events: **bot lifecycle events** and **post-call processing events**. ### A) Bot lifecycle events These events track the bot's full lifecycle, from join to post-call completion: | `bot_event` | What it means | |---|---| | `bot.scheduled` | (Scheduled bots only — when `join_at` is set) MeetStream accepted the scheduling request and created the schedule. Fires immediately after `POST /bots`. Confirms the schedule was set; the bot will not actually join until the `scheduled_join_time` carried on the payload. | | `bot.joining` | MeetStream accepted the bot creation request and dispatched the bot (fired by the API server, **not** the bot container). For scheduled bots, fires when the scheduled execution time triggers. | | `bot.in_waiting_room` | Bot has clicked Join and is now waiting to be admitted. Fires for **every** bot once between `bot.joining` and `bot.inmeeting` — briefly for instant-admit meetings; lingers if the host has a lobby enabled. | | `bot.inmeeting` | Bot successfully joined and is actively in the meeting | | `bot.recording` | Bot started capturing audio/video (fires once after recording actually begins) | | `bot.recording_permission_allowed` | **(Zoom only)** Host granted the bot recording permission. Fires between `bot.inmeeting` and `bot.recording`. | | `bot.recording_permission_denied` | **(Zoom only)** Host explicitly denied recording permission OR did not respond within the timeout. Followed by `bot.leaving` → `bot.stopped`. | | `bot.leaving` | Bot is exiting the meeting (transitional event before any terminal event) | | `bot.stopped` | Clean exit — meeting ended, API stop, host ended, or any other graceful terminal cause. `bot_status: "Stopped"`, `status_code: 200`. | | `bot.kicked` | Bot was forcibly removed from the meeting by the host or a participant. `bot_status: "Stopped"`, `status_code: 200`. | | `bot.denied` | Host explicitly denied the bot's join request. `bot_status: "Denied"`, `status_code: 500`. (For Zoom recording-permission denial, see `bot.recording_permission_denied`.) | | `bot.notallowed` | Bot was not admitted (waiting-room/lobby timeout). `bot_status: "NotAllowed"`, `status_code: 500`. | | `bot.failed` | Unexpected error during the bot's lifecycle. `bot_status: "Error"`, `status_code: 500`. | | `bot.done` | Final bot status after all processing is complete (successful, failed, or partially completed) | #### Example payloads **`bot.scheduled`** *(scheduled bots only — fires immediately after `POST /bots` when `join_at` is set)* ```json { "bot_event": "bot.scheduled", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Scheduled", "message": "Bot scheduled to join at 2026-05-18T08:10:00+00:00", "status_code": 200, "timestamp": "2026-05-17T19:42:13.000000+00:00", "scheduled_join_time": "2026-05-18T08:10:00+00:00", "custom_attributes": {} } ``` > **About `bot.scheduled`:** Confirms to webhook-driven integrations that the schedule was accepted. The bot will not actually join the meeting until `scheduled_join_time` — at which point `bot.joining` and the normal lifecycle fire. Without subscribing to this event, the only signal that a scheduled bot was created is the 201 response body (or polling `GET /bots/{id}` and checking `Status: "Scheduled"`). **`bot.joining`** *(fired by the API server when the bot is dispatched)* ```json { "bot_event": "bot.joining", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Joining", "message": "Bot is joining the meeting", "status_code": 200, "timestamp": "2026-05-18T08:10:00.000000+00:00", "custom_attributes": {} } ``` **`bot.in_waiting_room`** ```json { "bot_event": "bot.in_waiting_room", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "InWaitingRoom", "message": "Bot is waiting to be admitted", "status_code": 200, "timestamp": "2026-05-18T08:10:05.000000+00:00", "custom_attributes": {} } ``` **`bot.inmeeting`** ```json { "bot_event": "bot.inmeeting", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "InMeeting", "message": "Bot successfully joined the meeting", "status_code": 200, "timestamp": "2026-05-18T08:10:12.000000+00:00", "custom_attributes": {} } ``` **`bot.recording`** ```json { "bot_event": "bot.recording", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Recording", "message": "Bot started recording", "status_code": 200, "timestamp": "2026-05-18T08:10:13.000000+00:00", "custom_attributes": {} } ``` **`bot.recording_permission_allowed`** *(Zoom only)* ```json { "bot_event": "bot.recording_permission_allowed", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "RecordingPermissionAllowed", "message": "Recording permission granted by host", "status_code": 200, "timestamp": "2026-05-18T08:10:12.500000+00:00", "custom_attributes": {} } ``` **`bot.recording_permission_denied` — denied by host** *(Zoom only)* ```json { "bot_event": "bot.recording_permission_denied", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "RecordingPermissionDenied", "message": "Failed: Recording permission denied by host", "status_code": 500, "timestamp": "2026-05-18T08:11:00.000000+00:00", "custom_attributes": {} } ``` **`bot.recording_permission_denied` — host did not respond in time** *(Zoom only)* ```json { "bot_event": "bot.recording_permission_denied", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "RecordingPermissionDenied", "message": "Failed: Recording permission timeout", "status_code": 500, "timestamp": "2026-05-18T08:11:12.000000+00:00", "custom_attributes": {} } ``` **`bot.leaving`** ```json { "bot_event": "bot.leaving", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Leaving", "message": "Bot is leaving the meeting", "status_code": 200, "timestamp": "2026-05-18T09:05:40.000000+00:00", "custom_attributes": {} } ``` ### Terminal-event payloads Every terminal scenario fires a **single** webhook. Branch on `bot_event` to distinguish the cause. **Clean exit (`bot.stopped`)** ```json { "bot_event": "bot.stopped", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Stopped", "message": "Bot exited the call: Meeting ended by host", "status_code": 200, "timestamp": "2026-05-18T09:05:41.000000+00:00", "custom_attributes": {} } ``` **Kicked by host/participant (`bot.kicked`)** ```json { "bot_event": "bot.kicked", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Stopped", "message": "Bot was removed from the meeting by a participant", "status_code": 200, "timestamp": "2026-05-18T08:45:00.000000+00:00", "custom_attributes": {} } ``` **Lobby timeout (`bot.notallowed`)** ```json { "bot_event": "bot.notallowed", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "NotAllowed", "message": "Failed: Not admitted to meeting", "status_code": 500, "timestamp": "2026-05-18T08:15:00.000000+00:00", "custom_attributes": {} } ``` **Host denied join (`bot.denied`)** ```json { "bot_event": "bot.denied", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Denied", "message": "Failed: Host denied join", "status_code": 500, "timestamp": "2026-05-18T08:20:00.000000+00:00", "custom_attributes": {} } ``` > **Note:** For **Zoom** recording-permission denial, the bot emits `bot.recording_permission_denied` followed by `bot.leaving` → `bot.stopped` — **not** `bot.denied`. See the `bot.recording_permission_denied` example above. **Unexpected failure (`bot.failed`)** ```json { "bot_event": "bot.failed", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Error", "message": "Error: Failed to connect to meeting (stuck in connecting state)", "status_code": 500, "timestamp": "2026-05-18T08:11:00.000000+00:00", "custom_attributes": {} } ``` > **`bot.done`** is the terminal event of the post-call pipeline — see Section 3.B below for the full payload, behavior, and its relationship with `bot_status: "Done"`. --- **Important notes:** - Every bot starts with `bot.joining`, fired by the MeetStream API server the moment the bot creation request is accepted and dispatched. For scheduled bots (`join_at`), the API server **also** fires `bot.scheduled` immediately on `POST /bots` to confirm the schedule was accepted; `bot.joining` then fires later when the scheduled execution time triggers. - `bot.in_waiting_room` is fired for **every** bot once the container clicks Join. For meetings without a lobby it fires briefly before `bot.inmeeting`; for meetings with a host-admit lobby it lingers until the host admits. - If the bot joins successfully, you will receive `bot.inmeeting`. - `bot.recording` is sent once the bot actually starts capturing audio/video. For Zoom this is gated on host-granted recording permission, so there can be a noticeable gap between `bot.inmeeting` and `bot.recording`. For Teams/GMeet, `bot.recording` typically fires within a second of `bot.inmeeting`. - **(Zoom only)** Between `bot.inmeeting` and `bot.recording`, you may receive `bot.recording_permission_allowed` once the host grants recording permission. If the host denies the request OR does not respond within the configured timeout, you'll receive `bot.recording_permission_denied`, followed by the usual `bot.leaving` → `bot.stopped` terminal sequence (note: the terminal is `bot.stopped`, **not** `bot.denied` — recording-permission denial ends with a clean stop). - `bot.leaving` is emitted briefly before any terminal event (whether normal exit, kick, denial, or failure). - The in-meeting lifecycle ends with **exactly one** terminal webhook: `bot.stopped` / `bot.kicked` / `bot.denied` / `bot.notallowed` / `bot.failed`. - If the bot **fails to join** (lobby timeout/denied/error), you may get `bot.leaving` then a failure terminal event right after `bot.joining` **without** a `bot.inmeeting`. - After the in-meeting terminal event, MeetStream runs the post-call pipeline (audio upload, video upload, transcription) and you'll receive the artifact-specific events (`audio.processed`, `video.processed`, `transcription.processed`) followed by `bot.done` when the pipeline completes. - Failure terminals (`bot.denied`, `bot.notallowed`, `bot.failed`) carry `status_code: 500` and `message` prefixed with `"Error: "` or `"Failed: "` (see Section 4). Clean terminals (`bot.stopped`, `bot.kicked`) carry `status_code: 200`. ### B) Post-call processing events After the in-meeting terminal webhook, MeetStream continues processing the recorded session in the background. You will receive additional webhook events as each artifact finishes processing, plus a terminal `bot.done` once the pipeline is complete and `data_deletion` if/when the media is removed: | `bot_event` | What it means | `bot_status` after the event | |---|---|---| | `audio.processed` | Audio extraction and processing completed | `MediaProcessing` | | `transcription.processed` | Transcription generation completed | `MediaProcessing` | | `video.processed` | Video processing completed | `MediaProcessing` | | `bot.done` | Post-call pipeline finished — audio/video/transcription artifacts are ready to fetch | `Done` | | `data_deletion` | Bot media has been deleted (manually via `DELETE /bots/{id}` or automatically when the retention window elapses) | `MediaExpired` | These events arrive **asynchronously** after the bot has stopped — the order and timing depend on processing duration for each artifact. The expected order on a healthy bot is roughly: ``` audio.processed transcription.processed // (any order vs audio/video) video.processed bot.done // pipeline complete ... (later, on retention or manual delete) data_deletion // media gone ``` > **`Status` field progression during post-call:** the live `Status` column on the bot record (visible via `GET /bots/{id}`) advances through `MediaProcessing` (pipeline started) → `Done` (pipeline finished, matches the `bot.done` webhook) → `MediaExpired` (media deleted, matches `data_deletion`). `MediaProcessing` is a status-only transition and does not have its own webhook — track the artifact-specific events instead. #### Example payloads **`audio.processed`** ```json { "bot_id": "5b0ff6e7-3cea-4c9f-a6b4-851c5f11cf4f", "bot_event": "audio.processed", "audio_status": "Success", "message": "Audio processing completed successfully", "status_code": 200 } ``` **`transcription.processed`** ```json { "bot_id": "5b0ff6e7-3cea-4c9f-a6b4-851c5f11cf4f", "bot_event": "transcription.processed", "transcript_status": "Success", "message": "Transcript processing completed successfully", "status_code": 200 } ``` **`video.processed`** ```json { "bot_id": "5b0ff6e7-3cea-4c9f-a6b4-851c5f11cf4f", "bot_event": "video.processed", "video_status": "Success", "message": "Video processing completed successfully", "status_code": 200 } ``` **`bot.done`** — post-call pipeline finished ```json { "bot_event": "bot.done", "bot_id": "6667fd0c-0165-471a-a880-06a1180be377", "bot_status": "Done", "message": "Post-call processing completed", "status_code": 200, "timestamp": "2026-05-18T09:07:30.000000+00:00", "custom_attributes": {} } ``` > **About `bot.done`:** Always carries `status_code: 200` — it signals only that the post-call pipeline has *finished*, not whether each step succeeded. Inspect the artifact-specific events above (`audio.processed`, `transcription.processed`, `video.processed`, `transcription.failed`, etc.) for per-step success or failure. **`data_deletion`** — media has been deleted (matches `Status=MediaExpired`) ```json { "bot_id": "5b0ff6e7-3cea-4c9f-a6b4-851c5f11cf4f", "bot_event": "data_deletion", "status": "success", "message": "Bot data deleted successfully", "deleted_objects": 5, "timestamp": "2024-01-15T14:30:00Z", "status_code": 200 } ``` > **About `data_deletion`:** Fires once when the bot's media is removed from MeetStream's storage — either when the customer calls `DELETE /api/v1/bots/{bot_id}` or when the retention window configured at bot-creation time elapses. After this event the bot's `Status` column reads `MediaExpired` and the artifact-fetch endpoints (`GET /bots/{id}/get_audio`, `/video`, `/transcript`) will return 404/410. --- ## 4) Status reference (bot_status) `bot_status` gives more detail. The table below shows which `bot_status` value each event carries, alongside the `bot_event` name. Note that `bot_status` for `bot.kicked` is reported as `"Stopped"` (not `"Kicked"`) — branch on `bot_event` to distinguish kicks from clean stops. | bot_status (wire) | `bot_event` | Meaning | |---|---|---| | `Scheduled` | `bot.scheduled` | (Scheduled bots only) Schedule accepted. Bot will join at `scheduled_join_time` on the payload. | | `Joining` | `bot.joining` | Bot creation request accepted; bot has been dispatched (fired by the API server, not the container) | | `InWaitingRoom` | `bot.in_waiting_room` | Bot has clicked Join and is now waiting to be admitted (instant for most meetings; lingers if host has a lobby enabled). Fires for every bot. | | `InMeeting` | `bot.inmeeting` | Bot is in the meeting (admitted) | | `Recording` | `bot.recording` | Bot started capturing audio/video. For Zoom this fires after the host grants recording permission; for Teams/GMeet it fires on the first audio frame received | | `RecordingPermissionAllowed` | `bot.recording_permission_allowed` | **(Zoom only)** Host granted recording permission. Fires between `InMeeting` and `Recording` | | `RecordingPermissionDenied` | `bot.recording_permission_denied` | **(Zoom only)** Host denied recording permission or did not respond within the timeout. Followed by `Leaving` → `Stopped` | | `Leaving` | `bot.leaving` | Bot is exiting the meeting (transitional, fires before any terminal event) | | `Stopped` | `bot.stopped` | Clean exit — meeting ended, API stop, host ended, or any other graceful terminal cause. `status_code: 200`. | | `Stopped` | `bot.kicked` | Bot was forcibly removed from the meeting by the host or a participant. Note `bot_status` is `"Stopped"`, not `"Kicked"` — branch on `bot_event` to detect kicks. `status_code: 200`. | | `NotAllowed` | `bot.notallowed` | Bot could not join (commonly waiting room/lobby timeout). `status_code: 500`. | | `Denied` | `bot.denied` | Host explicitly denied the bot's join request (for Zoom recording-permission denial, the terminal is the clean `bot.stopped`). `status_code: 500`. | | `Error` | `bot.failed` | Unexpected error during lifecycle. `status_code: 500`. | | `MediaProcessing` | *(no webhook)* | Bot has exited the meeting and the post-call pipeline has begun (audio/video upload, transcription). Visible via GET `/bots/{id}` but does not fire its own webhook — track artifact-specific events (`audio.processed`, `transcription.processed`, `video.processed`) for progress. | | `Done` | `bot.done` | Post-call pipeline finished. The live `Status` column also flips to `Done` at this moment (matches the `bot.done` webhook). | | `MediaExpired` | `data_deletion` | Bot media has been deleted — either manually via `DELETE /bots/{id}` or automatically when the configured retention window elapses. The `data_deletion` webhook carries the matching details. | ### Failure messages When a stage fails, `status_code` is `500` and `message` carries a prefix you can grep on for alerting: - `"Error:
"` — unexpected exceptions (uncaught errors, AWS API failures) - `"Failed:
"` — handled/expected failures (validation, timeout, denial) Examples: - `"Error: Failed to start recording"` - `"Failed: Recording permission denied by host"` - `"Failed: Not admitted to meeting"` - `"Error: Transcript processing failed"` --- ## 5) Recommended actions based on webhook events Your server can react to webhook events and run follow-up workflows. ### A) On `bot.inmeeting` Typical actions: - Update your UI/state: "bot is live" - Start timers / internal tracking - Notify other systems that recording has started ### B) On any terminal event (`bot.stopped` / `bot.kicked` / `bot.denied` / `bot.notallowed` / `bot.failed`) This is the moment to mark the session as complete. Artifacts may still be processing at this point. Recommended flow: 1. Receive a terminal webhook 2. Branch on `bot_event`: - `bot.stopped` — clean exit (meeting ended, API stop, host ended, etc.). Wait for post-call processing events before fetching outputs. - `bot.kicked` — bot was forcibly removed by host/participant. Recording up to that point is still processed. - `bot.denied` — host explicitly denied join. Log the `message` and alert/handle accordingly. - `bot.notallowed` — bot was not admitted (lobby/waiting-room timeout). Log and handle accordingly. - `bot.failed` — unexpected error during the bot's lifecycle. Log `message` and alert. ### C) On post-call processing events Once you receive `audio.processed`, `transcription.processed`, or `video.processed`, the corresponding artifact is ready to fetch: - Audio: https://docs.meetstream.ai/api-reference/api-endpoints/bot-endpoints/get-bot-audio - Video: https://docs.meetstream.ai/api-reference/api-endpoints/bot-endpoints/get-bot-video ### D) On `data_deletion` Confirms that bot data has been removed. Update your records accordingly — further fetch requests for this bot's artifacts will fail. --- ## 6) Webhook signing (optional but recommended) If you configure a webhook secret, MeetStream includes an HMAC signature in headers: - `X-MeetStream-Signature`: `sha256=` - `X-MeetStream-Timestamp`: ISO 8601 timestamp Verification steps: 1. Compute `HMAC-SHA256(your_secret, raw_request_body)` 2. Compare with `X-MeetStream-Signature` (strip `sha256=` prefix) 3. Optionally validate timestamp is within an acceptable window (replay protection) The secret is generated when you create a [workspace webhook endpoint](https://docs.meetstream.ai/guides/webhooks/workspace-webhooks) in the dashboard and is shown once. Deliveries to a per-bot `callback_url` are not signed. Copy-paste verification code for Python and Node: [Verifying Webhook Signatures](https://docs.meetstream.ai/guides/webhooks/webhook-signature-verification). --- ## 7) Retry behavior (important) - Webhook delivery is **best-effort**. - If your endpoint returns a **non-2xx**, the webhook is **not retried**. - A bot may send up to **3 `bot.joining`** events if join retries are configured. - `bot.inmeeting` is sent **at most once**. Exactly **one** terminal webhook (`bot.stopped` / `bot.kicked` / `bot.denied` / `bot.notallowed` / `bot.failed`) is sent per bot. - Post-call processing events (`audio.processed`, `transcription.processed`, `video.processed`, `bot.done`, `data_deletion`) are sent **at most once**. --- ## 8) Minimal webhook handler checklist - ✅ Accept `POST` requests at `callback_url` - ✅ Verify signature (if enabled) - ✅ Always respond **2xx quickly** - ✅ Idempotent handling (store processed event IDs or de-dupe by `{bot_id, bot_event, timestamp}`) - ✅ On any terminal event (`bot.stopped` / `bot.kicked` / `bot.denied` / `bot.notallowed` / `bot.failed`), mark session complete - ✅ On `audio.processed` / `video.processed` / `transcription.processed`, fetch the corresponding artifact - ✅ On `data_deletion`, clean up local references > Meeting bot API documentation for Zoom, Google Meet and Microsoft Teams: create bots, stream real-time audio, transcribe, and run in-meeting voice agents.