Calls (1:1 voice & video)
Ringing, answer, decline, end — with high-priority push to the callee's iOS/Android device.
On this page
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:
- Firebase service-account JSON (recommended — works on iOS + Android + Web)
- APNs
.p8key + team ID + key ID + bundle ID (iOS-only native, lower latency)
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: [...] },
...
]
| State | Meaning |
|---|---|
waiting | Ringing — callee hasn't answered yet |
live | In progress |
ended | Completed normally (both parties hung up) |
cancelled | Caller 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>
);
}