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 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 or delete a plan

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}
Both answer 404 for a record you don’t own and 409 once the bot lifecycle owns the record. 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 once FSM-owned). 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).

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. 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. 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.

Manage the bot

Update config — PUT /bots/{platform}/{native_meeting_id}/config
Stop / leave — DELETE /bots/{platform}/{native_meeting_id}
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.

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}
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.