Calls (1:1 voice & video)

Ringing, answer, decline, end — with high-priority push to the callee's iOS/Android device.

1. The call flow

┌──────────────┐                                  ┌─────────────┐
│              │   1. /me/calls/invite           │             │
│   Caller     │ ──────────────────────────────▶ │  Kardolive  │
│              │                                  │             │
│              │   2. returns sessionId           │             │
│              │ ◀─────────────────────────────── │  creates    │
│              │                                  │  session    │
└──────────────┘                                  │  type='call'│
                                                  │  state=     │
                                                  │  'waiting'  │
┌──────────────┐                                  │             │
│              │   3. APNs/FCM push               │             │
│   Callee     │ ◀─────────────────────────────── │  pushes     │
│   (offline)  │                                  │             │
│              │                                  │             │
│              │   4. /me/calls/:id/answer        │             │
│              │ ──────────────────────────────▶ │  session    │
│              │                                  │  state=     │
│              │   5. sessionId + JWT             │  'live'     │
│              │ ◀─────────────────────────────── │             │
│              │                                  │             │
└──────┬───────┘                                  └──────┬──────┘
       │                                                 │
       │   6. both ends open WebRTC to SFU              │
       └────────────────────────────────────────────────┘
                       MEDIA FLOWS
                  (sub-300ms typical)

