Skip to main content
The Meetings API covers a meeting’s whole life: plan it ahead (or import it from a calendar), send a bot into the call (or let auto-join do it), and read the transcript in real time. It is the public Vexa API — the same surface whether you use the hosted service or self-host.

Base URL & auth

Every request carries your key:
Don’t have a key yet? Hosted: sign in at vexa.ai/signin with a Google account and copy your key from your account page — free credit, no card required. Self-hosted: make all prints a key when the stack comes up.

Platforms

Pass one of these as platform: The bot joins like any participant — no plugins, no host configuration. * Jitsi is join + capture offline-proven; live validation is pending. Meet, Zoom and Teams are production. On Jitsi the bot joins and records audio, but transcripts are not yet proven live — #883 reports a witness run on self-hosted docker-jitsi-meet where the speaker binder matched 0 of 122 frames and the meeting completed with 0 transcript segments. Track #570. For teams, a meeting is addressed either by its numeric meeting ID (10–15 digits, the one Teams prints beside a passcode) or by the thread id from a …/l/meetup-join/19:meeting_…@thread.v2 link. With the numeric ID, send the passcode as passcode — the bot joins at …/meet/<id>?p=<passcode>, and the passcode is kept off the stored constructed_meeting_url and out of the logs. Add teams_base_host for a non-default cloud (teams.live.com for personal meetings, gov.teams.microsoft.us / dod.teams.microsoft.us for GCC-High and DoD); hosts outside the Teams web clients are rejected with 422. Sending the full meeting_url settles host and passcode together. Only passcode is read. password, meeting_password and similar aliases are rejected with a 422 naming the right field, rather than accepted and dropped. For jitsi, the full meeting_url is required (like Zoom): a Jitsi room name is scoped to a deployment — MyRoom exists on meet.jit.si and on every self-hosted instance — so only the URL says which one to join. The native_meeting_id reflects that scoping: the bare room name on meet.jit.si (MyRoom), and room@host on any other deployment ([email protected]) — so same-named rooms on different deployments never collide. A password-protected room is joined by passing the optional passcode field. To make calendar sync recognize a self-hosted deployment’s links, list its hostnames in the VEXA_JITSI_HOSTS env (comma-separated). On Jitsi the transcript also carries the meeting’s chat: each chat message arrives as a segment with source: "chat" (the sender is the speaker), and speaker names come from the conference’s own dominant-speaker signal.

Meeting statuses

A meeting is one record from plan to transcript: Sending a bot to a planned meeting (yours or auto-join’s) upgrades the same record in place — its title, workspace binding, and transcript stay together. PATCH/DELETE work only while the record is still planned; once the bot lifecycle owns it they answer 409.

Plan a meeting

Create a meeting before it happens — no bot is spawned. All fields are optional: a plan can be just a title, and the link can be attached later.
string
What the meeting is about — shown in the Meetings list.
string
ISO-8601 start time. Present → status scheduled (and auto-join arms); absent → idle.
string
A Meet/Zoom/Teams link — parsed server-side into platform + native_meeting_id. Unrecognized links are rejected with 422.
string
Bind the meeting to a shared workspace — its members then see the meeting, its live feed, and the transcript.
boolean
Send the bot automatically at start time. Default true.
POST /meetings
Returns 201 with the meeting record. 409 when a non-terminal meeting already exists for the same link.

Edit a plan or delete meeting artifacts

Planned meetings are addressed by record id (a plan without a link has no platform/native path). Send only the fields you’re changing; null clears a field (scheduled_at: null flips the status back to idle, meeting_url: null detaches the link, workspace_id: null unbinds).
PATCH /meetings/{meeting_id}
DELETE /meetings/{meeting_id}
PATCH is plan-only. DELETE has two deliberately distinct meanings:
  • For an idle/scheduled plan, it deletes the plan row.
  • For a completed/failed meeting, it deletes durable transcript rows, transcript-derived notes/shares, recording objects from primary object storage, and recording metadata. The terminal meeting row remains as lifecycle evidence, but transcript reads return 404.
