Recall.ai has raised a $38M series B!

More resources

Filter by category

View all

Tutorials

Best Practices

Tutorials

Deep Dive

Zoom Developer Forum

.avif) Google Meet Developer Forum

Microsoft Teams Developer Forum

Announcements

Thank you! Your submission has been received!

Oops! Something went wrong while submitting the form.

\ A guide to programmatic screen capture on macOS\ \ Deep Dive](/content/blog/macos-screencapture-api/index.html)

\ Joining multiple simultaneous meetings through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-simultaneous-meeting-limitations/index.html)

\ How to get transcripts from Microsoft Teams\ \ Tutorials](/content/blog/how-to-get-transcripts-from-microsoft-teams/index.html)

\ How to Get Recordings and Transcripts from Slack Huddles\ \ Tutorials](/content/blog/get-recordings-and-transcripts-from-slack-huddles-api/index.html)

\ Understanding the Zoom On Behalf Of (OBF) Token\ \ Deep Dive](/content/blog/zoom-obf/index.html)

\ What is a meeting bot?\ \ Deep Dive](/content/blog/what-is-a-meeting-bot/index.html)

\ Zoom SDK Credentials\ \ Deep Dive](/content/blog/zoom-sdk-credentials/index.html)

\ What is a Zoom Access Key (ZAK) token?\ \ Deep Dive](/content/blog/what-is-a-zoom-access-key-zak-token/index.html)

\ How WebSockets cost us $1M on our AWS bill\ \ Deep Dive](/content/blog/how-websockets-cost-us-1m-on-our-aws-bill/index.html)

\ The fix for a segfault that never shipped\ \ Deep Dive](/content/blog/the-fix-for-a-segfault-that-never-shipped/index.html)

\ Postgres Postmaster does not scale\ \ Deep Dive](/content/blog/postgres-postmaster-does-not-scale/index.html)

\ How to access audio streams in the Zoom Web SDK?\ \ Zoom Developer Forum](/content/blog/how-to-access-audio-streams-in-the-zoom-web-sdk/index.html)

\ How to determine if audio is connected on the Zoom Web SDK?\ \ Zoom Developer Forum](/content/blog/how-to-determine-if-audio-is-connected-on-the-zoom-web-sdk/index.html)

\ Recall.ai Series B: The API for Meeting Recording\ \ Announcements](/content/blog/recall-ai-series-b-the-api-for-meeting-recording/index.html)

\ How to build a Microsoft Teams Bot\ \ Tutorials](/content/blog/how-to-build-a-microsoft-teams-bot/index.html)

\ What is the Google Meet Media API?\ \ Best Practices](/content/blog/what-is-the-google-meet-media-api/index.html)

\ Postgres LISTEN/NOTIFY does not scale\ \ Deep Dive](/content/blog/postgres-listen-notify-does-not-scale/index.html)

\ Code Walkthrough: Generating Meeting Action Items With Recall.ai\ \ Tutorials](/content/blog/generating-meeting-action-items-with-recall-ai/index.html)

\ What are speaker labels and how to obtain them\ \ Tutorials](/content/blog/speaker-labels-and-names-explained/index.html)

\ 7 APIs to get Zoom transcripts: A comprehensive guide\ \ Best Practices](/content/blog/7-apis-to-get-zoom-transcripts-a-comprehensive-guide/index.html)

\ How to get transcripts from Zoom\ \ Tutorials](/content/blog/how-to-get-transcripts-from-zoom/index.html)

\ How to record Microsoft Teams\ \ Tutorials](/content/blog/how-to-get-recordings-from-microsoft-teams/index.html)

\ New Recall.ai Pricing for 2026: $0.50 per Hour of Meeting Recording\ \ Announcements](/content/blog/new-recall-ai-pricing-for-2026/index.html)

\ Open source Microsoft Teams recording software\ \ Microsoft Teams Developer Forum](/content/blog/microsoft-teams-recording-software-open-source/index.html)

\ How to pick a meeting note taker\ \ Best Practices](/content/blog/how-to-pick-a-meeting-note-taker/index.html)

\ How to Get a Recording from the Google Meet API\ \ Best Practices](/content/blog/how-to-get-a-recording-from-the-google-meet-api/index.html)

\ How to get Google Meet transcripts programmatically\ \ Best Practices](/content/blog/how-to-get-transcripts-from-google-meet-developer-edition/index.html)

\ What is Zoom RTMS?\ \ Best Practices](/content/blog/what-is-zoom-rtms/index.html)

\ How to build a desktop recording app (Like Granola)\ \ Tutorials](/content/blog/how-to-build-a-desktop-recording-app/index.html)

\ Speaker Diarization: What it is and how to get diarized transcripts\ \ Best Practices](/content/blog/speaker-diarization/index.html)

\ Understanding Zoom RTMS Behavior and Its Constraints\ \ Best Practices](/content/blog/understanding-zoom-rtms-behavior-and-its-constraints/index.html)

\ How to build a meeting notetaker\ \ Tutorials](/content/blog/how-to-build-a-meeting-notetaker/index.html)

\ How to build a meeting bot \ \ Tutorials](/content/blog/how-to-build-a-meeting-bot/index.html)

\ When to build a Microsoft Teams bot with Puppeteer\ \ Microsoft Teams Developer Forum](/content/blog/puppeteer-microsoft-teams-bot/index.html)

\ How to join a Zoom meeting using the Zoom Meeting SDK\ \ Zoom Developer Forum](/content/blog/how-to-join-a-zoom-meeting-using-the-zoom-meeting-sdk/index.html)

\ Microsoft Teams open source bot\ \ Microsoft Teams Developer Forum](/content/blog/microsoft-teams-open-source-bot/index.html)

\ How to Build a Google Meet Bot from Scratch\ \ Tutorials](/content/blog/how-i-built-an-in-house-google-meet-bot/index.html)

.avif)\ Is there open source Google Meet recording software?\ \ Google Meet Developer Forum](/content/blog/is-there-open-source-google-meet-recording-software/index.html)

.avif)\ Puppeteer Google Meet Bot\ \ Google Meet Developer Forum](/content/blog/puppeteer-google-meet-bot/index.html)

\ Zoom recording software open source\ \ Zoom Developer Forum](/content/blog/zoom-recording-software-open-source/index.html)

\ How to build a Chrome recording extension using tabCapture API\ \ Tutorials](/content/blog/how-to-build-a-chrome-recording-extension/index.html)

\ Zoom Signature Invalid\ \ Zoom Developer Forum](/content/blog/zoom-signature-invalid/index.html)

\ How to get automatic recordings from a Zoom bot\ \ Zoom Developer Forum](/content/blog/zoom-bot-automatic-recording/index.html)

\ Retrieving participant names through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-retrieving-participant-names/index.html)

\ How to Access Live Transcription Data from Zoom Web SDK (Component View)\ \ Zoom Developer Forum](/content/blog/how-to-access-live-transcription-data-from-zoom-web-sdk-component-view/index.html)

\ How to build a Zoom bot from scratch\ \ Tutorials](/content/blog/how-to-build-a-zoom-bot/index.html)

\ Create a Google Meet Programmatically\ \ Tutorials](/content/blog/create-a-google-meet-programmatically/index.html)

\ How to join Zoom using Puppeteer\ \ Zoom Developer Forum](/content/blog/how-to-join-zoom-using-puppeteer/index.html)

\ Handling Zoom SDK recording permissions\ \ Zoom Developer Forum](/content/blog/zoom-sdk-recording-permissions/index.html)

\ How to Enable a Zoom Bot to Skip the Waiting Room\ \ Zoom Developer Forum](/content/blog/zoom-bot-skip-waiting-room/index.html)

\ How do I sync audio and video when processing raw audio/video from the Zoom SDK?\ \ Zoom Developer Forum](/content/blog/how-do-i-sync-audio-and-video-when-processing-raw-audio-video-from-the-zoom-sdk/index.html)

\ Fixing DLL warnings in the Zoom Windows Meeting SDK\ \ Zoom Developer Forum](/content/blog/fixing-dll-warnings-in-the-zoom-windows-meeting-sdk/index.html)

\ Zoom join tokens for local recording\ \ Best Practices](/content/blog/zoom-join-tokens-for-local-recording/index.html)

\ How to get recordings from Zoom using a Zoom recording bot API\ \ Tutorials](/content/blog/how-to-get-recordings-from-zoom/index.html)

\ How to manage ZAK Tokens for Zoom bots\ \ Zoom Developer Forum](/content/blog/how-to-manage-zak-tokens-for-zoom-bots/index.html)

\ How to build a Zoom notetaker\ \ Tutorials](/content/blog/how-to-build-a-zoom-notetaker/index.html)

\ Recall.ai Announces Participant Emails\ \ Announcements](/content/blog/recall-ai-announces-participant-emails/index.html)

\ How often should I expect to receive the Zoom recording completed webhook for a single meeting?\ \ Zoom Developer Forum](/content/blog/how-often-should-i-expect-to-receive-the-zoom-recording-completed-webhook-for-a-single-meeting/index.html)