A call is just a LiveSession with type: 'call', capacity: 2, waitingRoom: true. The /me/calls/* endpoints are convenience wrappers that orchestrate ringing + push.

2. Invite — caller side

// your app backend
@Post('contacts/:contactId/call')
async startCall(
  @Req() req: { user: { id: string; name?: string } },
  @Param('contactId') contactId: string,
  @Body() body: { kind: 'voice' | 'video' },
) {
  const r = await fetch('https://kardocloud.com/v1/me/calls/invite', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.KARDOLIVE_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      fromUserId: req.user.id,
      fromDisplayName: req.user.name,
      toUserId: contactId,
      kind: body.kind,
      projectId: process.env.KARDOLIVE_PROJECT_ID,
    }),
  });
  if (!r.ok) throw new Error('call invite failed');
  return r.json(); // { sessionId, state:'waiting', ... }
}

// Caller's client immediately joins the session and waits for the callee
@Post('contacts/:contactId/call')
const { sessionId } = await fetch('/api/contacts/' + contactId + '/call', {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ kind: 'video' }),
}).then((r) => r.json());

// Open the conference UI (uses useSession from React quickstart)
setActiveCallSession(sessionId);

Once the caller's client joins, they hear/see nothing from the remote side until the callee answers — that's the "ringing" state from the caller's perspective.

3. Answer — callee side

The callee got a push notification (see below). Their app foregrounds with the call details, they tap "Answer":

// your app callee app — handle push payload
//   { kardolive: '1', type: 'call.invite', sessionId, kind, from }

async function answerCall(sessionId: string) {
  // 1) Mark session live
  await fetch(`/api/kardolive/calls/${sessionId}/answer`, { method: 'POST' });

  // 2) Open the conference UI to actually receive media
  setActiveCallSession(sessionId);
}

// your app backend
@Post('calls/:sessionId/answer')
async answer(@Req() req: any, @Param('sessionId') sessionId: string) {
  return fetch(`https://kardocloud.com/v1/me/calls/${sessionId}/answer`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.KARDOLIVE_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId: req.user.id }),
  }).then((r) => r.json());
}

The session transitions waiting → live. Both ends now see each other's media.

4. Decline / end

// Decline (before the call goes live)
POST /v1/me/calls/<sessionId>/decline
{ "userId": "<callee user id>" }
// → state: cancelled

// End (after going live)
POST /v1/me/calls/<sessionId>/end
{ "userId": "<either party's user id>" }
// → state: ended

Both endpoints fire webhooks (call.declined, call.ended) so the other party can clean up.

5. Voice vs video

One field controls everything:

{ "kind": "voice" }   // just audio
{ "kind": "video" }   // audio + video

Voice-only calls don't request a camera permission on the client side. Use:

// In the client, when opening the session
const { localRef, remotes, state } = useSession(sessionId, {
  audio: true,
  video: kind === 'video',  // false for voice-only
});

The UI typically renders a pulse animation around an avatar for voice-only — see /scenarios/voice-call/ for a complete reference UI.

6. Push notifications (the ringing)

Without push, the callee never knows you called. Wire this once per environment:

Step 1 — upload push credentials (one time)

In the Kardolive dashboard: Project → Push notifications. Upload either:

Step 2 — register devices from your backend client

// When user grants notification permission in your app:
const apnsOrFcmToken = await getPlatformPushToken();

await fetch('/api/kardolive/register-device', {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ platform: 'ios', token: apnsOrFcmToken }),
});

// your app backend
@Post('register-device')
async registerDevice(@Req() req: any, @Body() body: { platform: 'ios'|'android'|'web', token: string }) {
  return fetch('https://kardocloud.com/v1/me/push/devices', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.KARDOLIVE_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      userId: req.user.id,
      platform: body.platform,
      token: body.token,
      projectId: process.env.KARDOLIVE_PROJECT_ID,
    }),
  }).then((r) => r.json());
}

Step 3 — handle incoming push on the callee's device

iOS (Swift):

// AppDelegate
func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable : Any],
                 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    if let payload = userInfo as? [String: Any],
       payload["kardolive"] as? String == "1",
       payload["type"] as? String == "call.invite",
       let sessionId = payload["sessionId"] as? String,
       let kind = payload["kind"] as? String {
        // Open your IncomingCall view
        showIncomingCall(sessionId: sessionId, kind: kind)
    }
    completionHandler(.newData)
}

Android (Kotlin):

class FCMService : FirebaseMessagingService() {
  override fun onMessageReceived(msg: RemoteMessage) {
    val data = msg.data
    if (data["kardolive"] == "1" && data["type"] == "call.invite") {
      val sessionId = data["sessionId"]!!
      val kind = data["kind"]!!
      // Use Android's CallStyle notification for native ringing UI
      ringIncomingCall(sessionId, kind)
    }
  }
}

Web (Service Worker):

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  if (data.kardolive === '1' && data.type === 'call.invite') {
    event.waitUntil(self.registration.showNotification(
      data.from + ' is calling',
      { body: data.kind + ' call', data, requireInteraction: true }
    ));
  }
});

7. Call history & missed calls

Calls are LiveSession rows of type: 'call'. List them like any other session:

GET /v1/me/sessions?type=call&limit=50
→ [
  { id, state, startedAt, endedAt, participants: [...] },
  ...
]
StateMeaning
waitingRinging — callee hasn't answered yet
liveIn progress
endedCompleted normally (both parties hung up)
cancelledCaller hung up before answer, OR callee declined

A missed call = state: 'cancelled' and the callee was in the participants but never set joinedAt. Detect from your callee-side webhook handler when call.invited fires but call.answered never does.

8. Full contact-call example

Adds a "Call" button to a your app contact screen. Voice and video.

// ── your app backend ───────────────────────────────
@Post('contacts/:contactId/call')
async startCall(@Req() req: any, @Param('contactId') contactId: string, @Body() body: { kind: 'voice' | 'video' }) {
  return fetch('https://kardocloud.com/v1/me/calls/invite', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.KARDOLIVE_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      fromUserId: req.user.id,
      fromDisplayName: req.user.name,
      toUserId: contactId,
      kind: body.kind,
      projectId: process.env.KARDOLIVE_PROJECT_ID,
    }),
  }).then((r) => r.json());
}

@Post('calls/:sessionId/:action')
async callAction(
  @Req() req: any,
  @Param('sessionId') sessionId: string,
  @Param('action') action: 'answer' | 'decline' | 'end',
) {
  return fetch(`https://kardocloud.com/v1/me/calls/${sessionId}/${action}`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.KARDOLIVE_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId: req.user.id }),
  }).then((r) => r.json());
}

// ── your app client — caller side ──────────────────
function ContactScreen({ contact }: { contact: any }) {
  const [callSession, setCallSession] = useState<{ id: string; kind: 'voice'|'video' } | null>(null);

  async function startCall(kind: 'voice' | 'video') {
    const r = await fetch(`/api/contacts/${contact.id}/call`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ kind }),
    });
    const { sessionId } = await r.json();
    setCallSession({ id: sessionId, kind });
  }

  return (
    <div>
      <h2>{contact.name}</h2>
      <button onClick={() => startCall('voice')}>📞 Voice</button>
      <button onClick={() => startCall('video')}>📹 Video</button>

      {callSession && (
        <CallView
          sessionId={callSession.id}
          kind={callSession.kind}
          onEnd={() => {
            fetch(`/api/calls/${callSession.id}/end`, { method: 'POST' });
            setCallSession(null);
          }}
        />
      )}
    </div>
  );
}

// ── CallView wraps useSession from React quickstart ─
function CallView({ sessionId, kind, onEnd }: { sessionId: string; kind: 'voice'|'video'; onEnd: () => void }) {
  const { localRef, remotes, state } = useSession(sessionId, {
    audio: true,
    video: kind === 'video',
  });
  return (
    <div className="call-overlay">
      {remotes.length === 0 && state !== 'closed' && (
        <div className="ringing">Calling {kind === 'video' ? 'with video' : ''}…</div>
      )}
      <video ref={localRef} className="self" autoPlay muted playsInline />
      {remotes.map((r) => (
        <video key={r.peerId} autoPlay playsInline ref={(el) => el && (el.srcObject = r.stream)} />
      ))}
      <button onClick={onEnd}>End</button>
    </div>
  );
}

// ── your app client — callee side (push handler) ───
// When push arrives with type='call.invite':
function IncomingCallScreen({ sessionId, fromName, kind }) {
  async function answer() {
    await fetch(`/api/calls/${sessionId}/answer`, { method: 'POST' });
    // Hand off to the same CallView
    setActiveCall({ sessionId, kind });
  }
  async function decline() {
    await fetch(`/api/calls/${sessionId}/decline`, { method: 'POST' });
    dismiss();
  }
  return (
    <div className="incoming-call">
      <div>{fromName} · {kind} call</div>
      <button onClick={answer}>Answer</button>
      <button onClick={decline}>Decline</button>
    </div>
  );
}

Next

Webhooks → · API reference →