Skip to main content

Voice & Video Calls — Technical Reference

← Back to Voice & Video Calls

Where this lives

Backend

Frontend

Technical implementation checklist

The checkboxes below (and on the idea-tier page) track backend + frontend-nextjs (web) status specifically, consistent with how every other feature in these docs is scored. iOS wiring is documented separately in each line for reference, since a lot of this feature is genuinely more complete on iOS than on web — but iOS state doesn't check a web box.

  • startCall — wired end-to-end on web (ChatView.tsx); also wired on iOS (CallStore+StartCall.swift)
  • answerCall / declineCallweb has no incoming-call UI: CallIncomingDocument is imported in ChatView.tsx but never passed to useSubscription — dead import, and neither mutation has a frontend caller. Fully wired on iOS via CallKit (CallKitManager.swift, IncomingCallView.swift)
  • endCall (mute is a client-only control) — wired on web (VoiceCallModal.tsx: toggleMute, handleEndCall); also wired on iOS (CallControlButtons.swift)
  • myCallHistory — zero references anywhere in apps/frontend-nextjs/src. Wired on iOS (CallStore.swift)
  • joinAsViewer / promoteToSpeaker / demoteToViewer / requestToSpeak / approveSpeakerRequest / denySpeakerRequest / cancelSpeakerRequest / pendingSpeakerRequests — zero references anywhere in apps/frontend-nextjs/src — no group-call viewer/speaker UI on web. Fully implemented on iOS (SpeakerRequestBanner.swift, SpeakerRequestsSheet.swift, CallStore+SpeakerManagement.swift)
  • call (single-call query), updateCallStatus, and mySpeakerRequests exist in the schema and resolvers but have zero references in apps/frontend-nextjs/src or apps/ios — not yet consumed by any client
  • VoIP push (voip-push.service.js, paired with iOS VoIPPushManager.swift) is inherently iOS-only — there's no browser equivalent, so it isn't tracked as a web checklist item

Types

enum CallType { VOICE VIDEO }
enum CallStatus { INITIATING RINGING ACCEPTED DECLINED MISSED ENDED FAILED }

Call model

FieldDescription
conversationIdConversation associated with the call
callerId / receiverIdInitiator and recipient
typeVOICE or VIDEO
statusCurrent call status
startedAt / endedAt / durationTiming information
participantsList of call participants
activeParticipantCountCurrently active participants

Participants

enum CallParticipantRole { CALLER RECEIVER PARTICIPANT }
enum CallParticipantStatus { INVITED RINGING JOINED LEFT DECLINED MISSED }

Each participant records joinedAt, leftAt, and their individual duration.

Queries

myCallHistory returns a reverse-chronological list of all calls the user participated in, with type, status, and duration. Filter by type to show "Missed calls" or "Video calls."

activeCall checks whether there is a live call in a given conversation right now. Use this when entering a conversation to decide whether to show a "Join active call" banner.

call fetches a single call by ID (caller/receiver or a CallParticipant row is required to view it). Not yet called from any client.

query CallHistory($limit: Int, $offset: Int) {
myCallHistory(limit: $limit, offset: $offset) {
id type status duration createdAt
caller { username profilePicture }
receiver { username profilePicture }
}
}

query ActiveCall($conversationId: String!) {
activeCall(conversationId: $conversationId) { id status activeParticipantCount }
}

query GetCall($id: ID!) {
call(id: $id) { id status duration activeParticipantCount }
}

Starting and managing calls

startCall initiates a call and returns a LiveKit token, wsUrl, and roomName. The caller's client connects to LiveKit using these credentials. The backend simultaneously dispatches a VoIP push to the receiver (iOS) or a WebSocket notification (web/Android) via callIncoming.

answerCall is called by the receiver when they tap "Accept." It returns their own LiveKit token.

declineCall rejects an incoming call. The caller receives a callStatusChanged event with status DECLINED.

joinCall allows a third participant to join an ongoing group call. It returns a LiveKit token for the new participant.

leaveCall removes the caller from the active call without ending it (other participants stay). endCall terminates the call for all participants and records the final duration.

# Initiate a call — returns LiveKit credentials
mutation StartCall($input: StartCallInput!) {
startCall(input: $input) { token wsUrl roomName callId role }
}