\ Why are waiting rooms causing issues with the Zoom Web Meeting SDK?\ \ Zoom Developer Forum](/content/blog/why-are-waiting-rooms-causing-issues-with-the-zoom-web-meeting-sdk/index.html)

\ Why is my Zoom recording completed webhook so delayed?\ \ Zoom Developer Forum](/content/blog/why-is-my-zoom-recording-completed-webhook-so-delayed/index.html)

\ Why aren't transcription webhooks firing for my Zoom meetings?\ \ Zoom Developer Forum](/content/blog/why-arent-transcription-webhooks-firing-for-my-zoom-meetings/index.html)

\ How do I track participants across Zoom breakout rooms?\ \ Zoom Developer Forum](/content/blog/how-do-i-track-participants-across-zoom-breakout-rooms/index.html)

\ Can I identify meeting participants by email on Zoom?\ \ Zoom Developer Forum](/content/blog/can-i-identify-meeting-participants-by-email-on-zoom/index.html)

\ How do I capture participant data in real time on Zoom?\ \ Zoom Developer Forum](/content/blog/how-do-i-capture-participant-data-in-real-time-on-zoom/index.html)

\ Tracking persistent Zoom meeting ID's for participants who leave and rejoin\ \ Zoom Developer Forum](/content/blog/tracking-persistent-zoom-meeting-ids-for-participants-who-leave-and-rejoin/index.html)

\ Debugging issues with automatic cloud recording\ \ Zoom Developer Forum](/content/blog/debugging-issues-with-automatic-cloud-recording/index.html)

\ Tracking Zoom meetings when using Zoom PMI\ \ Zoom Developer Forum](/content/blog/tracking-zoom-meetings-when-using-zoom-pmi/index.html)

\ Encoding raw video from the Zoom SDK to MP4\ \ Zoom Developer Forum](/content/blog/encoding-raw-video-from-the-zoom-sdk/index.html)

\ Handling crashes in the Zoom Windows Meeting SDK\ \ Zoom Developer Forum](/content/blog/handling-crashes-in-the-zoom-windows-meeting-sdk/index.html)

\ Encoding raw video frames from the Zoom SDK to JPEG\ \ Zoom Developer Forum](/content/blog/decoding-raw-video-frames-from-the-zoom-sdk/index.html)

\ Recording meetings without a local recording token\ \ Zoom Developer Forum](/content/blog/recording-meetings-without-a-local-recording-token/index.html)

\ Resolving intermittent errors while downloading Zoom transcripts\ \ Zoom Developer Forum](/content/blog/resolving-intermittent-errors-while-downloading-zoom-transcripts/index.html)

\ Applying account-level settings to meetings created via the Zoom API\ \ Zoom Developer Forum](/content/blog/applying-account-level-settings-to-meetings-created-via-the-zoom-api/index.html)

\ Why can't my bot join meetings hosted by people outside of my Zoom organization?\ \ Zoom Developer Forum](/content/blog/why-cant-my-bot-join-meetings-hosted-by-people-outside-of-my-zoom-organization/index.html)

\ Zoom Cloud Recording API: Transcript Tutorial, Common Pitfalls, and Workarounds\ \ Tutorials](/content/blog/zoom-transcript-api/index.html)

\ Zoom Meeting Bots: What They Are and Solutions\ \ Deep Dive](/content/blog/zoom-bots/index.html)

\ Can I access gallery view video on the Linux SDK?\ \ Zoom Developer Forum](/content/blog/can-i-access-gallery-view-video-on-the-linux-sdk/index.html)

\ How to access the Zoom Archive Files API\ \ Zoom Developer Forum](/content/blog/how-to-access-the-zoom-archive-files-api/index.html)

\ Resolving Web SDK local recording errors\ \ Zoom Developer Forum](/content/blog/resolving-web-sdk-local-recording-errors/index.html)

\ How to resolve permissions errors with the Zoom Users API\ \ Zoom Developer Forum](/content/blog/how-to-resolve-permissions-errors-with-the-zoom-users-api/index.html)

\ How to track participant name changes in breakout rooms\ \ Zoom Developer Forum](/content/blog/how-to-track-participant-name-changes-in-breakout-rooms/index.html)

\ Why is the transcript.completed webhook not firing?\ \ Zoom Developer Forum](/content/blog/zoom-sdk-webhook-not-firing/index.html)

\ Debugging Zoom SDK failed video capture\ \ Zoom Developer Forum](/content/blog/zoom-sdk-debugging-video-capture/index.html)

\ Capturing video and audio streams through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-receiving-video-streams/index.html)

\ Can you suppress the Zoom recording consent prompt through the SDK?\ \ Zoom Developer Forum](/content/blog/zoom-sdk-suppressing-consent-prompt/index.html)

\ How to stream video to a meeting through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-streaming-video-to-meeting/index.html)

\ Sending chat messages through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-sending-chat-messages/index.html)

\ Handling Zoom app OAuth redirect URL requirements\ \ Zoom Developer Forum](/content/blog/zoom-app-oauth-redirect-url-requirement/index.html)

\ Debugging missing Zoom transcription webhooks\ \ Zoom Developer Forum](/content/blog/zoom-sdk-missing-transcription-webhooks/index.html)

\ Handling Zoom SDK recording compliance requirements\ \ Zoom Developer Forum](/content/blog/zoom-sdk-compliance-requirements/index.html)

\ Handling Zoom app verification\ \ Zoom Developer Forum](/content/blog/zoom-app-domain-verification/index.html)

\ How to join registration-required meetings with a Zoom bot\ \ Zoom Developer Forum](/content/blog/zoom-sdk-joining-registration-required-meetings/index.html)

\ How to use a Zoom bot to join meetings without passcodes\ \ Zoom Developer Forum](/content/blog/zoom-sdk-joining-meetings-without-passcodes/index.html)

\ Debugging stuck Zoom SDK bots\ \ Zoom Developer Forum](/content/blog/zoom-sdk-docker-issues/index.html)

\ How to create a voice agent to send to Zoom meetings\ \ Zoom Developer Forum](/content/blog/zoom-sdk-ai-voice-agent/index.html)

\ How to capture Zoom audio and video with a meeting bot\ \ Zoom Developer Forum](/content/blog/create-audio-video-capture-bots/index.html)

\ How to transcribe a Zoom meeting in real time\ \ Zoom Developer Forum](/content/blog/accessing-real-time-transcription-api/index.html)

\ How to receive raw video from the Zoom SDK\ \ Zoom Developer Forum](/content/blog/accessing-host-video-stream/index.html)

\ Announcing the Recall.ai and Speechmatics Integration\ \ Announcements](/content/blog/announcing-the-recall-ai-and-speechmatics-integration/index.html)

\ Can I access raw audio data using the Zoom Web SDK?\ \ Zoom Developer Forum](/content/blog/can-i-access-raw-audio-data-using-the-zoom-web-sdk/index.html)

Go To Blog

Tutorials

How to build a meeting bot

Updated at:

March 12, 2026

.avif)

Written By:

Maggie Veltri

Table of Contents

Building a meeting bot from scratch usually means juggling SDKs, app reviews, raw media handling, and storage for each meeting platform (Zoom, Google Meet, Microsoft Teams, etc.). In this tutorial, I’ll skip that complexity. Using a single, platform-agnostic API, I’ll show you how to create a bot that can:

  • Join meetings on Zoom, Google Meet, Microsoft Teams, and more
  • Stream transcripts, video, and audio in real time
  • Deliver post-meeting recordings
  • Notify your backend of events via webhooks

By the end, you’ll have a production-ready meeting bot that feeds conversation data to your app and can even send data back into live meetings.

What is a meeting recording bot?

Before I dive into how to build a meeting bot, let’s take a moment to cover what a meeting bot is. A meeting bot is a way for your app to capture data from meetings. It appears in the meeting like a participant does, taking up another participant tile.

Meeting bots can join meetings on Zoom, Google Meet, Microsoft Teams, or other platforms, capture audio/video for real-time or post-meeting delivery, and produce transcripts while the meeting is happening or after it ends. People often use “meeting bot,” “meeting recording bot,” and “bot recording” interchangeably. In this article, we'll also use these terms interchangeably.

Beyond using an API to send a meeting bot to a meeting, there are other ways to get meeting data: first-party APIs and desktop recording SDKs. This guide focuses on adding a bot to a meeting with the Recall.ai Meeting Bot API because it combines simplicity, reliability, and speed, and allows users to send data into the meeting. If you want to build a meeting bot from scratch, check out our posts on how to build a Zoom bot, how to build a Google Meet bot, and how to build a Microsoft Teams bot.

How to build a meeting bot - YouTube

Tap to unmute

How to build a meeting bot RecallAI

RecallAI288 subscribers

Prerequisites to building a meeting bot

