Appearance
A one-to-one call
Two people, audio or video. The caller's client places the call; every connected client of the callee's identity rings; one answers. Media goes directly between the two browsers when the network allows, and through a relay when it does not.
Both sides need a connected TalklineClient (The web SDK) whose token carries roomJoin, and the caller's needs roomCreate as well: placing a call creates its room.
Only a connected client rings
An invite rings the clients of that identity that are connected right now — an open tab with a connected TalklineClient. A callee with no app open is not reached, and the call ends unanswered. Your backend hears of it in call.ringing with needsPush: true and can notify the person its own way.
Placing a call
ts
const call = await client.call({ to: ['agent:7'], media: 'video' });call() opens the microphone (and the camera, for video), creates the call and its room, and resolves while the callee's clients ring — not when someone answers. The caller is already in the room: roomJoined has fired, and your UI can show "Calling…" from call.status, which is ringing.
Options:
| Option | Meaning |
|---|---|
to | The identity to ring, in an array. One today. |
media | 'audio' (default) or 'video'. |
metadata | Your JSON for the call's room, at most 4 096 bytes. Both sides see it as room.metadata. |
ringTimeoutSec | How long to ring before giving up, 5–120 seconds. Default 30. |
To give up before anyone answers:
ts
await call.cancel();Answering
ts
client.on('incomingCall', (call) => {
// call.from — who is calling; call.media — 'audio' or 'video'
// call.expiresAt — when the ring runs out, as a timestamp in milliseconds
showRinger(call);
});
// The user pressed Answer:
const room = await call.accept();
// …or Decline:
await call.reject();accept() opens the microphone (and camera, for a video call), takes the seat and resolves with the room. Answer a video call with audio only by passing call.accept({ media: 'audio' }). reject(reason) takes an optional string of your own, which Talkline stores and never reads.
A call can ring while the client is in a room — one joined by link, say. Answering there is refused invalid_state: whether answering leaves the room is yours to decide. To leave and answer:
ts
await client.room?.leave();
const room = await call.accept();A call that rings during another call is refused by the SDK, which raises invalid_state as an error event; the call in progress goes on.
Answering can fail, and a button that does nothing is worse than one that says why: another tab or device of the same identity may have answered first (already_answered), or the ring may have run out while the tab was asleep (invite_expired). Catch the promise.
When one of a person's tabs answers or declines, the others stop ringing on their own: their call ends with the reason answered_elsewhere or rejected.
During the call
ts
client.on('trackSubscribed', (track) => {
const el = track.attach();
document.querySelector(track.kind === 'video' ? '#remote-video' : '#remote-audio')!.replaceWith(el);
});
await client.localParticipant.setMicrophoneEnabled(false); // mute
await client.localParticipant.setCameraEnabled(false); // camera off
await client.localParticipant.setScreenShareEnabled(true); // share a screen, when canPublishSources allows itclient.activeCall is the call, and callUpdated fires on every change to it. client.room is its room: room.participants is the other person.
Hanging up
ts
await call.hangup();A call that is ringing on this side is declined with reject(); hangup() refuses it invalid_state. call.room is the call's room while the client is in it, and null while it rings and once it is over.
Either side hanging up ends a one-to-one call. Both sides get callEnded once, with a reason:
| Reason | Meaning |
|---|---|
hangup | This side hung up. |
remote_hangup | The other side hung up. |
no_answer | Nobody answered before the ring timeout, or nobody could be reached. |
rejected | The callee declined. |
cancelled | The caller gave up before anyone answered. |
answered_elsewhere | Another tab or device of this identity answered. Raised by the SDK, never sent by the server. |
busy | The callee was busy. Named, and not sent today. |
reconnect_timeout | One side lost its connection and did not come back in time. Named, and not sent today: the other side hears remote_hangup. |
transport_failure | The connection could not be kept up. |
evicted | Your backend removed a participant or ended the room. |
quota_exhausted | A quota or an identity's credit ran out during the call. |
node_failure | A server failed and the call could not be carried over. |
max_duration | The call reached the longest a call may last. |
The status a finished call ends in — completed, no_answer, rejected, cancelled or failed — is call.status.
hangup and remote_hangup are relative to the client that reads them. Your backend's view is not: GET /v1/calls and the call.* webhooks keep the call's own record, where hangup means the person who placed the call hung up and remote_hangup the other side did, whichever side asks.
On your backend
Each call produces webhooks: call.ringing when it rings, call.answered when it is answered, and exactly one of call.ended, call.missed, call.rejected or call.cancelled when it is over. call.ended carries the duration and billable seconds. Webhooks.
GET /v1/calls lists calls, GET /v1/calls/{sid} reads one with its invites, GET /v1/calls/{sid}/events is its timeline, and POST /v1/calls/{sid}/end ends one from your backend. Calls reference.
Placing a call from your backend
POST /v1/calls places a call with nobody connected to place it — a callback your system schedules, say:
ts
const call = await talkline.calls.create({ from: 'app:user:123', to: ['agent:7'], media: 'audio' });Talkline creates the room and rings agent:7 straight away. The from identity takes its seat by joining the call's room, call.roomSid, with a token scoped to it:
ts
// On your backend, for app:user:123's client:
const token = await talkline.mintToken({
identity: 'app:user:123',
grants: { roomJoin: true, room: call.roomSid, canPublish: true, canSubscribe: true },
});
// In that client:
await client.join({ room: call.roomSid });
await client.localParticipant.enableMicrophone();