Both answer an owner-indistinguishable 404 for an unknown or unowned record. An in-flight meeting (requested through stopping) answers 409; use DELETE /bots/{platform}/{native_meeting_id} to stop a bot, then delete artifacts after the meeting becomes terminal. The same edit/delete is also reachable by native keyPATCH/DELETE /meetings/{platform}/{native_meeting_id} — which resolves (platform, native_meeting_id) to your newest owned row and applies the same rules (404 unknown/unowned, 409 while in flight). Use it when you address meetings by their join-link identity rather than the record id; DELETE answers 200 on this path (the by-record-id DELETE answers 204).
Deletion confirms erasure from the live Postgres/Redis state and configured primary object storage. It does not claim immediate erasure from database snapshots, object-store version history, or other backups; those residuals expire under the operator’s deployment retention policy.

Set the planned status by native key

PUT /meetings/{platform}/{native_meeting_id}/intent writes only the two user-owned statuses — this is the route a dropdown drives, where PATCH is the route a form drives.
PUT /meetings/{platform}/{native_meeting_id}/intent
Returns 200 with {meeting_id, status, scheduled_at}. 404 for a record you don’t own. The write is idempotent — setting the status it already holds changes nothing and emits no WebSocket frame.

Bind a meeting to a shared workspace

POST /meetings/{platform}/{native_meeting_id}/workspace sets the meeting’s workspace_id. Members of that workspace can then subscribe to the meeting’s live transcript feed. Many meetings may point at one workspace.
POST /meetings/{platform}/{native_meeting_id}/workspace
Returns 200 with {"workspace_id": "ws-acme"}. 422 when workspace_id is missing or empty; 404 for a record you don’t own. POST /meetings accepts the same binding at creation time.

api.v1 native-id support (per route)

api.v1 is sealed as a file hash (contracts.seal.json), which pins the schema document, not the running implementation. This table states, per route, whether the (platform, native_meeting_id) keying a 0.10 client uses is served by the 0.12 core:
What the seal enforces now. Because the file hash pins the document and not the running implementation, CI now also runs a reverse conformance check (gate:contract-conformance): for every (path, method) api.v1 declares, the shipped gateway + meeting-api must serve it — otherwise the route must be recorded in the audited core/gateway/contracts/api.v1/KNOWN_GAPS.json ledger (with a reason + issue link), which the gate prints loudly on every run. A sealed endpoint renamed or dropped without a ledger entry fails CI. The frozen golden examples are also driven against the real responses, so a renamed response field (e.g. running_botsrunning) fails too. The two Not implemented rows above and the share response-shape divergence are the current recorded gaps — a client can rely on the seal meaning “served, or explicitly listed as a gap”, not merely “the schema file didn’t change”.

Auto-join

A scheduled meeting with a link is joined automatically: a background sweep sends the bot ~60 s before scheduled_at (never more than 10 min after — a stale plan is skipped, not joined late). Opt out per meeting with auto_join: false. Failures are loud: a concurrency-cap or spawn failure stamps auto_join_error into the meeting’s data and retries after a backoff. Timing is tunable on a self-host — see Configuration.

Calendar sync

Connect up to ten named ICS feeds through the Calendar API. Each connection owns its auto-join policy and bot name. Sync imports every upcoming event as a planned meeting: events with Meet/Zoom/Teams links are armed for auto-join, while link-less events remain visible with platform: "unknown" until a link appears. Imported rows retain normalized scheduling/participant fields and the complete per-source ICS snapshot under data.calendar_sources[].event. Moves, cancellations, attendee changes, and custom provider metadata follow the feed only while the meeting remains planned; calendar sync never rewrites a meeting after the bot lifecycle claims it.
The endpoints below are the singular compatibility routes, which address one feed. An account can hold up to ten named connections, each with its own auto_join and bot_name, through /user/calendars — the full contract is the Calendar API. New integrations should use that.
PUT /user/calendar
See the Calendar API reference for connection CRUD, per-connection sync endpoints, request/response schemas, compatibility endpoints, error statuses, and metadata persistence. See Calendar sync for Google Calendar and Outlook setup.

Send a bot to a meeting