You need three things before you write a line of code:

  1. A Recall.ai account + API key, you can sign up for free
  2. A meeting URL (Zoom, Google Meet, or Microsoft Teams).
    • During development, it’s fine to paste a URL directly into your request. But in production, you may not want to rely on your end users manually copying and pasting links every time they want a bot to join their meeting. That’s where a calendar integration helps: once a user has connected their calendar, your app can automatically detect upcoming meetings and pass the correct URL to the bot without any manual input from the user.
  3. A backend that can receive webhooks. During development, expose your local server using ngrok (or any other tunneling tool) so Recall.ai can reach it. With a free ngrok account, you can claim one static domain, which is a persistent subdomain that won’t change every time you start ngrok. Set the NGROK_BASE in the example code to your static domain, which will look something like https://random-subdomain-here.ngrok-free.app.

For this tutorial I used Python + FastAPI in the examples, but the API is language-agnostic. If you prefer TypeScript, you can check out How to get recordings from Zoom or How to get transcripts from Zoom, which go over how to add a Zoom bot in TypeScript.

Step 1: Create a bot

The first step is to tell Recall.ai which meeting to join and what to deliver back. I wrote a simple server that calls Create Bot with a meeting_url and a recording_config that specifies how to capture and deliver the transcripts, audio, video, and participant events. Because I opted to add real-time data, I also registered where Recall.ai should push that data. I used a webhook URL to receive transcript and participant events, and a WebSocket to receive raw media frames.

app/pythonHowToBuildABot.py — call create bot endpoint (/start)

1import os, json, base64

2from collections import defaultdict

3

4import httpx

5from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect

6from fastapi.responses import JSONResponse

7import uvicorn

8from starlette.websockets import WebSocketDisconnect, WebSocketState

9

10API = f"https://{RECALL_REGION}.recall.ai/api/v1"

11H = {"Authorization": f"Token {RECALL_API_KEY}", "Content-Type": "application/json"}

12

13@app.post("/start")

14asyncdefstart(payload: dict):

15assert http isnotNone, "HTTP client not initialized"

16

17 meeting_url = payload["meeting_url"]

18 display_name = payload.get("display_name", "Recall Bot")

19 platform = payload.get("platform")

20 meeting_pw = payload.get("meeting_password")

21 ws_url = payload.get("ws_url") # e.g., f"{NGROK_BASE}/ws/rt?token={WS_TOKEN}"

22 realtime_video = payload.get("realtime_video") # None | "png" | "h264"

23

24 endpoints = [{

25"type": "webhook",

26"url": f"{NGROK_BASE}/wh",

27"events": [

28"transcript.data",

29"participant_events.join","participant_events.leave","participant_events.update",

30"participant_events.speech_on","participant_events.speech_off",

31"participant_events.webcam_on","participant_events.webcam_off",

32"participant_events.screenshare_on","participant_events.screenshare_off",

33"participant_events.chat_message"

34 ],

35 }]

36

37if ws_url:

38# MEDIA ONLY on WS

39 ws_events = ["audio_mixed_raw.data"]

40if realtime_video == "png": ws_events.append("video_separate_png.data")

41if realtime_video == "h264": ws_events.append("video_separate_h264.data")

42 endpoints.append({"type": "websocket", "url": ws_url, "events": ws_events})

43

44 recording_config = {

45"transcript": {"provider": {"recallai_streaming": {

46"language_code": "en", "filter_profanity": False, "mode": "prioritize_low_latency"

47 }}},

48"participant_events": {},

49"meeting_metadata": {},

50"start_recording_on": payload.get("start_recording_on", "participant_join"),

51"realtime_endpoints": endpoints,

52"audio_mixed_raw": {},

53 }

54

55# PNG only — add variant to be safe (helps separate video reliability)

56if realtime_video == "png":

57 recording_config.update({

58"video_separate_png": {},

59"video_mixed_layout": "gallery_view_v2",

60"variant": {"zoom": "web_4_core", "google_meet": "web_4_core", "microsoft_teams": "web_4_core"}

61 })

62elif realtime_video == "h264":

63 recording_config.update({

64"video_separate_h264": {},

65"video_mixed_layout": "gallery_view_v2",

66"variant": {"zoom": "web_4_core", "google_meet": "web_4_core", "microsoft_teams": "web_4_core"}

67 })

68

69 body = {"meeting_url": meeting_url, "bot_name": display_name, "recording_config": recording_config}

70if platform: body["platform"] = platform

71if meeting_pw: body["meeting_password"] = meeting_pw

72

73# Helpful to confirm subscriptions quickly

74 log("[start] recording_config =", json.dumps(recording_config, separators=(",", ":"), ensure_ascii=False)[:2000])

75

76try:

77 r = await http.post(f"{API}/bot", headers=H, json=body)

78 r.raise_for_status()

79except httpx.HTTPStatusError as e:

80return JSONResponse(

81 {"ok": False, "status": e.response.status_code, "url": str(e.request.url), "body": e.response.text},

82 status_code=502

83 )

84

85 bot = r.json()

86 bot_id = bot.get("id") or bot.get("bot_id") or bot.get("uuid") or (bot.get("data") or {}).get("id")

87return {"bot_id": bot_id, "webhook": f"{NGROK_BASE}/wh", "websocket": ws_url orNone, "video": realtime_video or"none"}

... See all 88 lines

The API responds with a bot_id. Copy it. Keep it. You’ll need it to retrieve artifacts later.

For an exhaustive example cURL request, check out the appendix. If you want your bot to display a video or image, or play audio into the meeting, you can configure output settings in the same recording_config. I’ll cover that later in the Output Media section.

One practical note: ad hoc meetings often start without a scheduled calendar event. That’s fine. Bots can join after a meeting has begun. Simply call the Create Bot endpoint with the meeting URL and a bot will join the meeting and begin recording.

Create bot cURL request

1# Create bot (uses recallai_streaming as the transcription provider; expands $NGROK_BASE for your webhook)

2curl -X POST "https://$RECALL_REGION.recall.ai/api/v1/bot" \

3 -H "authorization: Token $RECALL_API_KEY" \

4 -H "content-type: application/json" \

5 -d '{

6 "meeting_url": "PASTE_YOUR_MEETING_LINK",

7 "recording_config": {

8 "transcript": { "provider": { "recallai_streaming": { "language_code": "en" } } },

9 "video_mixed_mp4": {},

10 "audio_mixed_mp3": {},

11 "realtime_endpoints": [

12 {

13 "type": "webhook",

14 "url": "'"$NGROK_BASE"'/wh",

15 "events": ["transcript.data", "bot.status_change"]

16 }

17 ]

18 }

19 }'

... See all 20 lines

Step 2: Subscribe to real-time events

Once the bot is in the meeting, Recall.ai can send events to your app. The meeting bot I built gets all of the most commonly requested real-time data: transcripts, participant events (join, leave, mute, camera on/off, etc.), and bot status changes.

I’ll first focus on bot status changes. Bot status changes tell your app what state the bot is in (such as in_waiting_room, call_ended, etc.). Here I’ll subscribe to bot.status_change webhook events to figure out when a bot has left the meeting so that I can fetch the post-meeting artifacts. To subscribe to these events, I’ll go to the Recall.ai dashboard, select Webhooks on the left side, click Add Endpoint, paste the URL that I’ll be using to listen for events (the ngrok domain in my case), and select bot.status_change under bot in Subscribe to events. Once I’ve done that, I’ll get a webhook event from Recall.ai whenever a bot status change occurs. I recommend listening for the bot.done event, which indicates that the bot is done and the artifacts are ready for download.

How to get real-time transcripts and participant events

I’ll also receive transcripts, participant events, etc., in real time via webhook events.

Within the recording_config from step 1, I had specified recallai_streaming as my transcription provider — this allows me to receive a post-meeting transcript. In order to get real-time transcripts, I also need to subscribe to a real-time endpoint for transcripts like this:

1"realtime_endpoints": [

2 {

3"type": "webhook",

4"url": "'"$NGROK_BASE"'/wh",

5"events": [

6"transcript.data",

7 ]

8 }

9]

To receive a real-time transcript, I listen for transcript.data events. In order to receive and respond appropriately to the transcript events, I need a minimalist webhook handler that does three things: parse JSON, branch by event, and ack fast. I put together a small webhook handler:

app/pythonHowToBuildABot.py — /wh (excerpt)

1from fastapi import Request

2from fastapi.responses import JSONResponse

3

4@app.post("/wh")

5asyncdefwh(req: Request):

6try:

7 p = await req.json()

8except Exception:

9 raw = await req.body()

10print("[wh] non-JSON payload:", raw[:200])

11return JSONResponse({"ok": False, "error": "bad json"}, status_code=400)

12

13 ev = p.get("event") or p.get("type", "")

14 dat = p.get("data") or p.get("payload") or {}

15 dd = dat.get("data") or {}

16

17if ev == "transcript.data":

18 words = dd.get("words") or []

19 text = " ".join((w.get("text","") for w in words)) or dd.get("text","") or""

20 who = (dd.get("participant") or {}).get("name")

21print(f"[wh] transcript speaker={who} text={text}")

22

23elif ev == "bot.status_change":

