Calls & Meetings API Documentation
Base URL: http://localhost:8765/api/v1 (local) — https://dev.api.nexgate.co/api/v1 (staging)
LiveKit: ws://127.0.0.1:7880 (local) — wss://livekit.dev.nexgate.co (staging)
v1.1 — verified end to end on staging on 2026-09-14: a 1:1 call, a group call, a meeting, and joining a meeting by share link. Adds Part 0, which is the page to read first. The
urlin every token response is now a realwss://address rather than a laptop one — always take it from the response, never hardcode it.
Short Description: Real-time voice and video inside the chat system. Two surfaces sit here: Calls, which ring somebody now, and Meetings, which are scheduled or open rooms people join. Both are signalling only — this API issues room credentials and records what happened; the media itself never touches the backend, it goes phone → LiveKit SFU → phone.
Hints:
- Every endpoint needs
Authorization: Bearer <token>. - A join token is for one room, one identity, and expires in 10 minutes
(
app.livekit.token-ttl-seconds=600). Fetch it when you are about to connect, not when you render a list. - Refusals are
409with a machine-readablecode. Branch on the code, never on the prose. - The server owns call state and learns it from LiveKit webhooks, not from clients. A phone that dies mid-call never tells anyone it left.
- An unanswered call becomes
MISSEDafter 45 seconds (app.livekit.ring-timeout-seconds=45).
Standard Response Format
Success responses use the Globe Response Builder envelope:
{
"success": true,
"httpStatus": "OK",
"message": "Call started",
"action_time": "2026-08-20T10:30:45",
"data": { }
}
Refusals — read this before writing a client
A business refusal is HTTP 409 CONFLICT, and the envelope's message
carries the code, with the code repeated inside data:
{
"success": true,
"httpStatus": "CONFLICT",
"message": "BUSY",
"action_time": "2026-08-20T10:30:45",
"data": { "code": "BUSY", "reason": "That person is already in a call" }
}
Note success stays true — the envelope reports transport, not business
outcome. Treat any 409 as a refusal and read data.code.
Part 0 — Which one am I building?
Read this page before anything else. Calls and meetings share one table, one state machine and one media server, and they are not interchangeable. Choosing wrong produces something that works in testing and feels wrong to real users.
The one-line difference
A call is answered. A meeting is joined.
Everything below follows from that sentence.
Side by side
| Call | Meeting | |
|---|---|---|
| Starts from | an existing conversation, or a person/shop | nothing — it stands alone |
| Announces itself | rings every recipient, now | appears in a calendar; never rings |
| The recipient's screen | full-screen incoming call, must answer or decline | a row they may tap when they choose |
| States | RINGING → ACTIVE → ENDED |
SCHEDULED → OPEN → ENDED |
| Can be missed | yes — 45 s, then MISSED |
never. A meeting you skipped is not a missed call |
| Roles | none — everyone is a participant | HOST / SPEAKER / LISTENER |
| Share link | no | yes, and the link carries a role |
| Empty room | ends — last one out closes it | stays OPEN for its window |
| LiveKit room | call_{callId} |
meet_{callId} |
| Endpoint family | /api/v1/calls |
/api/v1/meetings |
Choosing
Do you want their phone to ring right now?
|
+-- yes --> CALL /api/v1/calls
| |
| +-- one person? targetAccountId or conversationId
| +-- a shop? targetShopId
| +-- a group thread? conversationId of the group
|
+-- no ---> MEETING /api/v1/meetings
|
+-- a fixed guest list? access INVITED
+-- anyone with the link? access OPEN
+-- audience larger than the stage? use LISTENER roles
Why it matters: thirty people in a group thread and you start a call — thirty phones ring at once, demanding an answer. That is hostile. A meeting says "we gather at 17:00, the room will be here", and people arrive. Same room, same media server, entirely different social contract.
The two things clients most often get wrong
🔴 A meeting has no
MISSEDoutcome, so do not build a "missed meeting" notification. If a user did not attend, nothing happened to them. Calls are where absence is a fact worth recording.
🔴 An empty meeting room must not be closed by your client. Somebody opening the room at 16:58 and leaving must not end the 17:00 meeting. Only the host ending it, or its window closing, moves it to
ENDED.
Part 1 — Calls
api/v1/calls — invite, accept and end are REST; the incoming alert is SSE
plus push; media never comes near this controller.
The shape of a call
CALLER BACKEND CALLEE
| | |
|-- POST /calls -------------->| |
| {kind, one target} | create call (RINGING) |
| | mint caller token |
|<-- 200 CallTokenResponse ----| |
| | |
| |== SSE call.incoming =========>| (device online)
| |== push notification =========>| (device asleep)
| | both carry the CALLEE token |
| | |
|---- connect to LiveKit ----->[ SFU ]<---- connect ----------|
| | |
| LiveKit webhook: participant joined |
| | state -> ACTIVE |
| | |
|-- POST /{id}/end ----------->| outcome COMPLETED |
| |== SSE call.ended ============>|
| | writes CALL_EVENT to thread |
Two things worth saying out loud in a demo:
- The
call.incomingevent carries the callee's token in the payload. A ringing phone that had to fetch one first would add a round trip to answer latency.POST /{callId}/joinexists only for a callee that learned about the call some other way, or cold-started. - A call always ends up attached to a conversation — that is where its
CALL_EVENTmessage goes and what the permission check reads — but the caller never has to create one first.
Call state machine
nobody answers (45s)
RINGING ─────────────────────────────> ENDED (MISSED)
│ ▲
│ somebody answers │ hang up / decline / cancel
▼ │
ACTIVE ───────────────────────────────────
CallState only ever advances. States: RINGING, ACTIVE, ENDED
(plus SCHEDULED and OPEN, which belong to meetings).
CallOutcome is set once, on entry to ENDED, and copied into the thread's
CALL_EVENT:
| Outcome | Meaning |
|---|---|
COMPLETED |
reached ACTIVE — somebody answered |
MISSED |
rang out past the 45s timeout |
DECLINED |
callee explicitly refused |
CANCELLED |
caller hung up before it was answered |
FAILED |
could not be set up at all |
BUSY |
callee already in another call |
CANCELLED and MISSED are deliberately separate — "I gave up" and "you never
picked up" are different facts, and collapsing them loses the one the caller
cares about.
POST /api/v1/calls — Start a call
Rings somebody. Returns the caller's own room credentials.
Request (StartCallRequest) — exactly one target must be present:
| Field | Type | Required | Description |
|---|---|---|---|
conversationId |
UUID | one-of | Calling from an open thread |
targetShopId |
UUID | one-of | Calling a shop cold from its page; resolves or creates the commerce thread |
targetAccountId |
UUID | one-of | Calling a person you may not have messaged yet |
kind |
enum | yes | AUDIO or VIDEO |
shopId |
UUID | no | Present when calling as a shop (different from targetShopId). Validated against the caller's real contexts, never trusted |
curl -s -X POST $BASE/api/v1/calls \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"targetAccountId":"...","kind":"AUDIO"}'
Response (CallTokenResponse):
| Field | Type | Description |
|---|---|---|
callId |
UUID | |
roomName |
string | LiveKit room |
token |
string | One room, one identity, 10-minute TTL |
url |
string | LiveKit websocket URL |
kind |
enum | AUDIO / VIDEO |
state |
enum | RINGING |
kind is fixed for the call's lifetime. A video call that drops to audio is
still a VIDEO call that degraded, and the client decides that, not the
server.
POST /api/v1/calls/{callId}/join — Answer
For a callee that learned about the call over SSE or a cold start. A push-woken client already has its token in the payload and skips this round trip entirely.
| Param | In | Required | Description |
|---|---|---|---|
callId |
path | yes | |
shopId |
query | no | Answering as a shop |
Returns CallTokenResponse.
POST /api/v1/calls/{callId}/end — Hang up, decline, or cancel
All three are one verb. Which of the three it was is decided by the call's state and who asked — not by the client naming it — so a client cannot report a call it declined as one it completed.
| Param | In | Required |
|---|---|---|
callId |
path | yes |
shopId |
query | no |
Returns CallResponse.
POST /api/v1/calls/{callId}/participants — Pull somebody in
Adds a person to a call already running. Initiator only, personal calls only.
| Param | In | Required |
|---|---|---|
callId |
path | yes |
accountId |
query | yes |
Refuses with CALL_TOO_LARGE past app.livekit.group-max-participants — a
hard refusal rather than ringing the first N, because silently dropping members
of a group call is worse than saying no: nobody can tell who was left out.
GET /api/v1/calls — Call history
The Calls list. Keyed on who was ON the call rather than on conversations, so a call somebody added you to appears here even though its thread is not one of yours.
| Param | In | Default |
|---|---|---|
shopId |
query | — (personal) |
limit |
query | 30 |
Returns CallResponse[].
GET /api/v1/calls/{callId} — One call
Returns CallResponse. Carries no token — a client that wants to join asks
for one, so a stale poll cannot hand out live credentials.
CallResponse
| Field | Type |
|---|---|
callId |
UUID |
conversationId |
UUID |
kind |
AUDIO / VIDEO |
state |
RINGING / ACTIVE / ENDED |
outcome |
see table above |
initiatorJid |
string |
createdAt, answeredAt, endedAt |
ISO-8601 |
durationSeconds |
long |
Call refusal codes
| Code | When |
|---|---|
AMBIGUOUS_TARGET |
zero or more than one target given |
NO_COUNTERPARTY |
nobody on the other end to ring |
BUSY |
callee already in another call |
ALREADY_IN_CALL |
the caller is |
BLOCKED |
blocked in either direction |
CALL_TOO_LARGE |
past the participant cap |
CALL_ENDED |
joining or acting on a finished call |
NOT_A_PARTICIPANT |
you were never on it |
NOT_INITIATOR |
only the initiator may add participants |
NOT_A_PERSONAL_CALL |
add-participant is personal-calls-only |
NOT_INVITED |
not on the guest list |
Part 2 — Meetings
api/v1/meetings — scheduled and open calls. Separate from calls because
the verbs differ: you schedule and join a meeting; you ring and answer a
call.
A meeting is a CallEntity too, so it shares the state machine — but it is
joined rather than answered, so it never rings and can never be MISSED.
SCHEDULED ──(its window opens)──> OPEN ──(host ends it)──> ENDED
OPEN means the room is live for its window — and stays open when empty,
since somebody looking in at 16:58 must not end the 17:00 meeting.
Meeting flow
HOST BACKEND INVITEES
| | |
|-- POST /meetings ------------------>| state SCHEDULED |
| {title, scheduledAt, invitees} | |
| |== SSE meeting.scheduled ==>| (once, for the calendar)
| | |
| [ MeetingScheduleJob: window opens ] |
| | state OPEN |
| |== SSE meeting.starting ===>| (NO token — see below)
| | |
|-- POST /{id}/join ----------------->|<---- POST /{id}/join ------|
|<-- CallTokenResponse -------------- |----> CallTokenResponse --->|
| | |
|-- POST /{id}/end ------------------>| state ENDED |
meeting.starting carries no token, unlike call.incoming. A meeting is
joined when the user decides to, not by a phone deciding for them.
Roles — a bandwidth decision, not ceremony
| Role | May |
|---|---|
HOST |
speak, promote, demote, end |
SPEAKER |
publish; shares the publisher cap |
LISTENER |
subscribe only |
A speaker costs an uplink and one decode on every other device; a listener costs one downstream stream and no uplink at all. That is why the participant cap counts speakers and lets listeners run far higher — and why the role is enforced in the token grant rather than asked of the client.
Access
CallAccess |
Meaning |
|---|---|
INVITED |
a guest list — behaves like a group call arranged in advance (default) |
OPEN |
anyone holding a join link — but still a NexGate account, because a participant without a JID is unaddressable and unblockable |
POST /api/v1/meetings — Schedule a meeting
Request (CreateMeetingRequest):
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | yes | A call needs no name because nobody labels "phoning Asha"; a meeting does, because everybody labels "Friday pricing review" |
description |
string | no | |
scheduledAt |
ISO-8601 | no | Absent means now |
endsAt |
ISO-8601 | no | When the room closes itself. Approximate by nature — not a promise about when people stop talking |
kind |
enum | no | AUDIO / VIDEO |
access |
enum | no | INVITED (default) or OPEN |
conversationId |
UUID | no | Anchored to a thread → writes its CALL_EVENT there; standalone → recorded in GET /api/v1/calls instead |
inviteeAccountIds |
UUID[] | no |
Returns CallResponse with state: SCHEDULED.
POST /api/v1/meetings/{meetingId}/join — Join
Returns CallTokenResponse. The token's grants encode the caller's role.
POST /api/v1/meetings/{meetingId}/links — Create a join link
Role-scoped: a speaker link and a listener link are different links.
| Param | In | Default |
|---|---|---|
meetingId |
path | — |
role |
query | LISTENER |
Response:
{ "token": "…", "role": "LISTENER", "expiresAt": "2026-09-19T…" }
Links live 30 days. You cannot mint a HOST link — that refuses with
CANNOT_SHARE_HOST.
POST /api/v1/meetings/join/{token} — Join by link
A link never downgrades somebody who is already in. If a person was invited as a
SPEAKERand then arrives through aLISTENERlink, they stay a speaker. The link's role applies only to somebody joining for the first time, so sharing a listener link into a group of invited speakers does not silence them. Verified on staging.
The link still requires a signed-in account. An unauthenticated request is
401, not a guest session — a participant without a JID would be unaddressable and unblockable.
Path takes the link token. Still requires an authenticated NexGate account.
Returns CallTokenResponse.
PUT /api/v1/meetings/{meetingId}/participants/role — Promote / demote
| Param | In | Required |
|---|---|---|
jid |
query | yes |
role |
query | yes (HOST / SPEAKER / LISTENER) |
Host only.
POST /api/v1/meetings/{meetingId}/end — End it
Returns CallResponse with state: ENDED.
Meeting refusal codes
| Code | When |
|---|---|
TITLE_REQUIRED |
no title |
NOT_A_MEETING |
the id is a call, not a meeting |
NOT_HOST |
host-only verb |
NOT_INVITED |
INVITED access and you are not on the list |
MEETING_ENDED |
joining a finished meeting |
LINK_EXPIRED |
past the 30-day link lifetime |
SPEAKER_LIMIT |
stage is full; join as a listener |
CANNOT_SHARE_HOST |
you may not mint a HOST link |
BLOCKED |
blocked in either direction |
Part 3 — The real-time channel
GET /api/v1/events (SSE)
One stream covers every context the caller may act for, so the context switcher and the permission check read the same source and cannot disagree.
Client note: the browser EventSource API cannot set an Authorization
header, so web must use fetch-based streaming, not EventSource. Putting
the token in the query string would leak credentials into access logs and proxy
history — a poor trade for a few lines of client code.
Resume with Last-Event-ID (header wins) or ?lastEventId=.
Call and meeting events
| Event | Carries a token? | Payload |
|---|---|---|
call.incoming |
yes | callId, conversationId, kind, from, roomName, url, token |
call.updated |
no | |
call.ended |
no | callId, conversationId, outcome, durationSeconds |
meeting.scheduled |
no | meetingId, title, scheduledAt, hostJid, conversationId? |
meeting.starting |
no, on purpose |
call.ended goes to everyone, including whoever hung up — their other
devices are still ringing otherwise.
Signalling fires AFTER_COMMIT throughout: alerting someone about a call whose
row rolled back would ring a phone for a call that does not exist. SSE reaches
a device that already holds a connection; push wakes one that does not. Both
go out — a device with a live stream ignores the duplicate, and guessing
wrong means a call nobody hears.
POST /api/v1/calls/livekit/webhook — internal
LiveKit → backend. This is what actually moves a call to ACTIVE and to
ENDED. Not for clients; signed by LiveKit.
Configuration
| Property | Default | Meaning |
|---|---|---|
app.livekit.url |
ws://127.0.0.1:7880 |
SFU websocket |
app.livekit.token-ttl-seconds |
600 |
Join token lifetime |
app.livekit.ring-timeout-seconds |
45 |
Unanswered → MISSED |
app.livekit.group-max-participants |
8 |
Group call / meeting speaker cap |
The CallTimeoutJob runs every 15 seconds against the 45-second timeout, so a
missed call settles within a minute. The client stops its own ringing at 45s
regardless.
No comments to display
No comments to display