string
required
google_meet · zoom · teams
string
required
The meeting id from the join URL (e.g. abc-defg-hij).
string
Display name the bot uses in the call. Defaults to Vexa.
string
ISO code (e.g. en). Omitted = auto mode: each STT window’s language is detected independently and stamped on its segments’ language field. Set = forced mode: every STT call is pinned to this code and every segment carries it. Granularity is the transcription window (a few seconds of one speaker’s audio), not word-level; in auto mode a low-confidence detection on the Zoom/Teams (mixed-audio) lane discards the window rather than guessing. Undetermined windows yield language: null. (0.10’s allowed_languages is not part of the 0.12 API.)
string
transcribe (default) or translate.
boolean
Whether this spawn should request transcription. Resolution: an explicit value wins; else the deployment env TRANSCRIBE_ENABLED; else true. Non-boolean values (other than the common string forms true/false/1/0/yes/no/on/off) are refused with 422 (transcribe_enabled must be a boolean). When the resolved value is true and no STT backend is configured, POST /bots answers 503 with a detail naming the unset keys, e.g. no transcription backend configured — set it in Settings or environment variables TRANSCRIPTION_SERVICE_URL + TRANSCRIPTION_SERVICE_TOKEN. “Settings” there means PUT /user/transcription. Set false for capture-only (bot may still join; no STT client is constructed) — on its own that persists nothing, see Capture now, transcribe later.
boolean
Whether this spawn should persist an audio recording (see Recordings — there is no video recording path). Same resolution pattern as transcribe_enabled against env RECORDING_ENABLED (default true at the env resolver; the service default when callers omit it follows the request/env chain). Capture-only (transcribe_enabled=false) is not the same as recorded — recording is gated separately. Set both to capture a meeting for transcription elsewhere: Capture now, transcribe later.
POST /bots
If a planned meeting exists for the same link, the spawn claims it: the same record moves to requested, keeping its title, scheduled_at, and workspace binding. 409 when a bot is already active on that link; 429 past your concurrency limit — see the concurrency-refusal divergence, which answers 403 today. With transcription requested (the default) and STT unconfigured, the spawn is refused loud503 naming TRANSCRIPTION_SERVICE_URL / TRANSCRIPTION_SERVICE_TOKEN. Two ways out: set the backend for your account over the API — PUT /user/transcription, which overrides the deployment default without a restart — or set those env vars for the whole deployment (Configuration). Capture-only: {"transcribe_enabled": false} (or TRANSCRIBE_ENABLED=false for the deployment). Configured and still failing? See Troubleshooting.

Completion service provenance

The signed meeting.completed webhook includes data.meeting.service_provenance when the meeting has complete producer-owned lifecycle facts:
transcription_provider is frozen when that bot is created: vexa, customer, or none. bot_admitted_at and bot_departed_at are the bot-observed lifecycle transitions, not meeting wall-clock time or delayed webhook receipt time. A bot that never reached active reports bot_outcome: "never_admitted" with both timestamps absent. The block never contains an endpoint URL, hostname, token, credential, meeting title, or transcript. If a legacy or mixed-version run lacks provider ownership or producer timestamps, the block is null or absent. Consumers must treat that as unresolved provenance; do not reconstruct it from transcribe_enabled, current user settings, endpoint URLs, or meeting start/end time.

Get the transcript

GET /transcripts/{platform}/{native_meeting_id}
Response
Segments stream in while the meeting runs — poll this endpoint, or subscribe over WebSocket for live, per-segment updates. Live drafts arrive as completed: false and are replaced by completed: true confirmations. The same response is reachable by record id, which is what you have after planning a meeting that has no link yet:
GET /transcripts/by-id/{meeting_id}
Both forms return the meeting record alongside its segmentsid, platform, native_meeting_id, status, start_time, end_time, constructed_meeting_url, notes, recordings, and the full data detail. 404 for an unknown record or one you don’t own.

Manage the bot

Update config — PUT /bots/{platform}/{native_meeting_id}/config
Stop / leave — DELETE /bots/{platform}/{native_meeting_id}
Stop answers 200 with {"status":"stopping","meeting_id":…,"also_stopped":[],"cancelled":[]}.
Running bots — GET /bots/status
Make the bot speak — POST /bots/{platform}/{native_meeting_id}/speak
Config (PUT …/config, change language/task mid-call) and speak (POST …/speak, TTS into the call) ride the live bot-control plane and are not yet wired in the v0.12 open-core stack — they currently return 404. Send-a-bot, stop, running bots (GET /bots/status), list, and transcripts are live.
List meetings — GET /meetings
Single meeting — GET /meetings/{meeting_id}