24 code = dd.get("code")

25print(f"[wh] bot.status_change code={code}")

26if code == "bot.done":

27# Artifacts are ready via Retrieve Bot.

28pass

29

30# TODO: handle participant_events.* similarly (join/leave/update/chat)

31

32# Return quickly (2xx) to avoid retries; enqueue heavy work in the background.

33return JSONResponse({"ok": True})

... See all 34 lines

You might have noticed the comment in my webhook handler to return quickly. Because Recall.ai may retry if my server doesn’t acknowledge quickly, I return a 2xx response immediately, queue work in the background, and make writes idempotent so duplicates don’t cause problems. For more on retries and idempotency patterns, see the appendix.

For a full look at the options for transcript and participant events, check out the docs or take a look at the GitHub repo for the config.

How to get real-time audio and video

Webhooks are perfect for structured events like transcripts, participant updates, and bot status changes. But because I want real-time video and audio as well, I need to establish a WebSocket connection.

Once I open the WebSocket connection, Recall.ai streams binary-encoded audio and video data directly to my server in real time. Since audio and video frames arrive many times per second, the stream generally runs at a higher frequency and bandwidth than webhook events. This makes real-time streams received via WebSockets a better fit for use cases like observer rooms or deepfake detection, which all require real-time analysis. I put together a minimal WebSocket handler below to subscribe and start decoding those events:

app/pythonHowToBuildABot.py — /ws/rt (excerpt)

1import json, base64

2from fastapi import WebSocket, WebSocketDisconnect

3@app.websocket("/ws/rt")

4asyncdefws_rt(ws: WebSocket):

5await ws.accept()

6try:

7whileTrue:

8try:

9 txt = await ws.receive_text()

10except WebSocketDisconnect:

11break

12try:

13 e = json.loads(txt)

14except Exception:

15print("[ws] bad-json")

16continue

17 ev = e.get("event")

18 d = e.get("data") or {}

19 dd = d.get("data") or {}

20 buf = dd.get("buffer")

21if ev == "audio_mixed_raw.data"and buf:

22 n = len(base64.b64decode(buf))

23print(f"[ws] audio {(n//32)}ms {n/1024:.1f}KB") # 16kHz mono s16le → 32 bytes ≈ 1ms

24elif ev == "video_separate_png.data"and buf:

25 raw = base64.b64decode(buf)

26 who = (dd.get("participant") or {}).get("name")

27print(f"[ws] png {len(raw)/1024:.1f}KB {who}")

28elif ev == "video_separate_h264.data"and buf:

29 n = len(base64.b64decode(buf))

30 who = (dd.get("participant") or {}).get("name")

31print(f"[ws] h264 {n/1024:.1f}KB {who}")

32elif ev == "transcript.data":

33 words = dd.get("words") or []

34 text = " ".join((w.get("text","") for w in words)) or dd.get("text","") or""

35 who = (dd.get("participant") or {}).get("name")

36print(f"[ws] transcript {who}: {text}")

37else:

38print("[ws] event", ev)

39finally:

40print("[ws] disconnected")

... See all 41 lines

With both the webhook registered and WebSocket connection established, I’ll start seeing events flow into my terminal. Transcript lines will print out with speaker names, audio events will log their frame sizes and timestamps, and I’ll even see per-participant PNG or H.264 video frames being received in real time. So at this point, my meeting bot is sending my app all data in real time.

Step 3: Retrieve recordings and transcripts post-meeting

As long as the bot is in the meeting, artifacts are still being generated. In order to fetch the artifacts generated by the meeting, the bot must no longer be in the meeting and the bot status must be bot.done. At that point, any processing has finished and I pull all of the data collected about the meeting with a single call to the Retrieve Bot endpoint:

app/pythonHowToBuildABot.py — /retrieve/{bot_id} (excerpt)

1@app.get("/retrieve/{bot_id}")

2asyncdefretrieve(bot_id: str):

3 r = await http.get(f"{API}/bot/{bot_id}", headers=H)

4 r.raise_for_status()

5return r.json()

The response generated when I hit this endpoint includes the download URLs for the audio, video, transcript, metadata, and participant events that I specified in my recording_config. The response also includes meeting chat data and meeting metadata like start/end time.

To get the download_urls, I read the media_shortcuts field in the JSON response for the recording. Think of media_shortcuts as a little bundle of ready-to-use links for the most common outputs (final transcript JSON, mixed MP4 video, participant events, metadata, and mixed MP3 audio) already scoped to that recording. The URLs are pre-signed links to the artifacts stored in S3. Don’t cache them. Instead, store the bot_id and fetch fresh links whenever you need to play or download.

Here's an abbreviated example of the JSON response you might get from the Retrieve Bot endpoint:

1{

2"id":"[BOT_ID]",

3"status":{"code":"bot.done"},

4"recordings":[

5{

6"id":"[RECORDING_ID]",

7"media_shortcuts":{

8"transcript":{"data":{"download_url":"https://..."}},

9"video_mixed":{"data":{"download_url":"https://..."}},

10"audio_mixed":{"data":{"download_url":"https://..."}}

11}

12}

13]

14}

... See all 14 lines

A few things to note:

  • Keys only appear for assets you enabled in your recording_config.
  • Meetings can produce multiple recordings (see the appendix to learn more). If your bot has multiple recordings, iterate through them.

As a quick check, I open each media_shortcuts``download_url in a browser to verify the transcript and recordings.

Once you’ve verified the download_urls, you can move on to handling them programmatically. For a JSON parsing example that extracts download_urls, see the appendix. If you’re after less-common artifacts that won’t be found in the media_shortcuts, the appendix covers those too.

For a deeper walkthrough that focuses only on specific artifacts, see How to get transcripts from Zoom and How to get recordings from Zoom.

How to store meeting artifacts

Now that I’ve got the meeting artifacts, the next step is figuring out how and where to store them. For this tutorial, I’m using Recall.ai’s Storage and Playback because it’s by far the simplest option and saves me from having to manage my own storage setup. Plus, it keeps everything easily accessible whenever I need it.

Storage & Playback (default, recommended)

Since I’ve opted to use Recall.ai’s Storage and Playback option, I don’t need to do anything in order to store my recordings and transcripts. For this tutorial, I keep the bot.id in memory. In production, persist it (e.g., add a recording field/table) so you can fetch fresh links on demand. Any time I need the transcript JSON or MP4/MP3, I call the Retrieve Bot endpoint with the bot.id and read the media_shortcuts on each recording. The links in media_shortcuts are pre-signed and short-lived by design, so don’t cache them. Instead, fetch fresh URLs when you need to play or download. If I needed recordings that were not included in media_shortcuts I could also store the recording.id and hit the Retrieve Recording endpoint to access less common media like speaker-separated audio.

Alternative: Zero-data retention

If your subprocessor policy requires that Recall.ai never retains data, set the retention field in recording_config to null for zero-data retention. Recall.ai won’t store anything, so you’ll need to stream and save the data yourself.

If, however, your policy allows Recall.ai to hold data briefly, you can listen for the bot.done event, grab the artifacts from media_shortcuts right away by hitting the Retrieve Bot endpoint, and then expire the data. This gives you a short window to download everything before it’s permanently deleted.

Be aware: once you delete/expire the data from Recall.ai, if you lose your copy, the data is gone.

For more tips on fetching and storing data, see the appendix.

Step 4: Process the data from your bot recording

I’ve gone over how to get post-meeting artifacts (via media_shortcuts). The next step is to turn the data you’ve collected into things users actually use (readable notes, action items, search, and shareable links) without locking you into a particular stack or storage choice. If you want to go further, you can also layer in context about who spoke, who shared screens, and what was shown.

I’ll do four small passes (and toss in an optional fifth for fun):

  1. Load & flatten the transcript into consistent segments
  2. Shape segments into readable paragraphs (speaker-grouped)
  3. Summarize & extract actionable items (LLM-agnostic hook)
  4. Search & deliver results where your users live (Slack/email/your UI)
  5. (Optional) Enrich with participant, audio, and video context

Below are tight Python excerpts. Full helpers live in the appendix.

4.1 Prepare the transcript for processing

Recall.ai already provides transcripts in a normalized, speaker-grouped format for both real-time and post-meeting use. Each transcript entry includes a participant object (with speaker metadata) and a list of words, each containing text and timestamps.

Before you group or summarize, flatten this structure into a simpler list of {speaker, start_ms, end_ms, text} objects. This keeps downstream steps, like paragraph grouping, summarization, and timestamp linking, clean and consistent.

Here’s a quick helper that loads the transcript JSON and flattens it into that shape:

app/step4.py

1defto_segments(transcript_json: Any) -> List[Dict[str, Any]]:

