Palbase
Sign inGet started

Web SDK

Calls

pb.calls gives your web app voice and video over WebRTC, scoped to your messaging groups. A call is always addressed by a messaging group display id — a grp_<uuidv7> — and the group's membership is what decides who may join. You start or join a call and the SDK manages the room, the tracks and the <video> / <audio> elements for each remote participant.

Quick example

import { pb } from '@palbase/web';

const call = await pb.calls.start('grp_018f…');

The id you pass is the id of an active Chat: pb.messaging.directChat(…) and groupChat(…) return drafts whose id is a reserved local placeholder, and only the first send materializes the group and gives it its grp_ id. Call a chat that has never carried a message and the request will not resolve to a group.

Transport security, not end-to-end. Calls are transport-encrypted (DTLS-SRTP) today. Frame-level end-to-end encryption is not yet active — rooms currently connect without E2EE. setMediaKey is a wired design seam for the upcoming web-MLS frame key; until that lands, do not describe web calls as end-to-end encrypted.

Starting a call

const call = await pb.calls.start(groupId, { media: ['audio', 'video'] });

pb.calls.start(groupId, opts?) creates a call in the group and connects you to it. A Call begins in connecting and flips to active when the room reports it connected — but start() awaits that connection before it resolves, so the call you receive is normally already active. Read call.state rather than waiting for a transition that has already happened, and use subscribe for everything after it. Local tracks are published once the room is connected: the microphone first, then the camera, and only the ones you asked for.

ParameterTypeDescription
groupIdstringMessaging group display id (grp_<uuidv7>). Not a project id — no platform resource id is accepted here.
opts.mediaArray<'audio' | 'video'>Which local tracks to publish. Default: ['audio', 'video'].
// audio-only call
const call = await pb.calls.start('grp_018f…', { media: ['audio'] });

Throws

start() rejects with a BackendError. Exactly one of these is thrown by the SDK itself; the rest are server answers, and they all arrive with kind: 'server'.

Conditionkindcodestatus
groupId is empty (client-side guard, no request sent)'validation'invalid_group_id
Caller is not a member of the group'server'not_group_member403
The room is full'server'call_room_full409
The call service is unavailable'server'call_service_unavailable503

There is no 'forbidden' kind and no 'conflict' kind in this SDK. BackendErrorKind is exactly 'notConfigured' | 'validation' | 'unauthorized' | 'rateLimited' | 'server' | 'network' | 'decode', and 403, 409 and 503 all map to 'server' carrying the server's own code. BackendError also has no details property — discriminate on err.code:

import { pb, isBackendError } from '@palbase/web';

try {
  const call = await pb.calls.start('grp_018f…');
} catch (err) {
  if (isBackendError(err) && err.code === 'call_room_full') {
    // tell the user the room is full
  }
}

Use isBackendError(err), never err instanceof BackendError: the package ships dual ESM and CJS builds, and a mixed graph can hold two class identities.

Accepting and declining

