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

EndpointsCredential
Place, open room, read live call, join, media tokenEnd-user JWT
AnswerRing grant: the grant field of the ring push
DeclineRing grant, or end-user JWT
Hang up, reconnect, ring more peopleCall-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_states goes from ringing to answered, declined or missed.
  • 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…"
}
FieldTypeDescription
cvintegerResponse format version. Currently 1.
createdbooleantrue when this request started the call or room. false when it joined one already live.
callobjectThe call summary, below.
livekit.urlstringThe media server to connect to.
livekit.tokenstringMedia 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_tokenstringUse it for hang up, reconnect and ring.

The call summary

FieldTypeDescription
iduuidThe call id.
conversation_iduuid
conversation_namestring | null
media"audio" | "video"
kind"call" | "room"
state"live" | "finished"
started_byobject{ user_id, external_id, display_name }.
started_atstring | nullISO 8601.
ended_atnullAlways null here. Read Call history for the end time.
participantsobject[]{ user_id, external_id, display_name } for each person in the call now.
ring_statesobjectPoolse user id → ringing, answered, declined or missed, for each person rung. {} for a room.
video_allowedbooleanThe workspace allows video. When false, every call is audio-only and the media token can publish only the microphone.
max_participantsintegerThe workspace's people-per-call limit.

Place a call

POST/v1/conversations/:conversation_id/calls
End-user JWT

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

FieldTypeRequiredDescription
media"audio" | "video"noDefault 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"}'
CodeStatusWhen
forbidden403The caller is not a member.
not_found404No such conversation.
call_minutes_exhausted402This month's minutes for the medium are used up (Limits & quotas).
call_full409Joining the live call would exceed max_participants.
sfu_not_configured503No media server is configured.

Open a room

POST/v1/conversations/:conversation_id/room
End-user JWT

Starts 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

GET/v1/conversations/:conversation_id/call
End-user JWT

200: { "cv": 1, "call": CallSummary }, where call is the call summary. Use it to show a "join" banner.

CodeStatusWhen
no_live_call404Nothing is live on this conversation.
forbidden403The caller is not a member.
not_found404No such conversation.

Join a call

POST/v1/calls/:id/join
End-user JWT

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

CodeStatusWhen
call_finished410The call has ended, or is unknown.
call_minutes_exhausted402This month's minutes for the call's medium are used up.
call_full409The call is at max_participants. Someone already in it can always rejoin.
forbidden403The caller is not a member of the conversation.
sfu_not_configured503No media server is configured.

Answer

POST/v1/calls/:id/answer
Ring grant

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

CodeStatusWhen
unauthorized401Missing or expired grant, a grant for another call, or the user is blocked.
mau_cap_exceeded402The user is new this month and the plan's monthly active users are full. Any call-grant route can return it.
call_finished410The call ended before the answer arrived.
call_full409The call is at max_participants.
forbidden403The user is no longer a member.
sfu_not_configured503No media server is configured.

Decline

POST/v1/calls/:id/decline
Ring grant or end-user JWT

Settles this user's ring and stops their other devices ringing. It never ends the call.

FieldTypeRequiredDescription
reason"declined" | "busy"noDefault 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

POST/v1/calls/:id/hangup
Call-control token

Leaves 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

POST/v1/calls/:id/reconnect
Call-control token

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

CodeStatusWhen
unauthorized401Bad token, or the user is blocked.
call_finished410The call has ended.
forbidden403The user is no longer a member.
sfu_not_configured503No media server is configured.

Ring more people

POST/v1/calls/:id/ring
Call-control token

Rings more of the conversation's members into a call already under way ("add people"). Each person is rung exactly as on a new call.

FieldTypeRequiredDescription
user_idsuuid[]noPeople by poolse id.
external_idsstring[]noPeople 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.

CodeStatusWhen
unauthorized401Bad token, or the user is blocked.
call_minutes_exhausted402This month's minutes for the call's medium are used up.
call_not_answered409Nobody has answered the call yet.
call_finished410The call has ended.
forbidden403The caller is no longer a member.

Media token

POST/v1/conversations/:conversation_id/call-token
End-user JWT

Mints 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
}
FieldTypeDescription
urlstringMedia server URL.
tokenstringAccess 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.
roomstringconversation:<conversation_id>.
expires_atintegerUnix seconds. Fetch a new token after this.
CodeStatusWhen
forbidden403The caller is not a member.
not_found404No such conversation.
call_minutes_exhausted402The month's minutes for the live call's medium are used up.
call_full409A live call on the conversation is at max_participants and the caller is not in it.
no_live_call409No call or room is live on the conversation.
sfu_not_configured503No media server is configured.
Calls · API · poolse docs