2"""

3 Convert a normalized Recall.ai transcript into a flat list of text segments.

4 Input shape (guaranteed by Recall.ai):

5 [

6 {

7 "participant": { "name": "Alice", ... },

8 "words": [

9 { "text": "hello", "start_timestamp": {"relative": 0.0}, "end_timestamp": {"relative": 0.4} },

10 { "text": "everyone", "start_timestamp": {"relative": 0.4}, "end_timestamp": {"relative": 1.0} }

11 ]

12 },

13 ...

14 ]

15 Output shape (used by downstream helpers):

16 [

17 { "speaker": "Alice", "start_ms": 0, "end_ms": 1000, "text": "hello everyone" },

18 ...

19 ]

20 """

21# If the payload isn't the expected list (e.g., empty or malformed), return nothing.

22ifnotisinstance(transcript_json, list):

23return []

24

25 out: List[Dict[str, Any]] = []

26

27# Each entry represents one speaker turn (a participant + their spoken words)

28for entry in transcript_json:

29 words = (entry or {}).get("words") or []

30ifnot words ornotisinstance(words, list):

31continue# skip empty or invalid word lists

32

33# Extract relative timestamps (in seconds) and convert to milliseconds.

34# We use the first word's start and last word's end to bound the segment.

35try:

36 start_rel = float(words[0]["start_timestamp"]["relative"])

37 end_rel = float(words[-1]["end_timestamp"]["relative"])

38except (KeyError, TypeError, ValueError):

39# If timestamps are missing or malformed, skip this segment.

40continue

41

42# Join all word texts into a single line of readable text.

43 text = " ".join((str(w.get("text", "")).strip() for w in words)).strip()

44ifnot text:

45continue

46

47# Use the participant's display name if available.

48 speaker = ((entry.get("participant") or {}).get("name")) orNone

49

50# Append the normalized segment.

51 out.append({

52"speaker": speaker,

53"start_ms": int(start_rel * 1000),

54"end_ms": int(end_rel * 1000),

55"text": text

56 })

57

58return out

... See all 59 lines

4.2 Make the transcript readable

Once you’ve flattened the transcript, you’ll group adjacent segments by speaker with a small time gap. Grouping adjacent segments makes transcripts readable because, while transcripts often arrive as word bursts, users tend to read paragraphs.

app/step4.py - paragraphs (excerpt)

1from typing importList, Dict, Any

2

3defto_paragraphs(segments: List[Dict[str, Any]], gap_ms: int = 1200) -> List[Dict[str, Any]]:

4"""

5 Merge consecutive segments if the speaker stays the same and the gap < gap_ms.

6 """

7 out: List[Dict[str, Any]] = []

8 cur: Dict[str, Any] | None = None

9

10for s insorted(segments, key=lambda x: x["start_ms"]):

11 new_block = (

12 cur isNoneor

13 s.get("speaker") != cur.get("speaker") or

14 s["start_ms"] - cur["end_ms"] > gap_ms

15 )

16if new_block:

17if cur: out.append(cur)

18 cur = dict(s)

19else:

20 cur["end_ms"] = max(cur["end_ms"], s["end_ms"])

21 cur["text"] = (cur["text"] + " " + s["text"]).strip()

22if cur: out.append(cur)

23return out

... See all 24 lines

4.3 Turn conversation into summaries and action items (LLM-agnostic)

Now that you have clean paragraphs, you can feed them into any LLM or summarization model to produce structured notes.

Keeping this step model-agnostic by using a generate() that you control lets you try different providers without rewriting your pipeline to find the best fit for your use case.

Ask for structured JSON with timestamp anchors so you can link users back to the moment when something was said.

app/step4.py - summarize + actions (excerpt)

1import json

2from typing importCallable, Dict, Any, List

3

