Base URL & auth
Every request carries your key:
make all prints a key when the stack comes up.
Platforms
Pass one of these asplatform:
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
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 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/scheduledplan, it deletes the plan row. - For a
completed/failedmeeting, 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 return404.
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 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 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).
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
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_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 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 withplatform: "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
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. “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
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 loud —
503 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 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.
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}
segments — id, 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}
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
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}
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
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.