List rows are slim; detail is full

The two list endpoints — GET /bots and GET /meetings — return lightweight rows. Each row keeps its light metadata (id, status, times, title, connected docs) but omits the heavy data detail keys: speaker_events, bot_logs, recordings, status_transition, chat_messages, error_details, last_error, and the complete calendar_sources event snapshots. Compact calendar identity such as calendar_connection_id, calendar_uid, and scheduled_at remains available in a list row. Fetch a meeting’s full data from GET /meetings/{meeting_id} — the detail endpoint is unaffected and still returns every key. The list is also paged: with no limit, a default page size of 50 applies (pass limit/offset to page explicitly). GET /bots returns has_more: true when more rows remain past the current page. Pass exclude_planned=true to either endpoint when rendering run/transcript history: the server removes idle and scheduled plans before applying limit and offset. An explicit status=... filter takes precedence. This keeps future Calendar entries on schedule surfaces without letting them consume a history page.

Speaker-attributed transcripts

Each segment carries word-level timestamps (words[]) and a confidence, and most segments are diarized — attributed to a bound speaker display name. Attribution is not guaranteed: when the binder cannot resolve a turn it publishes the segment with an empty speaker rather than guessing, so your client must handle unattributed segments. Under heavy crosstalk roughly 4–7% of rows publish unnamed (v0.12.22 release notes). Speaker attribution is text-level (who said what), via speaker binding / clustering / captions — not separate audio tracks. Times are seconds from session start; absolute_start_time / absolute_end_time give wall-clock. The same segments arrive live (completed: false, a pending draft) and then confirmed (completed: true) — the gateway forwards the confirmed-plus-pending bundle to subscribers as the meeting runs.

Meeting participants

GET /meetings/{platform}/{native_meeting_id}/participants
Every row says where it came from, because the two sources answer different questions: 404 for a meeting you don’t own — not an empty list. sources names only the sources that actually contributed, so [] means neither did.
observed_roster is always "not_recorded" today, and that is the important field. Nothing in the current core records who was present in a meeting: the bot’s platform modules watch only tiles that emit a speaking signal, so a participant who joins and never speaks leaves no trace anywhere (tracked as #861). So an empty participants means attendance was never captured — it can never mean “nobody attended”, and your client should not render it that way. Today the only route to a complete list including silent people is the invite source, i.e. connecting the calendar the meeting came from.No identity resolution is performed. Someone both invited and heard appears twice, once per source, as Alice does above. Matching a voice label to an invitee is a guess, and the wrong guess silently merges two people — so the API hands you both layers, labelled, and leaves the join to you.

Recordings

The meeting’s audio recording is uploaded to object storage — on a self-host, your own MinIO bucket, so it never leaves your environment. This is the meeting audio, stored separately from the diarized transcript above (there is no “per-speaker audio” — speaker separation lives in the transcript as text).
List recordings — GET /recordings
Recording detail — GET /recordings/{recording_id}
Delete one recording — DELETE /recordings/{recording_id}
After the meeting is terminal, recording deletion is owner-scoped (404 for unknown and unowned ids; 409 while the meeting is in flight) and removes all discovered chunks/masters from configured primary object storage before removing the recording metadata. If storage deletion fails, metadata is retained so the same request can be retried safely. This route does not delete the meeting transcript; use DELETE /meetings/{meeting_id} after completion to erase the meeting’s transcript and all of its recordings together.
Master metadata (finalize-on-read) — GET /recordings/{recording_id}/master?type=audio
The master metadata returns a raw_url pointing at the byte stream GET /recordings/{recording_id}/media/{media_file_id}/raw, which the player loads. The /raw endpoint honours a Range header and returns 206 Partial Content with Content-Range and Accept-Ranges — these are preserved through the gateway, not only on a direct hit, so browser playback and seeking work.
In Vexa’s runtime terms, a bot is a browser container; the transcript it produces compiles into the workspace, where agents act on it. See Meetings.