4defbuild_summary_and_actions(

5 paragraphs: List[Dict[str, Any]],

6 generate: Callable[[str], str], # plug in your LLM of choice

7 max_chars: int = 15000

  1. -> Dict[str, Any]:

9"""

10 generate(prompt) -> stringified JSON with keys:

11 {

12 "summary": str,

13 "highlights": [{"title": str, "start_ms": int, "end_ms": int, "quote": str}],

14 "action_items": [{"title": str, "owner": str|null, "due_date": str|null, "confidence": float, "evidence_ms": int}]

15 }

16 """

17deffmt_time(ms: int) -> str:

18 s = max(0, ms // 1000); returnf"{s//60:02d}:{s%60:02d}"

19

20 lines = []

21for p in paragraphs:

22 speaker = p.get("speaker") or"Speaker"

23 ts = fmt_time(p.get("start_ms", 0))

24 lines.append(f"[{ts}] {speaker}: {p['text']}")

25 text = "\n".join(lines)[:max_chars]

26

27 prompt = f"""

28You are turning a meeting transcript into structured, linkable notes.

29Return STRICT JSON with keys: summary, highlights[], action_items[].

30- highlights[]: title, start_ms, end_ms, quote (verbatim from the transcript)

31- action_items[]: title, owner (email or null), due_date (YYYY-MM-DD or null), confidence (0..1), evidence_ms

32Only use information present in the transcript below.

33

34Transcript:

35{text}

36""".strip()

37

38 raw = generate(prompt)

39try:

40 data = json.loads(raw)

41 data.setdefault("summary", "")

42 data.setdefault("highlights", [])

43 data.setdefault("action_items", [])

44return data

45except Exception:

46return {"summary": "", "highlights": [], "action_items": []}

... See all 47 lines

4.4 Help users find and share what matters

Once you’ve summarized the meeting, you might want to allow users to explore or share insights.

Even a simple keyword search becomes powerful when each result links straight to the right moment in the recording.

The snippets below show a basic in-memory search and how to deliver highlights via Slack or your own UI. You can transition from in-memory to your own database.

app/step4.py - search + jump links (excerpt)

1from typing importList, Dict, Any, Tuple

2

3defsearch_paragraphs(paragraphs: List[Dict[str, Any]], query: str, limit: int = 5

  1. -> List[Tuple[Dict[str, Any], int]]:

5 q = query.lower().strip()

6 scored: List[Tuple[Dict[str, Any], int]] = []

7for p in paragraphs:

8 score = p["text"].lower().count(q)

9if score: scored.append((p, score))

10 scored.sort(key=lambda t: (-t[1], t[0]["start_ms"]))

11return scored[:limit]

12

13defjump_link(base_play_url: str, start_ms: int) -> str:

14"""

15 Create a link your player can understand (e.g., /play?bot_id=...&t=342).

16 Keep it aligned with how your frontend seeks into the MP4.

17 """

18 sec = max(0, start_ms // 1000)

19 sep = "&"if"?"in base_play_url else"?"

20returnf"{base_play_url}{sep}t={sec}"

... See all 21 lines

And here’s a minimal Slack helper that uses jump_link() to post highlights to your chosen channel.

app/step4.py — Slack webhook (excerpt)

1import httpx

2from typing importList, Dict, Any

3

4asyncdefpost_to_slack(webhook_url: str, title: str, highlights: List[Dict[str, Any]], base_play_url: str):

5 blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": f"*{title}*"}}]

6for h in highlights[:5]:

7 t = jump_link(base_play_url, h.get("start_ms", 0))

8 blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"• {h.get('title','')} — <{t}|jump>"}})

9asyncwith httpx.AsyncClient(timeout=15) as http:

10await http.post(webhook_url, json={"blocks": blocks})

... See all 11 lines

Check out the appendix for one FastAPI route that puts all of these steps together.

4.5 Enrich the meeting with participant, audio, and video context (optional)

Once you’ve built a transcript-based view, you can layer in other meeting signals to give users richer context.

This step is optional, but it’s what turns your transcript into a more complete view of the meeting, combining what was said with what was happening in the room.

See what other participants were doing during the meeting

Participant events such as join/leave, mute/unmute, camera on/off, screenshare on/off, and chat messages, can be attached to the transcript paragraphs that occur during each event’s timestamp window.

app/step4.py - events → paragraphs (excerpt)

1import httpx

2from typing importAny, Dict, List

3

4asyncdefload_events_json(url: str) -> Dict[str, Any]:

5asyncwith httpx.AsyncClient(timeout=60) as http:

6 r = await http.get(url)

7 r.raise_for_status()

8return r.json()

9

10defenrich_paragraphs_with_events(paragraphs: List[Dict[str, Any]], events: List[Dict[str, Any]]):

11"""Attach participant events to paragraphs that overlap in time."""

12for p in paragraphs:

13 start, end = p.get("start_ms", 0), p.get("end_ms", 0)

14 p["events"] = [e for e in events if start <= e.get("ts_ms", 0) <= end]

... See all 15 lines

This lets you show small badges like [chat] or [camera on] next to each paragraph or visualize a timeline of meeting activity.

Understand who spoke the most (and for how long)

You can use Recall.ai’s speech_on and speech_off events to calculate speaker talk time.

You can also approximate speaker talk time by summing each speaker’s paragraph durations.

app/step4.py - speaker stats (excerpt)

1from typing importList, Dict, Any, Optional

2from collections import defaultdict

3

4defcompute_speaker_stats(paragraphs: List[Dict[str, Any]], events: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Dict[str, int]]:

5"""

6 Returns per-speaker totals:

7 { speaker: { "talk_time_ms": int, "turns": int }, ... }

8 Prefers speech_on/off events; falls back to paragraph durations.

9 """

10 segs = defaultdict(list)

11 on: Dict[str, int] = {}

12

13# Prefer accurate talk-windows from events if present

14for e in (events or []):

15 who = (e.get("participant") or {}).get("name") or"Speaker"

16if e.get("type") == "speech_on":

17 on[who] = e.get("ts_ms", 0)

18elif e.get("type") == "speech_off"and who in on:

19 segs[who].append((on.pop(who), e.get("ts_ms", 0)))

20

21# Fallback to paragraph durations

22ifnot segs:

23for p in paragraphs:

24 who = p.get("speaker") or"Speaker"

25 segs[who].append((p.get("start_ms", 0), p.get("end_ms", 0)))

26

27return {

28 spk: {

29"talk_time_ms": sum(b - a for a, b in segs[spk]),

30"turns": len(segs[spk]),

31 }

32for spk in segs

33 }

... See all 34 lines

Show what was happening on screen

To help users see what was happening, you can grab thumbnails from the mixed MP4 at paragraph starts or at screenshare events.

This example uses FFmpeg, but any thumbnail-generation library or service works.

app/step4.py - video thumbnails (excerpt)

1import subprocess

2from pathlib import Path

3

4defthumbnail_at(video_path: str, ms: int, out_dir: str) -> str:

5"""Extract a single frame from an MP4 at the given millisecond timestamp."""

6 out = Path(out_dir) / f"thumb_{ms}.jpg"

7 ss = f"{ms/1000:.3f}"

8 subprocess.run(

9 ["ffmpeg", "-y", "-ss", ss, "-i", video_path,

10"-frames:v", "1", "-vf", "scale=480:-1", str(out)],

11 check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL

12 )

13returnstr(out)

... See all 14 lines

Each thumbnail can be paired with a jump_link() so users can click straight to that timestamp in playback.

Why meeting context matters

Transcripts show what was said, which is enough for summaries. To create action items and gather basic analytics, speaker diarization separates the content by speaker. Participant events fill in the rest: who joined or left, muted or unmuted, turned cameras on or off, or shared screens. Thumbnails add quick visual cues so users can see what was happening at key moments.

In your UI, these enrichments can enable a timeline of speaking activity and screenshares, such as a speaker sidebar with talk-time stats, and a highlights grid with thumbnails and jump links. Together, they turn a transcript into a meeting record that captures both the conversation and the flow of the meeting. These are just a few examples. How you use and build on conversation data is entirely up to you.

Optional: Stream video/audio into the meeting

Recall.ai Output Media API: send AI agents to your meetings - YouTube

Tap to unmute

Recall.ai Output Media API: send AI agents to your meetings RecallAI

RecallAI288 subscribers

I’ve talked about the data that my meeting bot can get out of a meeting and what it can do with that data, but because my bot is a meeting participant, it can do more than ingest meeting data; it can also output content into the meeting. I’ll use Recall.ai’s Output Media API to display video, audio, images, and more in the meeting.

Using Output Media allows my bot to programmatically show a “Recording in progress” slate, give the bot a custom avatar and voice to interact in the meeting, play hold music, or write chat messages.

I can also use Recall.ai to embed AI agents inside meetings. With the conversation data Recall.ai streams from the meeting to my service, agents can reason about what’s happening and then generate output to push back into the meeting. Because this tutorial is not about building AI agents, I won’t dive deep into this, but you can check out our docs if you want to learn more.

Where Output Media lives in the Meeting Bot API

To enable a bot to send data back into the meeting and to have it record its own audio, configure output settings inside recording_config when creating the bot via the Create Bot endpoint. In the code snippet below, I set output_media, automatic_video_output, automatic_audio_output.

app/pythonHowToBuildABot — /start (output media excerpt)

1recording_config.update({

2"automatic_video_output": {

3"in_call_recording": {

4# Pick exactly one of these (example placeholders):

5# "image_url": "https://yourcdn.example/slates/recording.png"

6# "webpage": { "url": "https://yourapp.example/live-preview" }

7 },

8"in_call_not_recording": {

9# Optional fallback image/webpage

10# "image_url": "https://yourcdn.example/slates/standby.png"

11 }

12 },

13"automatic_audio_output": {

14"in_call_recording": {

15# Optional audio:

16# "audio_url": "https://yourcdn.example/audio/hold.ogg"

17 }

18 },

19"output_media": {

20# Choose exactly one per docs; example:

21# "webpage": { "url": "https://yourapp.example/demo" }

22# or "camera": {...} or "screenshare": {...}

23 }

24})

... See all 25 lines

If I wanted to change what the bot was outputting during a meeting, I could use the Stream Media endpoints to swap sources on the fly (for example, swap from a “Recording in progress” slate to an AI avatar).

Conclusion

At this point, you’ve seen the full arc: Create a bot -> subscribe to events -> retrieve recordings -> process and deliver useful insights -> (optionally) stream video/audio into the meeting.

The meeting bot form factor gives you a direct presence in the meeting. Your bot joins like any participant. That visibility means it can both capture data and contribute to the meeting, making it ideal for real-time use cases, interactive agents, or in-meeting displays.

If you don’t like the idea of a bot in the meeting, the Desktop Recording SDK takes a different approach. It lets your app record directly from the user’s device without a bot joining the meeting. This makes it discreet, perfect for on-device capture, or environments where bots can’t join.

For both the Meeting Bot API and the Desktop Recording SDK, you can go from idea to production in days, instead of months.

If you’re ready to build your first bot, sign up for an account, check out the quickstart guide, and start turning conversations into insights your users will actually use. And if you ever need a hand, we’re just a message away because, like our bots, we never really sleep.

Appendix

Exhaustive Create Bot cURL example

Full Create Bot example (appendix): maximal options in one request

1# - Uses recallai_streaming (real-time)

2# - Includes platform + meeting_password

3# - Sets display name + avatar

4# - Captures transcript, mixed A/V, and per-participant audio

5# - Subscribes to webhook + websocket for realtime events

6# - Chooses layout + example capacity variant

7# - Configures output media (image/webpage) independent of recording

8curl -X POST "https://$RECALL_REGION.recall.ai/api/v1/bot" \

9 -H "authorization: Token $RECALL_API_KEY" \

10 -H "content-type: application/json" \

11 -d @- <<'JSON'

12{

13"meeting_url": "PASTE_YOUR_MEETING_LINK",

14"platform": "zoom",

15"meeting_password": "OPTIONAL_MEETING_PASSWORD",

16"bot_name": "YOUR_BOT_NAME",

17"bot_avatar_url": "https://yourcdn.example/assets/bot-avatar.png",

18"external_id": "ext-123",

19"recording_config": {

20"transcript": {

21"provider": {

22"recallai_streaming": {

23"language_code": "en",

24"filter_profanity": false,

25"mode": "prioritize_low_latency"

26 }

27 }

28 },

29"participant_events": {},

30"meeting_metadata": {},

31"video_mixed_mp4": {},

32"audio_mixed_mp3": {},

33"audio_mixed_raw": {},

34"audio_separate_mp3": {},

35"video_separate_h264": {},

36"video_mixed_layout": "gallery_view_v2",

37"include_bot_in_recording": { "audio": true, "video": false },

38"start_recording_on": "participant_join",

39"realtime_endpoints": [

40 {

41"type": "webhook",

42"url": "NGROK_BASE/wh",

43"events": [

44"transcript.data",

45"participant_events.join","participant_events.leave","participant_events.update",

46"participant_events.speech_on","participant_events.speech_off",

47"participant_events.webcam_on","participant_events.webcam_off",

48"participant_events.screenshare_on","participant_events.screenshare_off",

49"participant_events.chat_message",

50"bot.status_change"

51 ]

52 },

53 {

54"type": "websocket",

55"url": "NGROK_BASE/ws/rt?token=WS_TOKEN",

56"events": ["audio_mixed_raw.data","video_separate_h264.data"]

57 }

58 ],

59"automatic_video_output": {

60"in_call_recording": {

61"image_url": "https://yourcdn.example/slates/recording.png"

62 },

63"in_call_not_recording": {

64"image_url": "https://yourcdn.example/slates/standby.png"

65 }

66 },

67"automatic_audio_output": {

68"in_call_recording": {

69"audio_url": "https://yourcdn.example/audio/hold.ogg"

70 }

71 },

72"output_media": {

73"webpage": { "url": "https://yourapp.example/demo" }

74 }

75 }

76}

77JSON

... See all 78 lines

How to use the Recall.ai Calendar Integration

Recall.ai automatically detects and syncs meeting links from your connected calendar events, regardless of the provider (Zoom, Google Meet, Microsoft Teams, etc.).

To enable it:

  1. Go to your Recall.ai dashboard and open the Setup & Integrations tab.
  2. When your users connect their calendars, bots will be scheduled for any upcoming events that include a meeting link.

This integration ensures your Recall.ai meeting bot always joins the correct meeting without you having to write bot scheduling code yourself.

Recommended event matrix & reliability checklist

Event Matrix

Goal Event(s) When to Act
Live captions / coaching transcript.data (and optional transcript.partial_data) Append to UI; stream to LLM
Detect meeting lifecycle bot.status_change Show “joining,” “recording,” “processing,” “done”
Fetch post-meeting artifacts bot.status_change → bot.done Retrieve transcript and media
Ops monitoring bot.status_change (failures) Alert and auto-retry

Webhook reliability, retries, and idempotency

When running in production, you’ll need to account for how Recall.ai delivers webhook events. Unlike your local, happy-path demos, production workloads introduce network latency, occasional downtime, and event retries. This section covers how to make your webhook handling reliable and idempotent.

Why retries happen

Recall.ai retries webhook deliveries if your server doesn’t acknowledge quickly enough with a 2xx status code. This ensures that events aren’t silently dropped due to transient network failures or slow handlers. As a result, you will see duplicate events in production. Your code needs to handle them safely.

The safe handling pattern

  • Ack fast

    • Return a 2xx response as soon as you receive the event.
  • Don’t do heavy work in the webhook handler itself. Instead, enqueue the event (e.g., send it to a job queue like Redis, RabbitMQ, SQS, or a simple async task runner) and let the worker handle the rest.

  • Implement retries for downstream network operations like media downloads. Use exponential backoff and jitter.

  • Deduplicate

    • Each event payload includes enough information to uniquely identify it. Use external_id to correlate the meeting, bot, and storage objects. Maintain a short-TTL event ledger to prevent reprocessing duplicates.
  • A common pattern is to deduplicate on a composite key: (event_type, bot_id, updated_at)

  • For example, if you receive multiple transcript.data events with the same timestamp window, you can safely ignore duplicates.

  • Some events may include an event_id field in the payload. If the event_id is present, that’s the easiest dedupe key.

  • Idempotent writes

    • Database operations should be safe to run more than once.
  • For transcripts, a reliable approach is to UPSERT based on (timestamp, text hash). This ensures that even if you process the same segment twice, you won’t create duplicates.

  • For participant events, UPSERT on (participant_id, updated_at).

  • For all event types, keep a copy of the raw JSON payload with parsed models for auditing and troubleshooting.

Example: Transcript dedupe

Say you receive this transcript event:

1{

2"event":"transcript.data",

3"data":{

4"bot_id":"abc123",

5"updated_at":"2025-10-02T20:41:00Z",

6"data":{

7"start_ms":156000,

8"end_ms":158000,

9"text":"Let's wrap up the agenda."

10}

11}

12}

... See all 12 lines

You can use (event_type, bot_id, updated_at) as your dedupe key, or (start_ms, end_ms, md5(text)) for transcript-specific UPSERTs. Either way, replays won’t create duplicate rows.

Special note: raw media streams

If you subscribe to audio or video via WebSocket, expect high-frequency data delivery (often many messages per second). For example, audio_mixed_raw.data at 16 kHz, mono, signed 16-bit PCM means each audio sample is two bytes, so 32 bytes ≈ 1 ms of audio. That’s why in examples you’ll often see: duration_ms = len(decoded_bytes) // 32.

This calculation is handy when you want to timestamp or segment raw audio buffers consistently. When downloading or processing raw media in production, add retry logic with exponential backoff and jitter to handle transient errors gracefully.

Multiple recordings from a single meeting

A single bot can create multiple recordings when you use the Start Recording/ Stop Recording endpoints to control capture during a meeting. Each time you stop and then start recording, Recall.ai creates a new entry in the bot’s recordings array, each with its own transcript and media files.

If you want one continuous recording instead, use the Pause Recording/ Resume Recording. Pausing halts both recording and real-time transcription but resumes within the same recording file, creating silent or black-screen gaps rather than new segments.

When you call Retrieve Bot, iterate through the recordings array to access each segment’s artifacts via media_shortcuts.

How to parse media shortcuts from JSON response

app/mediahelpers.py — /start (output media excerpt)

1from typing importAny, Dict, List, Optional

2

3defextract_media_shortcuts(bot_json: Dict[str, Any]) -> List[Dict[str, Optional[str]]]:

4"""

5 Returns one dict per recording with fresh, short-lived URLs.

6 If a given artifact wasn't requested or isn't ready, its URL will be None.

7

8 Typical usage:

9 r = await http.get(f"{API}/bot/{bot_id}", headers=H)

10 shortcuts = extract_media_shortcuts(r.json())

11 for s in shortcuts:

12 print("Transcript:", s["transcript_url"])

13 print("Mixed video:", s["video_mixed_url"])

14 print("Mixed audio:", s["audio_mixed_url"])

15 """

16 out: List[Dict[str, Optional[str]]] = []

17

18for rec in bot_json.get("recordings") or []:

19 shortcuts = rec.get("media_shortcuts") or {}

20

21defurl(key: str) -> Optional[str]:

22 node = shortcuts.get(key) or {}

23 data = node.get("data") or {}

24return data.get("download_url")

25

26 out.append({

27"recording_id": rec.get("id"),

28"transcript_url": url("transcript"),

29"video_mixed_url": url("video_mixed"),

30"audio_mixed_url": url("audio_mixed"),

31 })

32

33return out

... See all 34 lines

Fetching Specific Artifacts

For the less-common artifacts, use the recording endpoints. Explicit asset retrieval is required for any of the assets not returned by the Retrieve Bot endpoint. To fetch assets like speaker-separated audio and video, you’ll need to first obtain the specific artifact id (from the Retrieve Bot endpoint) and then hit one of the asset endpoints that Recall.ai exposes, like this one for separate audio:

app/mediaHelpers.py

1import os, httpx

2from typing importOptional, Tuple

3

4RECALL_REGION = os.getenv("RECALL_REGION", "us-east-1")

5RECALL_API_KEY = os.getenv("RECALL_API_KEY", "")

6API = f"https://{RECALL_REGION}.recall.ai/api/v1"

7H = {"Authorization": f"Token {RECALL_API_KEY}", "Content-Type": "application/json"}

8

9asyncdefextract_audio_separate_id(bot_json: dict) -> Optional[str]:

10"""

11 Return the audio-separate artifact id from the Retrieve Bot JSON, if present.

12 Checks several likely keys depending on your recording_config.

13 """

14for rec in bot_json.get("recordings") or []:

15for key in ("audio_separate", "audio_separate_mp3", "audio_separate_raw"):

16 obj = rec.get(key)

17if obj and obj.get("id"):

18return obj["id"]

19returnNone

20

21asyncdefget_audio_separate_download_url(audio_separate_id: str) -> Tuple[Optional[str], Optional[str]]:

22"""

23 GET /audio_separate/{id}/ and return (download_url, format).

24

25 - format: 'raw' or 'ogg' (depends on how the audio was encoded)

26 - download_url: pre-signed link to all per-participant audio parts for that recording

27 """

28asyncwith httpx.AsyncClient(timeout=30) as client:

29 r = await client.get(f"{API}/audio_separate/{audio_separate_id}/", headers=H)

30 r.raise_for_status()

31 data = r.json()

32 d = data.get("data") or {}

33return d.get("download_url"), d.get("format")

... See all 34 lines

Practical tips for fetching and storing meeting data

  • Don’t cache the URLs. Cache IDs (bot.id, recording.id). If a link 403s later, just call the Retrieve Bot endpoint again and you’ll get fresh download_urls.
  • Depending on how users interact with the bot in a meeting, a single meeting can produce multiple recordings. Make sure to iterate through all recordings to get all the data for a meeting as mentioned in the section on multiple recordings for a meeting.
  • You can return the URLs directly to your web client for playback/download; they’re already authorized via the signature, so you don’t forward your API key.

Putting all of Step 4 together

Here’s a single FastAPI route that: loads the transcript from your media_shortcuts, flattens the transcript, builds paragraphs, summarizes + extracts actions, and returns everything your UI needs.

app/analyze.py — /analyze

1from __future__ import annotations

2import json

3from typing importDict, Any, List

4import httpx

5from fastapi import APIRouter, HTTPException

6

7from app.step4 import load_transcript_json, to_segments, to_paragraphs, build_summary_and_actions, jump_link

8

9router = APIRouter()

10

11defmy_generate(prompt: str) -> str:

12# Plug in your LLM call; must return JSON string

13return json.dumps({"summary": "", "highlights": [], "action_items": []})

14

15@router.post("/analyze")

16asyncdefanalyze(payload: Dict[str, Any]):

17"""

18 Body:

19 {

20 "transcript_url": "https://<short-lived-url-from-media_shortcuts>",

21 "base_play_url": "https://yourapp.example/play?bot\_id=...", // optional (for jump links)

22 "slack_webhook_url": null | "https://hooks.slack.com/services/..." // optional

23 }

24 """

25ifnotisinstance(payload, dict):

26raise HTTPException(status_code=400, detail="Body must be a JSON object")

27 transcript_url = payload.get("transcript_url")

28ifnot transcript_url ornotisinstance(transcript_url, str):

29raise HTTPException(status_code=400, detail="Missing required field: transcript_url")

30

31# Load + flatten

32try:

33 tj = await load_transcript_json(transcript_url)

34except httpx.HTTPError as e:

35raise HTTPException(status_code=502, detail=f"Failed to load transcript: {e}") from e

36 segs = to_segments(tj)

37 paras = to_paragraphs(segs)

38

39# Summarize

40 result = build_summary_and_actions(paras, generate=my_generate)

41 summary: str = result.get("summary") or""

42 highlights: List[Dict[str, Any]] = result.get("highlights") or []

43 action_items: List[Dict[str, Any]] = result.get("action_items") or []

44

45# Optional Slack digest

46 slack = payload.get("slack_webhook_url")

47 base = payload.get("base_play_url") or""

48if slack:

49 blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "*Meeting summary*"}}]

