Calls
A call belongs to a conversation. A conversation has at most one live call at a time. Signalling (placing, ringing, answering) goes through these endpoints. Audio and video go through a LiveKit media server, using the credentials these endpoints return.
- A call rings the conversation's other members.
- A room rings nobody. Members join it when they want, like a meeting room.
For the model see Calls & rooms. For finished calls see Call history. For the end-to-end phone flow see Ringing phones end to end.
Credentials
| Endpoints | Credential |
|---|---|
| Place, open room, read live call, join, media token | End-user JWT |
| Answer | Ring grant: the grant field of the ring push |
| Decline | Ring grant, or end-user JWT |
| Hang up, reconnect, ring more people | Call-control token: control_token from any session response |
All are sent as Authorization: Bearer <token>. A ring grant expires
180 seconds after the push was sent, which is longer than the longest
ring a workspace can set (120 seconds). A ring that has already ended
refuses the grant anyway. A control token lasts as long as the call, up
to 24 hours. Each works only for its own call. Any failure returns
401 unauthorized.
Every one of these credentials is checked against its user on each
request. A blocked user is refused with 401 unauthorized and the
message User is blocked., even with a grant or control token issued
before the block. A user that no longer exists gets 401 unauthorized
with Invalid or expired token.
Lifecycle
place (POST …/calls) room (POST …/room)
│ │
▼ ▼
live, ringing the others ─ first answer ─▶ live, answered
│ │
└──────────────┬──────────────────┘
▼
finished
- Live. Once placed, a call stays live and joinable by any member until it finishes. A member who declined, or whose ring ran out, can still join it.
- Ringing. Each person rung is rung for the workspace's ring time
(set on the dashboard's Calls page). Their
state in
ring_statesgoes fromringingtoanswered,declinedormissed. - Declining settles that one person's ring. It never ends the call.
- Finishing. A call finishes when the people in it leave. A hang-up ends it at once when at most one person is left and nobody is still ringing. If the caller hangs up while it is still ringing and nobody has answered, the call is cancelled. When people drop off without hanging up, the call finishes after a short grace period, so a phone that is reconnecting does not end it.
- After it finishes, the call endpoints return
410 call_finished, and the call appears in Call history.
Members follow these changes over realtime: call:started,
call:participants and call:finished on the
conversation channel, and
call:ring_ended and call:finished on the
user channel.
The session response
Placing, opening a room, joining, answering and reconnecting all return the same object:
{
"cv": 1,
"created": true,
"call": {
"id": "4a6922a7-0c3e-4b1d-9f5a-8e2c6d0b7a14",
"conversation_id": "c8a1f4e2-6b3d-4f7a-9c2e-1d5b8a0f3e61",
"conversation_name": "Engineering",
"media": "video",
"kind": "call",
"state": "live",
"started_by": {
"user_id": "11111111-2222-3333-4444-555555555555",
"external_id": "acme-alice",
"display_name": "Alice"
},
"started_at": "2026-10-26T07:33:20.000Z",
"ended_at": null,
"participants": [
{ "user_id": "11111111-2222-3333-4444-555555555555", "external_id": "acme-alice", "display_name": "Alice" }
],
"ring_states": {
"22222222-3333-4444-5555-666666666666": "ringing"
},
"video_allowed": true,
"max_participants": 16
},
"livekit": {
"url": "wss://…",
"token": "eyJhbGciOi…"
},
"control_token": "eyJhbGciOi…"
}
| Field | Type | Description |
|---|---|---|
cv | integer | Response format version. Currently 1. |
created | boolean | true when this request started the call or room. false when it joined one already live. |
call | object | The call summary, below. |
livekit.url | string | The media server to connect to. |
livekit.token | string | Media access token for this user. Treat it as a credential. It can publish the microphone only (no camera) when the workspace has video off or this month's video minutes are used up. |
control_token | string | Use it for hang up, reconnect and ring. |
The call summary
| Field | Type | Description |
|---|---|---|
id | uuid | The call id. |
conversation_id | uuid | |
conversation_name | string | null | |
media | "audio" | "video" | |
kind | "call" | "room" | |
state | "live" | "finished" | |
started_by | object | { user_id, external_id, display_name }. |
started_at | string | null | ISO 8601. |
ended_at | null | Always null here. Read Call history for the end time. |
participants | object[] | { user_id, external_id, display_name } for each person in the call now. |
ring_states | object | Poolse user id → ringing, answered, declined or missed, for each person rung. {} for a room. |
video_allowed | boolean | The workspace allows video. When false, every call is audio-only and the media token can publish only the microphone. |
max_participants | integer | The workspace's people-per-call limit. |
Place a call
/v1/conversations/:conversation_id/callsRings the conversation's other members on every registered device. See Devices. If a call or room is already live on the conversation, this joins it instead. Two members placing at once end up in the same call.
| Field | Type | Required | Description |
|---|---|---|---|
media | "audio" | "video" | no | Default audio. Any other value is treated as audio. video becomes audio when the workspace has video off. |
201 with the session response when it placed a new call. 200 when it
joined a live one.
The caller is in the call from the moment they place it, so their participant-minutes start then, while the call is still ringing.
curl -X POST https://api.poolse.dev/v1/conversations/$CONV_ID/calls \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{"media":"video"}'
| Code | Status | When |
|---|---|---|
forbidden | 403 | The caller is not a member. |
not_found | 404 | No such conversation. |
call_minutes_exhausted | 402 | This month's minutes for the medium are used up (Limits & quotas). |
call_full | 409 | Joining the live call would exceed max_participants. |
sfu_not_configured | 503 | No media server is configured. |
Open a room
/v1/conversations/:conversation_id/roomStarts a room on the conversation, which rings nobody, or joins whatever
is live there. Same body (media), same responses and errors as
Place a call. Joining a ringing call this way counts
as answering it.
Read the live call
/v1/conversations/:conversation_id/call200: { "cv": 1, "call": CallSummary }, where call is the
call summary. Use it to show a "join" banner.
| Code | Status | When |
|---|---|---|
no_live_call | 404 | Nothing is live on this conversation. |
forbidden | 403 | The caller is not a member. |
not_found | 404 | No such conversation. |
Join a call
/v1/calls/:id/joinJoins a live call or room as any member, at any time. If the caller's phone is ringing for this call, joining counts as answering it. No body.
200 with the session response.
| Code | Status | When |
|---|---|---|
call_finished | 410 | The call has ended, or is unknown. |
call_minutes_exhausted | 402 | This month's minutes for the call's medium are used up. |
call_full | 409 | The call is at max_participants. Someone already in it can always rejoin. |
forbidden | 403 | The caller is not a member of the conversation. |
sfu_not_configured | 503 | No media server is configured. |
Answer
/v1/calls/:id/answerAnswers from the push, with no user session: a locked or closed phone can answer. No body. The caller's other devices stop ringing.
200 with the session response.
| Code | Status | When |
|---|---|---|
unauthorized | 401 | Missing or expired grant, a grant for another call, or the user is blocked. |
mau_cap_exceeded | 402 | The user is new this month and the plan's monthly active users are full. Any call-grant route can return it. |
call_finished | 410 | The call ended before the answer arrived. |
call_full | 409 | The call is at max_participants. |
forbidden | 403 | The user is no longer a member. |
sfu_not_configured | 503 | No media server is configured. |
Decline
/v1/calls/:id/declineSettles this user's ring and stops their other devices ringing. It never ends the call.
| Field | Type | Required | Description |
|---|---|---|---|
reason | "declined" | "busy" | no | Default declined. Send busy when the user is already on another call. It shows as busy in call history. |
204, also when the call has already ended. 401 unauthorized for a bad
credential or a blocked user.
Hang up
/v1/calls/:id/hangupLeaves the call. See Lifecycle for when this ends the call
for everyone. No body. 204, also when repeated. 401 unauthorized for
a bad token or a blocked user.
Reconnect
/v1/calls/:id/reconnectGets a fresh session (new media token and control token) for a client that lost its connection. No body.
200 with the session response, created: false.
| Code | Status | When |
|---|---|---|
unauthorized | 401 | Bad token, or the user is blocked. |
call_finished | 410 | The call has ended. |
forbidden | 403 | The user is no longer a member. |
sfu_not_configured | 503 | No media server is configured. |
Ring more people
/v1/calls/:id/ringRings more of the conversation's members into a call already under way ("add people"). Each person is rung exactly as on a new call.
| Field | Type | Required | Description |
|---|---|---|---|
user_ids | uuid[] | no | People by poolse id. |
external_ids | string[] | no | People by your external_id. |
People are skipped without an error if they are unknown, not members, already in the call, or still ringing.
200:
{ "cv": 1, "rung_user_ids": ["33333333-4444-5555-6666-777777777777"] }
rung_user_ids lists the people actually rung.
| Code | Status | When |
|---|---|---|
unauthorized | 401 | Bad token, or the user is blocked. |
call_minutes_exhausted | 402 | This month's minutes for the call's medium are used up. |
call_not_answered | 409 | Nobody has answered the call yet. |
call_finished | 410 | The call has ended. |
forbidden | 403 | The caller is no longer a member. |
Media token
/v1/conversations/:conversation_id/call-tokenMints media-server credentials for the call or room that is live on the
conversation, without placing, joining or answering anything. A call or
room must already be live: start one first with
POST …/room or
POST …/calls. That is what counts the minutes, so a
token is never issued for media outside a call. Use it when your client
manages the media itself (for example web calls; see
Web calls with LiveKit). It does not register
the user in the call. The session responses above already include a
media token.
No body.
200:
{
"url": "wss://…",
"token": "eyJhbGciOi…",
"room": "conversation:c8a1f4e2-6b3d-4f7a-9c2e-1d5b8a0f3e61",
"expires_at": 1761486800
}
| Field | Type | Description |
|---|---|---|
url | string | Media server URL. |
token | string | Access token. Treat it as a credential. It carries the user's display_name, and the tenant id and external_id in its metadata. Microphone only when video is off or this month's video minutes are used up. |
room | string | conversation:<conversation_id>. |
expires_at | integer | Unix seconds. Fetch a new token after this. |
| Code | Status | When |
|---|---|---|
forbidden | 403 | The caller is not a member. |
not_found | 404 | No such conversation. |
call_minutes_exhausted | 402 | The month's minutes for the live call's medium are used up. |
call_full | 409 | A live call on the conversation is at max_participants and the caller is not in it. |
no_live_call | 409 | No call or room is live on the conversation. |
sfu_not_configured | 503 | No media server is configured. |