A Next.js demo app showing how to integrate Recall.ai's Calendar V2 API to automatically schedule meeting bots for calendar events. The app decides which meetings get a bot per calendar, which is how Calendar V2's app-managed scheduling is meant to be used.
Done testing? Delete your calendars before you stop running the app, or Recall keeps sending bots to your meetings. See Disconnecting a Calendar.
- Calendar Integration: Connect as many Google Calendar and Microsoft Outlook accounts as you like, at the same time. Each account becomes its own Recall calendar; connecting the same account again reconnects it.
- Calendar Sync: Automatically syncs calendar events via Recall.ai webhooks
- Schedule Modes: Choose per calendar which meetings get a bot: every meeting, ones you organized, ones with external guests, ones with only internal guests, or none
- Bot Settings: Customize each calendar's bots (name, joining early, a chat message, and when the bot leaves on its own)
- Per-Meeting Overrides: Add, edit or remove the bot for a single meeting, whatever the calendar's setting
When several connected calendars have the same meeting (e.g. two teammates are both invited), each calendar has its own calendar event for it. The deduplication_key you pass when scheduling a bot decides how many bots join: events that share a key share one bot. Keys are scoped to your Recall workspace, so other apps using Recall don't affect yours.
This app deduplicates at the meeting level, so one bot joins each meeting no matter how many connected calendars have it:
deduplication_key = `${start_time}-${meeting_url}`;Other common strategies:
| Strategy | Key | Result |
|---|---|---|
| Meeting | ${start_time}-${meeting_url} |
One bot per meeting (this app) |
| User | ${start_time}-${meeting_url}-${user_id} |
One bot per user in the meeting, e.g. for private notes |
| Domain | ${start_time}-${meeting_url}-${domain} |
One bot per company in the meeting, e.g. per customer |
Pick one scheme and keep it: changing it later can leave events with bots under the old key. See generate_bot_deduplication_key() in src/lib/recall/bot.ts, and the Scheduling Guide.
Each bot also records who added it in its scheduled_by metadata (schedule_mode or manual), so changing a calendar's schedule mode never removes a bot the user added by hand.
When a user clicks "Connect Google Calendar" or "Connect Microsoft Outlook":
Client Server Calendar Provider Recall.ai
│ │ (Google/Outlook) │
│ │ │ │
│ GET /api/auth/sign-in/google|microsoft │ │
│──────────────────▶│ │ │
│ │ │ │
│ │ 302 Redirect to provider OAuth │
│ │─────────────────────▶│ │
│ │ │ │
│ │ User authorizes calendar access │
│ │ │ │
│ Redirect back with auth code │ │
│◀─────────────────────────────────────────│ │
│ │ │ │
│ GET /api/auth/callback/google|microsoft?code=abc123 │
│──────────────────▶│ │ │
│ │ │ │
│ │ Exchange code for │ │
│ │ OAuth tokens │ │
│ │─────────────────────▶│ │
│ │ │ │
│ │ { refresh_token, │ │
│ │ email } │ │
│ │◀─────────────────────│ │
│ │ │ │
│ │ GET https://REGION.recall.ai/api/v2/calendars?email=...
│ │ (is this account already connected?) │
│ │──────────────────────────────────────────▶│
│ │ │ │
│ │ POST https://REGION.recall.ai/api/v2/calendars
│ │ or PATCH .../calendars/{id} to reconnect│
│ │ { oauth_refresh_token, oauth_client_id, ... }
│ │──────────────────────────────────────────▶│
│ │ │ │
│ │ │ Calendar created │
│ │◀──────────────────────────────────────────│
│ │ │ │
│ 302 Redirect to home page │ │
│◀──────────────────│ │ │
After OAuth (and on ongoing calendar changes), calendar providers notify Recall.ai, which then notifies your server. The server decides which meetings get a bot:
Google/Outlook Recall.ai Server
│ │ │
│ Calendar event │ │
│ created/updated │ │
│ (webhook push) │ │
│─────────────────────▶│ │
│ │ │
│ │ POST /api/webhooks/recall
│ │ { event: "calendar.sync_events",
│ │ calendar_id, last_updated_ts }
│ │──────────────────────▶│
│ │ │
│ │ 200 OK (right away) │
│ │◀──────────────────────│
│ │ │
│ │ GET https://REGION.recall.ai/api/v2/calendar-events
│ │ ?calendar_id=...&updated_at__gte={last_updated_ts}
│ │◀──────────────────────│
│ │ │
│ │ [only changed events] │
│ │──────────────────────▶│
│ │ │
│ │ For each future event, decide whether it
│ │ should have a bot: meeting link, then the
│ │ meeting's override, then the calendar's
│ │ schedule mode
│ │ │
│ │ POST .../calendar-events/{id}/bot (add a bot,
│ │◀──────────────────────│ or move one whose
│ │ │ meeting moved)
│ │ DELETE .../calendar-events/{id}/bot (remove a bot
│ │◀──────────────────────│ that no longer matches)
When a user opens a meeting in the week calendar and adds, edits or removes its bot:
Client Server Recall.ai
│ │ │
│ "Add bot" / "Save bot" │
│ │ │
│ save_meeting_bot (server action) │
│────────────────────▶│ │
│ │ │
│ │ POST https://REGION.recall.ai/api/v2/calendar-events/{id}/bot
│ │ { deduplication_key, bot_config }
│ │───────────────────▶│
│ │ │
│ │ Bot scheduled │
│ │◀───────────────────│
│ │ │
│ │ Save an "always" override, so the
│ │ calendar's schedule mode keeps it
│ │ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ │ │
│ "Remove" / "Skip this meeting" │
│ │ │
│ remove_meeting_bot_action (server action)
│────────────────────▶│ │
│ │ │
│ │ DELETE https://REGION.recall.ai/api/v2/calendar-events/{id}/bot
│ │───────────────────▶│
│ │ │
│ │ Bot unscheduled │
│ │◀───────────────────│
│ │ │
│ │ Save a "never" override, so the
│ │ calendar's schedule mode skips it
- Push-based sync: Calendar providers send webhooks to Recall, which notifies your server via the
calendar.updateandcalendar.sync_eventswebhooks. Uselast_updated_tsto fetch only changed events oncalendar.sync_eventswebhooks. - App-managed scheduling: With Calendar V2, your app decides which meetings get a bot. This app does it with a schedule mode per calendar and per-meeting overrides.
- Respond to webhooks quickly: The webhook is acknowledged right away and the Recall.ai API calls happen after responding. Handling the same webhook twice is harmless: the second sync finds nothing to change.
- Verified webhooks: Every webhook is checked against
RECALL_WEBHOOK_SECRETbefore it's processed. Unverified requests get a401. - Deduplication keys: Prevent duplicate bots when multiple users have the same meeting (see Bot Deduplication)
- Deleted events stay in the API: An event removed from the calendar is still returned with
is_deleted: true. Filter it out when showing events (is_deleted=falseon List Calendar Events, as the week calendar does), and don't schedule bots for it. Recall unschedules a deleted event's bot itself. - Recall auto-manages some bots: Recall unschedules bots when events are deleted or calendars are disconnected. It doesn't move a bot when its meeting moves, so the app schedules it again.
Recall doesn't sync a calendar's whole history, which can be surprising when testing:
- Future events: synced up to 4 weeks ahead. Meetings further out show up as that window moves forward.
- Past events: kept for 60 days, then removed from the API.
- First sync: happens in the background after a calendar is connected, so its events (and
platform_email) can take a moment to appear. Thecalendar.sync_eventswebhook tells you when events change. - Not synced: all-day events (bots don't join them) and any calendar other than the account's primary calendar.
See the Calendar Integration FAQ.
- Node.js 20.9+ (required by Next.js 16)
- ngrok account (for webhooks)
- Recall.ai API key
Follow the official Recall.ai guides to create OAuth credentials (you'll need these for step 4):
- Google Calendar: Google Calendar Setup Guide. Use
http://localhost:3000/api/auth/callback/googleas the redirect URI. - Microsoft Outlook: Microsoft Outlook Setup Guide. Register a Web redirect URI of
http://localhost:3000/api/auth/callback/microsoft, support "Any Entra ID Tenant + Personal Microsoft accounts", and add the delegated Microsoft GraphCalendars.Readpermission.
OAuth redirects happen in the user's browser, so localhost redirect URIs work. ngrok is only needed for Recall.ai webhooks.
git clone https://github.com/recallai/calendar-v2-sample-app.git
cd calendar-v2-sample-app
npm installngrok http 3000Copy the sample environment file and fill in your values:
cp .env.sample .envThen edit .env with your Recall API key, region, and OAuth credentials from step 1. A provider's connect button only shows once its OAuth variables are set.
In your Recall.ai webhooks dashboard, add a webhook endpoint subscribed to calendar.update and calendar.sync_events, with the URL:
https://YOUR_CUSTOM_NGROK_SUBDOMAIN.ngrok-free.app/api/webhooks/recall
Copy that endpoint's signing secret (whsec_...) into .env as RECALL_WEBHOOK_SECRET.
npm run devThis starts the app at http://localhost:3000. Database migrations run automatically when the server starts.
- Open http://localhost:3000 in your browser
- Click "Connect Google Calendar" or "Connect Microsoft Outlook" to authorize your calendar
- Click the pencil on the calendar's card to choose which meetings get a bot, and what those bots are like
- Your meetings for the next 7 days show in the week calendar. Click one to add, edit or remove its bot
- A Recall.ai bot will join each meeting that has one at the scheduled time
- When you're done, delete your calendars (see below)
To see every webhook Recall sent and whether it was delivered, open Webhooks in the Recall.ai dashboard.
Scheduled bots live in Recall, not in this app. If you stop running the app without deleting your calendars, Recall keeps sending bots to your meetings.
In the app: click the trash icon on the calendar's card. This calls DELETE /api/v2/calendars/{id}, which unschedules the calendar's upcoming bots and deletes its OAuth tokens.
Without the app: list your calendars to find the ID, then delete it:
curl https://$RECALL_REGION.recall.ai/api/v2/calendars/ -H "Authorization: $RECALL_API_KEY"curl -X DELETE https://$RECALL_REGION.recall.ai/api/v2/calendars/CALENDAR_ID/ -H "Authorization: $RECALL_API_KEY"A deleted calendar and its events can no longer be retrieved. This is different from a calendar becoming disconnected (e.g. the user revokes access in their Google account): Recall unschedules its bots too, but the calendar and its events stay in the API, and it can be reconnected. See the Calendar V2 FAQ.
Each calendar has one setting for which meetings automatically get a bot:
| Mode | A meeting gets a bot when… |
|---|---|
| Manually (default) | never; bots are only added by hand |
| Every meeting | it has a meeting link |
| When I organized it | you're the organizer |
| When there are external guests | someone outside your email domain is invited |
| When there are only internal guests | other people from your domain are invited, and no one from outside it |
Changing the mode applies it to the calendar's upcoming meetings (the next 28 days) straight away; after that, the calendar.sync_events webhook applies it as meetings change. Bots added by hand are kept whatever the mode.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/auth/sign-in/google |
Start Google Calendar OAuth |
| GET | /api/auth/callback/google |
Google OAuth callback; connects the calendar |
| GET | /api/auth/sign-in/microsoft |
Start Microsoft Outlook OAuth |
| GET | /api/auth/callback/microsoft |
Microsoft OAuth callback; connects the calendar |
| POST | /api/webhooks/recall |
Recall.ai webhook receiver |
The UI calls server actions in src/app/action.ts for everything else:
| Server action | Description |
|---|---|
save_calendar_settings |
Save a calendar's schedule mode and bot settings |
remove_calendar |
Delete a calendar via Recall's DELETE /api/v2/calendars/{id} |
save_meeting_bot |
Add a bot to a meeting, or change its settings |
remove_meeting_bot_action |
Remove a meeting's bot and skip the meeting from now on |
follow_schedule_mode |
Drop a meeting's override so the calendar's schedule mode decides |
calendar-v2-sample-app/
├── drizzle/ # Database migrations (run when the server starts)
├── src/
│ ├── app/
│ │ ├── action.ts # Server actions (validate input, call lib/scheduling)
│ │ ├── api/auth/ # OAuth sign-in and callback routes
│ │ ├── api/webhooks/ # Recall.ai webhook receiver
│ │ └── page.tsx # Home page
│ ├── components/ # UI components
│ ├── db/ # Drizzle schema and queries (SQLite)
│ ├── lib/
│ │ ├── env.ts # Environment validation
│ │ ├── oauth/ # Google and Microsoft OAuth
│ │ ├── recall/ # Recall.ai API calls (⬇ see table below)
│ │ ├── scheduling/ # Which meetings get a bot (⬇ see table below)
│ │ └── settings.ts # Schedule mode labels and default settings
│ └── schemas/ # Data validation models
└── package.json
This is where the Recall.ai integration lives. It only depends on src/lib/env.ts and src/schemas/, so you can copy it into your own codebase:
| File | Purpose |
|---|---|
client.ts |
Authenticated requests to Recall.ai, retrying 409, 429 and 507 responses, and following pagination. |
calendar.ts |
Calendars (POST/GET/PATCH/DELETE /api/v2/calendars), calendar events (GET /api/v2/calendar-events), and scheduling a bot for an event (POST/DELETE /api/v2/calendar-events/{id}/bot). |
bot.ts |
Retrieving a bot, the deduplication key, and whether a scheduled bot still matches its event. |
raw_event.ts |
Reading the provider's event in raw: its title, guests and organizer, which look different for Google Calendar and Outlook. |
webhook.ts |
Verifying that a webhook came from Recall.ai. |
This app's logic on top of the API. Adapt it to how your app decides which meetings get a bot:
| File | Purpose |
|---|---|
decision.ts |
Core logic. Whether an event should have a bot: no meeting link means no bot, then the meeting's override, then the calendar's schedule mode. |
sync.ts |
Makes an event's bot in Recall match that decision: adds, removes or moves it. Runs for each event a calendar.sync_events webhook reports. |
bot_config.ts |
Turns the app's bot settings into a Create Bot config, and reads them back from a scheduled bot. |
calendars.ts |
Saves a calendar's settings (applying a changed schedule mode to upcoming meetings), and deletes a calendar. |
meeting_bots.ts |
Adds, changes or removes one meeting's bot by hand, and saves the matching override. |
upcoming.ts |
The events the week calendar shows, with each one's bot and decision. |
The app keeps its own state in a local SQLite database (data/app.db, git-ignored) using Drizzle. Recall is the source of truth for calendars and events, so the app reads them from Recall and doesn't copy them. The database only holds what this app decides:
| Table | What it stores |
|---|---|
calendar_settings |
Each calendar's schedule mode and the settings for its bots. A calendar without a row uses the defaults. |
event_overrides |
Per-meeting "always" / "never" choices from adding or removing a bot by hand, which beat the schedule mode. |
bot_decisions |
Every bot sent, moved or removed, with the reason and what triggered it. Shown in the activity feed. |
npm run db:generate # after changing src/db/schema.ts
npm run db:migrate # apply migrations without starting the server
npm run db:studio # browse the database