Skip to content

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:

OptionMeaning
toThe identity to ring, in an array. One today.
media'audio' (default) or 'video'.
metadataYour JSON for the call's room, at most 4 096 bytes. Both sides see it as room.metadata.
ringTimeoutSecHow 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 it

client.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:

ReasonMeaning
hangupThis side hung up.
remote_hangupThe other side hung up.
no_answerNobody answered before the ring timeout, or nobody could be reached.
rejectedThe callee declined.
cancelledThe caller gave up before anyone answered.
answered_elsewhereAnother tab or device of this identity answered. Raised by the SDK, never sent by the server.
busyThe callee was busy. Named, and not sent today.
reconnect_timeoutOne side lost its connection and did not come back in time. Named, and not sent today: the other side hears remote_hangup.
transport_failureThe connection could not be kept up.
evictedYour backend removed a participant or ended the room.
quota_exhaustedA quota or an identity's credit ran out during the call.
node_failureA server failed and the call could not be carried over.
max_durationThe 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();

Talkline documentation