# Accept an incoming call
mutation AnswerCall($callId: String!) { answerCall(callId: $callId) { token wsUrl } }

# Reject an incoming call
mutation DeclineCall($callId: String!) { declineCall(callId: $callId) { status } }

# Join an ongoing group call as a new participant
mutation JoinCall($callId: String!) { joinCall(callId: $callId) { token wsUrl } }

# Leave without ending (others stay connected)
mutation LeaveCall($callId: String!) { leaveCall(callId: $callId) { status } }

# End call for all participants
mutation EndCall($input: EndCallInput!) { endCall(input: $input) { duration } }

# Update call status directly (not currently called from any client)
mutation UpdateCallStatus($input: UpdateCallStatusInput!) { updateCallStatus(input: $input) { status } }

Viewer / speaker mode

Group calls support viewers who watch without speaking. A viewer can request to become a speaker; the host approves or denies.

joinAsViewer joins the LiveKit room in a receive-only role. promoteToSpeaker grants the viewer a speaker role — the backend reissues their LiveKit token with publish permissions. demoteToViewer revokes speaker permissions and returns the user to view-only mode.

requestToSpeak creates a pending request visible to the host. approveSpeakerRequest accepts the request and promotes the user. denySpeakerRequest rejects it. cancelSpeakerRequest lets the requester withdraw their own pending request. A max of 30 speakers is enforced per call.

enum SpeakerRequestStatus { PENDING APPROVED DENIED CANCELLED }

pendingSpeakerRequests lists the outstanding requests for a call (host/speakers only). mySpeakerRequests lists the calling user's own requests across calls.

# Join without microphone/camera (receive-only)
mutation JoinAsViewer($callId: String!) { joinAsViewer(callId: $callId) { token role } }

# Host promotes a viewer to speaker (reissues LiveKit token with publish perms)
mutation PromoteToSpeaker($callId: String!, $userId: String!) { promoteToSpeaker(callId: $callId, userId: $userId) { token } }

# Host demotes a speaker back to viewer
mutation DemoteToViewer($callId: String!, $userId: String!) { demoteToViewer(callId: $callId, userId: $userId) { token } }

# Viewer requests to speak — creates a pending request the host sees
mutation RequestToSpeak($callId: String!) { requestToSpeak(callId: $callId) { id status } }
mutation ApproveSpeakerRequest($requestId: String!) { approveSpeakerRequest(requestId: $requestId) { status } }
mutation DenySpeakerRequest($requestId: String!) { denySpeakerRequest(requestId: $requestId) { status } }
mutation CancelSpeakerRequest($requestId: String!) { cancelSpeakerRequest(requestId: $requestId) { status } }

query PendingSpeakerRequests($callId: String!) { pendingSpeakerRequests(callId: $callId) { id status user { username } } }
query MySpeakerRequests { mySpeakerRequests { id status callId } }

Real-time subscriptions

All call events are delivered over WebSocket. Subscribe to these on the call screen to update the UI without polling.

# Fires on the receiver's device when a new call is incoming
subscription CallIncoming($userId: String!) { callIncoming(userId: $userId) { id type caller { username } } }

# Fires when the call status changes (RINGING → ACCEPTED, etc.)
subscription CallStatusChanged($callId: String!) { callStatusChanged(callId: $callId) { status } }

# Fires when the call ends — includes final duration
subscription CallEnded($callId: String!) { callEnded(callId: $callId) { duration } }

subscription ParticipantJoined($callId: String!) { participantJoined(callId: $callId) { user { username } } }
subscription ParticipantLeft($callId: String!) { participantLeft(callId: $callId) { user { username } } }

# Fires on the host when a viewer requests to speak
subscription SpeakerRequestReceived($callId: String!) { speakerRequestReceived(callId: $callId) { user { username } } }

# Fires on the viewer when their speaker request is approved or denied
subscription SpeakerRequestResponse($userId: String!) { speakerRequestResponse(userId: $userId) { status } }

VoIP (iOS)

VoIP push tokens allow incoming calls to wake the iOS app even when it's in the background. Register the token after a successful login; unregister it on logout.

mutation RegisterVoIPToken($token: String!) { registerVoIPToken(token: $token) { success } }
mutation UnregisterVoIPToken($token: String!) { unregisterVoIPToken(token: $token) { success } }