50for h in highlights[:5]:

51 title = (h.get("title") or"").strip()

52 start_ms = int(h.get("start_ms") or0)

53if base:

54 link = jump_link(base, start_ms)

55 line = f"• {title} — <{link}|jump>"

56else:

57 mm = max(0, start_ms // 1000) // 60

58 ss = max(0, start_ms // 1000) % 60

59 line = f"• {title} — {mm:02d}:{ss:02d}"

60 blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": line}})

61try:

62asyncwith httpx.AsyncClient(timeout=15) as http:

63await http.post(slack, json={"blocks": blocks})

64except Exception:

65pass# best-effort

66

67return {

68"paragraphs": paras[:200], # trim if needed

69"summary": summary,

70"highlights": highlights,

71"action_items": action_items,

72 }

... See all 73 lines

Helpers for Step 4

app/step4.py — helpers (load, flatten, paragraphs, summarize, search)

1# PII note: transcripts may contain PII. Redact before logging externally.

2

3import httpx, json, subprocess

4from pathlib import Path

5from typing importAny, Dict, List, Callable, Tuple, Optional

6from collections import defaultdict

7

8# Load

9asyncdefload_transcript_json(url: str) -> Any:

10asyncwith httpx.AsyncClient(timeout=60) as http:

11 r = await http.get(url)

12 r.raise_for_status()

13return r.json()

14

15# Flatten into segments

16defto_segments(transcript_json: Any) -> List[Dict[str, Any]]:

17"""

18 Convert a normalized Recall.ai transcript into a flat list of text segments.

19 Input shape (guaranteed by Recall.ai):

20 [

21 {

22 "participant": { "name": "Alice", ... },

23 "words": [

24 { "text": "hello", "start_timestamp": {"relative": 0.0}, "end_timestamp": {"relative": 0.4} },

25 { "text": "everyone", "start_timestamp": {"relative": 0.4}, "end_timestamp": {"relative": 1.0} }

26 ]

27 },

28 ...

29 ]

30 Output shape (used by downstream helpers):

31 [

32 { "speaker": "Alice", "start_ms": 0, "end_ms": 1000, "text": "hello everyone" },

33 ...

34 ]

35 """

36# If the payload isn't the expected list (e.g., empty or malformed), return nothing.

37ifnotisinstance(transcript_json, list):

38return []

39

40 out: List[Dict[str, Any]] = []

41

42# Each entry represents one speaker turn (a participant + their spoken words)

43for entry in transcript_json:

44 words = (entry or {}).get("words") or []

45ifnot words ornotisinstance(words, list):

46continue# skip empty or invalid word lists

47

48# Extract relative timestamps (in seconds) and convert to milliseconds.

49# We use the first word's start and last word's end to bound the segment.

50try:

51 start_rel = float(words[0]["start_timestamp"]["relative"])

52 end_rel = float(words[-1]["end_timestamp"]["relative"])

53except (KeyError, TypeError, ValueError):

54# If timestamps are missing or malformed, skip this segment.

55continue

56

57# Join all word texts into a single line of readable text.

58 text = " ".join((str(w.get("text", "")).strip() for w in words)).strip()

59ifnot text:

60continue

61

62# Use the participant's display name if available.

63 speaker = ((entry.get("participant") or {}).get("name")) orNone

64

65# Append the flattened segment.

66 out.append({

67"speaker": speaker,

68"start_ms": int(start_rel * 1000),

69"end_ms": int(end_rel * 1000),

70"text": text

71 })

72

73return out

74

75# Paragraphs

76defto_paragraphs(segments: List[Dict[str, Any]], gap_ms: int = 1200) -> List[Dict[str, Any]]:

77 out: List[Dict[str, Any]] = []

78 cur: Dict[str, Any] | None = None

79for s insorted(segments, key=lambda x: x["start_ms"]):

80 new_block = (cur isNoneor s.get("speaker") != cur.get("speaker") or s["start_ms"] - cur["end_ms"] > gap_ms)

81if new_block:

82if cur: out.append(cur)

83 cur = dict(s)

84else:

85 cur["end_ms"] = max(cur["end_ms"], s["end_ms"])

86 cur["text"] = (cur["text"] + " " + s["text"]).strip()

87if cur: out.append(cur)

88return out

89

90# Summarize

91defbuild_summary_and_actions(

92 paragraphs: List[Dict[str, Any]],

93 generate: Callable[[str], str],

94 max_chars: int = 15000

  1. -> Dict[str, Any]:

96deffmt_time(ms: int) -> str:

97 s = max(0, ms // 1000); returnf"{s//60:02d}:{s%60:02d}"

98 lines = []

99for p in paragraphs:

100 speaker = p.get("speaker") or"Speaker"

101 ts = fmt_time(p.get("start_ms", 0))

102 lines.append(f"[{ts}] {speaker}: {p['text']}")

103 text = "\n".join(lines)[:max_chars]

104

105 prompt = f"""

106You are turning a meeting transcript into structured, linkable notes.

107Return STRICT JSON with keys: summary, highlights[], action_items[].

108- highlights[]: title, start_ms, end_ms, quote

109- action_items[]: title, owner (email or null), due_date (YYYY-MM-DD or null), confidence (0..1), evidence_ms

110Only use information present in the transcript below.

111

112Transcript:

113{text}

114""".strip()

115

116 raw = generate(prompt)

117try:

118 data = json.loads(raw)

119 data.setdefault("summary",""); data.setdefault("highlights",[]); data.setdefault("action_items",[])

120return data

121except Exception:

122return {"summary": "", "highlights": [], "action_items": []}

123

124# Search

125defsearch_paragraphs(paragraphs: List[Dict[str, Any]], query: str, limit: int = 5

  1. -> List[Tuple[Dict[str, Any], int]]:

127 q = query.lower().strip()

128 scored: List[Tuple[Dict[str, Any], int]] = []

129for p in paragraphs:

130 score = p["text"].lower().count(q)

131if score: scored.append((p, score))

132 scored.sort(key=lambda t: (-t[1], t[0]["start_ms"]))

133return scored[:limit]

134

135defjump_link(base_play_url: str, start_ms: int) -> str:

136 sec = max(0, start_ms // 1000)

137 sep = "&"if"?"in base_play_url else"?"

138returnf"{base_play_url}{sep}t={sec}"

... See all 139 lines

.avif)

Written By:

Maggie Veltri

Table of Contents

The API to get transcripts, recordings, and metadata from meetings.

Start for free Get a demo

Need help? Email us at support@recall.ai

Get transcripts, recordings and metadata from meetings

Start for free

Product

Meeting Bot API\ \ \ example Desktop Recording SDK\ \ \ example Mobile Recording SDK\ \ \ example Calendar Integration\ \ \ example Storage and Playback\ \ \ example

Company

Careers\ \ \ We're hiring! Blog\ \ \ example Case Studies\ \ \ example

Security

Security Portal\ \ \ example SOC 2\ \ \ example ISO 27001\ \ \ example HIPAA\ \ \ example GDPR\ \ \ example CCPA\ \ \ example

Legal

Privacy\ \ \ We're hiring! Terms of Use\ \ \ We're hiring!

Resources

API Docs\ \ \ example Pricing\ \ \ example Notetaker API\ \ \ example Meeting Recording API\ \ \ example Transcription API\ \ \ example Meeting Transcription API\ \ \ example Compliance Recording API\ \ \ example Speaker Diarization API\ \ \ example Slack Huddles API\ \ \ example

Compare

Recall.ai vs Attendee\ \ \ example Recall.ai vs Meeting BaaS\ \ \ example Recall.ai vs Nylas Notetaker\ \ \ example Recall.ai vs Skribby\ \ \ example

More

Meeting Bots

Google Meet Bot API\ \ \ example Microsoft Teams Bot API\ \ \ example Zoom Meeting Bot API\ \ \ example

Recording

Google Meet Recording API\ \ \ example Microsoft Teams Recording API\ \ \ example Zoom Meeting Recording API\ \ \ example

Transcription

Google Meet Transcription API\ \ \ example Microsoft Teams Transcription API\ \ \ example Zoom Meeting Transcription API\ \ \ example