Base URL & auth
Every request carries your key:
Platforms
Pass one of these asplatform:
The bot joins like any participant — no plugins, no host configuration.
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
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
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}
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 key — PATCH/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_bots → running) 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
Ascheduled 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 a calendar’s secret ICS address and upcoming meetings import as planned records automatically (only events carrying a recognizable Meet/Zoom/Teams link; one record per event — the next occurrence of a recurring series). The URL is a secret: read-backs return it masked.auto_join here is the global default stamped onto imported meetings.
PUT /user/calendar
GET /user/calendar
Response
/calendar/embed)
is rejected with a 422 that points at the right field — the Secret address in iCal format
(on Google Workspace domains an admin policy can hide that field; see
Calendar sync for the unlock).
Sync feedback — connecting from the Terminal runs a sync immediately; over the API the same
two edges are yours:
POST /user/calendar/sync — run the sync NOW, get the result
Response
GET /user/calendar/sync — the last sync's status
last_error, when set, is a human-readable reason (wrong-URL kind, HTTP status, oversize,
redirect, not-ICS content) — the same strings the Terminal panel shows. 404 on the POST means no
feed is connected; 503 means the deployment has calendar sync disabled (see
Configuration).
Disconnect with {"ics_url": null}. See Calendar sync for where to find
the secret address.
Send a bot to a meeting
string
required
google_meet · zoom · teamsstring
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. Set false for capture-only (bot may still join; no STT client is constructed).boolean
Whether this spawn should persist an audio/video recording. 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.POST /bots
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 loud —
503 naming TRANSCRIPTION_SERVICE_URL / TRANSCRIPTION_SERVICE_TOKEN. Capture-only:
{"transcribe_enabled": false} (or TRANSCRIBE_ENABLED=false for the deployment). See
Configuration and Troubleshooting.
Completion service provenance
The signedmeeting.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
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, and last_error. 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.
Speaker-attributed transcripts
Each segment is diarized — attributed to a speaker (a bound display name, or a provisional label until it binds) — with word-level timestamps (words[]) and a confidence. 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
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.