Skip to content

AI Meetings API

Endpoints for spawning meeting bots, inspecting their lifecycle, retrieving recordings + transcripts, and configuring webhook delivery. See AI Meetings for the conceptual overview and a complete worked example.

All endpoints are app-scoped — the app_id lives in the URL path, and the call is authenticated with a Butterbase service-key (bb_sk_...) belonging to that app’s owner.

MethodPathPurpose
POST/v1/{app_id}/ai/meetingsSpawn a meeting bot
GET/v1/{app_id}/ai/meetings/{bot_id}Get one bot — status, duration, URLs
DELETE/v1/{app_id}/ai/meetings/{bot_id}Force the bot to leave the call
GET/v1/{app_id}/ai/meetingsList this app’s bots
GET/v1/{app_id}/ai/meetings/_estimatePredict the USD charge for a session
GET/v1/{app_id}/ai/meetings/usageRecent actor_usage_logs rows for the app
PUT/v1/{app_id}/ai/meetings/webhookConfigure forward URL + (re)mint signing secret
GET/v1/ai/meetings/_statusPublic — { "available": true | false }
POST /v1/{app_id}/ai/meetings
Authorization: Bearer {token}
Content-Type: application/json
{
"meetingUrl": "https://zoom.us/j/12345...",
"transcript": true,
"recording": "mp4",
"botName": "Acme Notetaker",
"metadata": { "session_id": "abc123" },
"automaticLeave": {
"waitingRoomTimeoutSec": 900,
"noOneJoinedTimeoutSec": 600,
"everyoneLeftTimeoutSec": 120,
"inCallNotRecordingTimeoutSec": 1800
}
}
FieldTypeDefaultNotes
meetingUrlstringrequiredAny Zoom / Meet / Teams / Webex URL
transcriptbooleantrueTranscribe in addition to recording
recording"mp4" | "audio_only" | false"mp4"false skips recording entirely
botNamestring"Butterbase Notetaker"Display name the bot uses when it joins the call. 1–64 chars
metadataRecord<string,string>{}Arbitrary string→string map. Keys may not start with bb_ (reserved)
automaticLeaveobject{}Per-bot overrides for the auto-leave timers. Any sub-field you omit inherits the provider default. See Auto-leave timers.

Tell the bot when to give up and leave on its own. All sub-fields are optional; omitted ones fall back to the provider’s default. All values are positive integers in seconds (max 86400 = 24h).

Sub-fieldTriggers when…
waitingRoomTimeoutSecBot has been stuck in the waiting room (host hasn’t admitted it) for this many seconds.
noOneJoinedTimeoutSecBot is in the call but no participants ever joined within this many seconds.
everyoneLeftTimeoutSecAll participants have left and this many seconds pass with the room empty.
inCallNotRecordingTimeoutSecBot is in the call but not recording (e.g. recording consent denied) for this many seconds.

Example — make the bot leave if not admitted within 15 minutes:

{ "automaticLeave": { "waitingRoomTimeoutSec": 900 } }

Response:

{
"id": "c086e720-d319-44b8-82d8-3a363f2cd9f4",
"status": "joining",
"startedAt": null,
"completedAt": null,
"durationSeconds": null,
"recordingUrl": null,
"transcriptUrl": null,
"botName": "Acme Notetaker",
"metadata": { "session_id": "abc123" }
}

botName is echoed back so callers can confirm what was sent. Bots created before this field was supported come back with "Butterbase Notetaker".

startedAt / completedAt / URLs populate as the bot progresses. Poll GET /v1/{app_id}/ai/meetings/{id} or rely on webhooks.

GET /v1/{app_id}/ai/meetings/{bot_id}
Authorization: Bearer {token}

Returns the same shape as POST. The status field moves through:

StatusMeaning
joiningBot is dialing in
waiting_roomBot is in the waiting room awaiting host admit
in_callBot is in the call (recording may not have started yet)
recordingRecording in progress
endedCall ended; artifacts being finalised
doneTerminal — call finished cleanly, recordingUrl + transcriptUrl available
fatalTerminal — bot failed to join, was kicked, or had a fatal error
GET /v1/{app_id}/ai/meetings?status={status}&limit={n}&cursor={cursor}
Authorization: Bearer {token}

Query params (all optional):