Warning: The web SDK receives no call invites. There is no ring signalling, no incoming-call observable, no invite topic and no web push in @palbase/web — so nothing tells a browser that a call has started. You must deliver the callId to the callee yourself: your own realtime channel, your own backend notification, or a link. (The SDK's own doc comment says the id comes "from the push notification payload"; that is the iOS path, not this one.)

Once you have a callId:

// accept — joins the room, returns a connecting Call
const call = await pb.calls.accept(groupId, callId, { media: ['audio', 'video'] });

// decline — never joins
await pb.calls.decline(groupId, callId, 'busy');
MethodSignature
acceptaccept(groupId: string, callId: string, opts?: { media?: Array<'audio' | 'video'> }): Promise<Call>
declinedecline(groupId: string, callId: string, reason?: string): Promise<void>

accept takes the same opts.media shape as start (default ['audio', 'video']). decline's reason is an optional free-form string; omitting it sends an empty one. Neither method guards its arguments the way start guards groupId — an empty id reaches the server and comes back as a server error rather than a validation one.

The Call object

A Call is a live session obtained from start() or accept(). You cannot construct one.

Properties

PropertyTypeDescription
idstringThe call identifier.
stateCallStateCurrent lifecycle state (getter).
participantsreadonly CallParticipant[]Snapshot of remote participants (getter). Never includes you.

There is deliberately no local-participant handle. Exposing one would put a raw media-engine object on the public API and pin the SDK to that engine's types; a golden test fails if any of them reappear in the published type definitions. Control your own microphone and camera with mute() and setCamera().

CallState is one of:

ValueMeaning
'connecting'The returned state; the media room is not joined yet.
'active'Room joined, tracks flowing.
'ended'leave() was called, or the room disconnected remotely.

A CallParticipant describes one remote participant:

FieldTypeDescription
identitystringStable participant identity.
isSpeakingbooleanWhether they are currently speaking.
audioMutedbooleanWhether their microphone is muted.
videoEnabledbooleanWhether their camera is on.
videoElementHTMLVideoElement | nullLive <video> element, or null before the video track arrives.
audioElementHTMLAudioElement | nullLive <audio> element, or null before the audio track arrives.

Methods

MethodSignatureDescription
subscribesubscribe(cb: CallChangeCallback): UnsubscribeFire cb on state and participant changes. Returns an unsubscribe function.
mutemute(on: boolean): Promise<void>mute(true) mutes your microphone, mute(false) unmutes.
setCamerasetCamera(on: boolean): Promise<void>Enable or disable your camera.
setMediaKeysetMediaKey(key: ArrayBuffer): Promise<void>Set a raw frame-encryption key. Design seam for upcoming web-MLS frame E2EE — see the note below.
leaveleave(): Promise<void>Leave the call and disconnect from the room (state → 'ended').

CallChangeCallback is (call: Call) => void — it receives the call itself, so read the fresh state and participants off the argument.

setMediaKey is not yet load-bearing. It forwards key to the media engine's external key provider, but rooms currently connect without E2EE options, so the key has no effect on the wire today. It exists so the upcoming web-MLS work can wire an MLS-derived frame key in without an API change. Frame-level E2EE is coming with web-MLS; until then, treat calls as transport-encrypted only.

Lifecycle

connecting → active   (room joined, tracks flowing)
active     → ended    (leave() called, or remote disconnect)

subscribe fires whenever the state transitions or the participant set changes — someone joins, leaves, mutes, starts or stops speaking, or a track attaches. Re-read call.state and call.participants inside your callback and re-render. There is no React hook for calls; wire subscribe in an effect yourself.

Example — render a call

import { pb } from '@palbase/web';

const grid = document.getElementById('call-grid')!;

const call = await pb.calls.start('grp_018f…', { media: ['audio', 'video'] });

const unsubscribe = call.subscribe((c) => {
  if (c.state === 'ended') {
    grid.replaceChildren();
    unsubscribe();
    return;
  }

  // re-render the remote participants
  grid.replaceChildren();
  for (const p of c.participants) {
    if (p.videoElement) {
      p.videoElement.autoplay = true;
      p.videoElement.playsInline = true;
      grid.appendChild(p.videoElement);
    }
    // audioElement is attached and played by the SDK
  }
});

// mute the microphone
await call.mute(true);

// turn the camera off
await call.setCamera(false);

// hang up
await call.leave();

The SDK creates and attaches the <video> and <audio> elements for each participant — you place participant.videoElement into your layout. Audio elements are played automatically, so you do not need to append them to hear sound. Because a call starts from a user gesture, that autoplay is allowed; a failed play attempt is swallowed rather than thrown.

On the wire

Three REST calls, all against the messaging module, all addressed by the group id:

POST /v1/messaging/groups/{gid}/calls                  { media }
  → 201 { call_id, token, edge_url, max_members }

POST /v1/messaging/groups/{gid}/calls/{cid}/accept     {}
  → { token, edge_url }

POST /v1/messaging/groups/{gid}/calls/{cid}/decline    { reason }

The SDK then connects to edge_url with token and publishes the tracks you asked for. max_members is returned by the start call and is not surfaced on Call — the room-full condition reaches you as the 409 above, not as a number you can check first.

Media travels over livekit-client, which is the one runtime dependency @palbase/web has. It is deliberately not bundled — it spawns its own web workers — and it is the reason @palbase/web/next/proxy is a separate entry point that must never import the generated client.

Not on the web SDK

These exist on iOS and are absent here:

  • Host controls. The server has mute and kick routes for a call; pb.calls exposes neither.
  • Transfer. No transfer(), no canTransfer.
  • Incoming-call state. No incomingCall or activeCall observable — see the warning above.
  • Starting a call from a chat. There is no chat.startCall(); pass chat.id to pb.calls.start.
  • A React hook. Nothing in @palbase/web/react covers calls.

Note: Calls are the least-verified surface in this SDK: the package's test suite covers the bundle graph and the published type surface for this module, not call behaviour, and there is no live smoke test for it. Treat the wire shapes above as documented behaviour and verify against your own environment before you depend on an edge case.

  • Messaging — calls are scoped to messaging groups; groupId is a Chat's id
  • Realtime — the channel to deliver a callId on, since the SDK does not
  • Error Handling — the seven BackendError kinds and how to discriminate them
  • Next.js — why the proxy entry point exists apart from the client
  • Voice & Video (iOS) — the iOS client, which has ring signalling and host controls