ParamTypeDefaultNotes
statusstringFilter to one lifecycle phase
limitinteger201–100
cursorstringFrom nextCursor in a prior response

Response:

{
"bots": [ /* MeetingBot[] */ ],
"nextCursor": "..." | null
}
DELETE /v1/{app_id}/ai/meetings/{bot_id}
Authorization: Bearer {token}

Forces the bot to leave the call. Returns 204 on success. Idempotent — a stopped or already-done bot still returns 204.

GET /v1/{app_id}/ai/meetings/_estimate?durationMinutes={n}&transcript={bool}
Authorization: Bearer {token}
ParamTypeDefaultNotes
durationMinutesintegerrequired1 – 1440
transcriptbooleantrueWhether to include transcription cost

Response:

{ "usd": 0.39 }
PUT /v1/{app_id}/ai/meetings/webhook
Authorization: Bearer {token}
Content-Type: application/json
{
"forward_url": "https://your-app.example.com/recall/events",
"rotate_secret": true
}
FieldTypeNotes
forward_urlstring (URL)Where Butterbase will POST forwarded events
rotate_secretbooleanIf true (or no row exists yet), mint a fresh per-app secret

Response:

{
"ok": true,
"app_id": "app_abc123",
"forward_url": "https://your-app.example.com/recall/events",
"secret": "wsec_..."
}

secret is returned once — only on initial create and when rotate_secret: true. Store it immediately. On subsequent calls without rotate_secret, secret is null. Butterbase stores the value AES-256-GCM-encrypted; the platform decrypts it transiently each time it signs an outbound forward.

When the bot’s status advances or an artifact becomes ready, Butterbase POSTs to your forward_url:

POST /your/configured/path
content-type: application/json
x-bb-event: bot.done
x-bb-signature: v1,<base64 HMAC-SHA256>
{
"event": "bot.done",
"data": {
"bot": { "id": "c086e720-...", "metadata": { ... } },
"data": { "code": "done", "sub_code": null, "updated_at": "..." }
}
}

Recompute base64(HMAC-SHA256(<your wsec_>, <raw request body>)), prefix with v1,, and compare to x-bb-signature in constant time. The full Stripe / GitHub / Recall.ai webhook pattern — see the worked example.

Each new app gets these events forwarded by default:

  • bot.in_call_recording
  • bot.done
  • bot.fatal
  • recording.done
  • transcript.done
  • transcript.failed

recording.done and transcript.done events deliver only the artifact id and metadata. To get a downloadable URL, follow up with GET /v1/{app_id}/ai/meetings/{bot_id} and read recordingUrl / transcriptUrl from the response. URLs are short-lived (Recall re-mints them on demand), which is why they’re not baked into the webhook payload.

GET /v1/{app_id}/ai/meetings/usage
Authorization: Bearer {token}

Returns the last 100 rows from actor_usage_logs:

{
"rows": [
{
"id": "5",
"dimension": "recording",
"seconds": 44,
"usd_charged": "0.007333",
"created_at": "2026-06-11T19:18:53.734Z"
},
{
"id": "4",
"dimension": "transcription",
"seconds": 44,
"usd_charged": "0.002200",
"created_at": "2026-06-11T19:18:53.485Z"
}
]
}

One row per dimension (recording and, when transcription was enabled, transcription) per completed session.

GET /v1/ai/meetings/_status

No auth — public. Returns:

{ "available": true }

false means the provider isn’t registered on this deployment (e.g. an OSS-mode deployment without MEETINGS_API_KEY set). Use this to gate UI affordances client-side.

OpenAI-shaped: { "error": { "message", "type", "code" } }.

Statuserror.typeerror.codeWhen
400invalid_request_errorinvalid_requestRequest body failed validation. error.details has the zod issues.
401authentication_errormissing_credentialsNo / invalid Authorization header.
402billing_errorinsufficient_creditsAI credits balance can’t cover the up-front lease. error.required_usd + error.available_usd included.
403permission_errornot_authorizedAuthenticated user doesn’t own this app.
404invalid_request_errorapp_not_foundapp_id doesn’t exist.
501api_errorprovider_unavailableMeetings adapter isn’t registered on this deployment.
5xxapi_error(varies)Upstream provider error. Retry with backoff.