Ring Calls

The Stream Video API offers a ringing flow that simulates traditional phone calls. This feature lets developers implement call experiences where users can receive incoming calls with ringtones and choose to accept or decline.

You can also show missed call notifications if someone doesn't answer the call on time or is busy in another call.

Most apps that ring want phone-call behavior: the caller rings, someone answers or nobody does, and the call is over once it ends. Three parts of the Stream API are designed to work together for this, and we recommend using all three:

  • A new call ID for every ring. One call is one conversation. Don't ring an old call again, create a new one.
  • The single_use call lifecycle. When the caller hangs up before anyone answers, or nobody answers before the ring times out, Stream ends the call for you, and an ended call can no longer be accepted, joined, rung or re-created. Leaving an answered call doesn't end it, so end the call when the conversation is over if you want the same protection afterwards. A late tap on a stale incoming-call screen fails cleanly instead of putting someone alone in an empty call. The built-in audio_call call type uses it by default. See Call lifecycle.
  • The call.ring_settled webhook. Your backend receives one event per ring, sent once the ring has a final outcome, with the final state of every user you rang. Use it for call logs, missed-call records and analytics instead of assembling the outcome yourself from call.accepted, call.rejected and call.missed. See Ring call events.

Each ring has a ring_id, carried by both call.ring and call.ring_settled, so your backend can match the start of a ring to its outcome.

Creating ring calls

To start the ringing flow, set the ring flag to true and provide the list of members you want to call. The caller should also be included in the list of members. Use a new call ID for every ring, for example a UUID.

The example rings a voice call on the built-in audio_call type, which already uses the single_use lifecycle. The video flag decides which incoming call screen the client-side SDKs show: false for an audio call, true for a video call.

For video calls, don't use audio_call with video: true: the type has video and screen sharing disabled, so the callee would see a video call screen in a call where nobody can send video. Ring on a call type with video enabled instead, and give the call the single_use lifecycle, either on a call type of your own or for each call with settings_override. Setting the mode shows both, including a video call on the default type.

// Make sure to use a unique id when creating ring calls
const call = client.video.call("audio_call", crypto.randomUUID());
await call.getOrCreate({
  ring: true,
  video: false, // a voice call: the SDKs show the audio incoming call screen
  data: {
    created_by_id: "myself",
    members: [{ user_id: "myself" }, { user_id: "my friend" }],
  },
});

Accepting and rejecting ring calls

Accepting and rejecting calls are client-side operations, you can read more about them in SDK documentations.

Ringing individual members

In some cases, you may want to ring individual members instead of the whole call, or you want to ring a member into an existing call. You can do this by using the ring method:

// Make sure to use a unique id when creating ring calls
const call = client.video.call("default", crypto.randomUUID());
await call.getOrCreate({
  ring: false, // set to false to avoid ringing the whole call
  data: {
    created_by_id: "myself",
    members: [{ user_id: "myself" }, { user_id: "my-friend" }],
  },
});

// note: my-friend needs to be a member of the call
await call.ring({ members_ids: ["my-friend"] });

// to invite a new member and ring them
await call.updateCallMembers({
  update_members: [{ user_id: "my-other-friend" }],
});
await call.ring({ members_ids: ["my-other-friend"] });

// to ring everyone
await call.ring();

Ring call events

The ring flow relies on the following events:

  • call.ring
  • call.accepted
  • call.rejected
  • call.missed
  • call.ring_settled (backend only)

call.ring event

This is the start of the ring flow. This event is dispatched to call members when someone starts a call with the ring flag set to true. It carries a ring_id that identifies this ring.

It's possible to send push notifications to call members on this event, for more information see Notification settings section

call.ring event reference

call.accepted event

This event is dispatched to the call members when a callee accepts an incoming call.

call.accepted event reference

call.rejected event

This event is dispatched to the call members when the caller or callee rejects the call.

There are multiple reasons why someone could reject a call (for example, someone explicitly rejecting a call with a "Decline" button is different from someone not answering the call). The call.rejected event contains a reason field to indicate why a call was rejected.

You can pass any string to the reason field, but the Stream API and the SDKs recognize the following values by default:

Name Description
decline The callee explicitly rejected the call (for example, with a "Decline" button)
cancel The caller cancelled the call before it was answered. SDKs will also send this event if all callees rejected the call.
timeout 1. When a callee is online but doesn't answer the call in incoming_call_timeout_ms time.
2. When no callee accepts the call in auto_cancel_timeout_ms time, the caller's SDK sends this event (the iOS SDK sends cancel instead)
busy Signals that the callee is busy. The iOS SDK sends it when the user rejects a ring while already in another call. The JavaScript and Android SDKs can reject incoming calls while the user is busy when rejectCallWhenBusy is enabled (off by default).

call.rejected event reference

call.missed event

The call.missed event is dispatched to callees who didn't answer (accept or reject) an incoming call within a given time.

The event is dispatched in the following scenarios:

  • The callee rejected the call with a timeout or busy reason
  • When a callee didn't accept or reject the call in missed_call_timeout_ms time (probably the user was offline)
  • The caller rejected the call with any reason, unless the callee previously rejected the call (declined the call)

It's possible to send push notifications to call members on this event, for more information see Notification settings section

call.missed event reference

call.ring_settled event

The call.ring_settled event tells your backend how one ring ended. It is sent once per ring, when the ring has a final outcome: every user you rang has accepted, rejected or missed it, the caller cancelled it, or the call ended while it was still ringing.

  • Backend only. It goes to your webhook, and to SQS, SNS or Pub/Sub if you use them. It is never sent to clients and never triggers a push notification. Clients learn each answer from call.accepted, call.rejected and call.missed, as before.
  • One event per ring. The server sends at most one call.ring_settled for a ring, with the same ring_id as the call.ring that started it. Webhook delivery is retried, so the same event can reach you more than once: deduplicate on ring_id.
  • Sent with the event that closed the ring. It is created within milliseconds of that event: just before a callee's call.accepted or call.rejected, the server's call.missed or a call.ended, and just after the caller's call.rejected. Webhook deliveries can arrive in any order, so order events by created_at, not by arrival.
  • A group ring stays open after the first accept. The ring settles once every user you rang has answered, when the missed-call timeout passes and the server marks the rest as missed, or when the call ends. So call.ring_settled can arrive some time after the first call.accepted.
  • Subscribing. A webhook that receives all events gets call.ring_settled automatically. If your webhook subscribes to a list of specific events, add call.ring_settled to the list. See webhooks.

The payload:

Field Description
call_cid The call (<type>:<id>)
session_id The call session the ring belongs to
ring_id Identifies this ring, the same value as on call.ring
initiated_by ID of the user who rang
rang_at When the ring was sent
outcome The final outcome of the ring, see below
settled_at When the ring reached its outcome
settled_by ID of the user whose action closed the ring, or server for the missed-call timeout and for an end with no user behind it
settle_reason What closed the ring, see below
targets The final state of every user you rang, keyed by user ID: state, state_at and reason, see below

Each target's state is accepted, rejected, missed or cancelled. A callee whose device rejected with reason timeout or busy counts as missed, with that reason kept on the target. A callee still unanswered when the caller cancels, or when the call ends, is cancelled, and so is a callee the missed-call timeout can't mark as missed, for example one who joined the call without accepting or was removed from it while it rang. A rejected or missed target's reason is what the callee rejected with. A cancelled target's reason is the caller's reject reason, or why the ring was closed.

The outcome summarizes the targets:

Outcome Meaning
answered_all Everyone you rang accepted
answered_some At least one user accepted, the others rejected, missed or were cancelled
rejected_all Everyone you rang declined
missed_all Everyone you rang missed it, including callees whose device rejected with timeout or busy
cancelled Everyone you rang was still unanswered when the caller cancelled or the call ended
mixed Any other combination, for example one decline and one miss

The settle_reason says what closed the ring. Read it together with settled_by:

settle_reason settled_by What happened
answered a callee The callee's accept was the last answer the ring was waiting for
rejected a callee The callee's decline was the last answer the ring was waiting for
timeout a callee The callee's device rejected with timeout or busy, and that was the last answer
timeout server The missed-call timeout passed while some callees were still unanswered
cancel the caller The caller rejected the ring (hung up), with any reason
ended a user A user ended or deleted the call from a client while it was ringing
auto_end server The call ended while it was ringing with no user behind it: the server ended it (for example, a session timeout), or your backend ended or deleted it

The SDKs reject on the caller's behalf when nobody accepts within auto_cancel_timeout_ms, so an unanswered ring often settles as cancelled with settle_reason: "cancel". If the callees' devices time out first, it settles as missed_all with settle_reason: "timeout", settled by the last callee. It settles as missed_all with settled_by: "server" when nobody rejects before the missed-call timeout, for example because the apps were closed while ringing.

If an event is lost despite the retries, the call session's accepted_by, rejected_by and missed_by maps, returned when you get the call, still record each user's answer, merged over every ring of the session. They don't match the targets one to one: rejected_by also holds timeout and busy rejects and the caller's cancel, and callees the caller cancelled on are in missed_by.

call.ring_settled event reference

Examples

SDKs implement ringing flow in a way that while a call is in the ringing phase, there is no active session. The session starts when the first callee accepts the call.

call.ring_settled is sent to your backend only, see ring call events. The call.ended steps happen only on a call with the single_use lifecycle; a reusable call stays open.

All accept

Example: Sara calls Ben and Jane. Both Ben and Jane accept the call.

  1. Sara rings Ben and Jane (with ring: true): call.created and call.ring events from Sara to all call members - ringing flow starts
  2. Ben accepts the call: call.accepted event from Ben
  3. Ben joins the call: call.session_started and call.session_participant_joined events
  4. As soon as Ben accepts the call Sara also joins: call.session_participant_joined
  5. Jane also accepts, and joins: call.accepted and call.session_participant_joined
  6. Everyone has answered, so the ring settles: call.ring_settled with outcome: "answered_all", settled_by: "jane" and settle_reason: "answered"
  7. Jane leaves the call: call.session_participant_left
  8. Ben and Sara also leave: 2x call.session_participant_left
  9. call.session_ended when inactivity_timeout_seconds elapsed since last participant left the call

Someone rejects

Example: Sara calls Ben and Jane. Ben accepts the call, Jane rejects.

  1. Sara rings Ben and Jane (with ring: true): call.created and call.ring events from Sara to all call members - ringing flow starts
  2. Ben accepts the call: call.accepted event from Ben
  3. Ben joins the call: call.session_started and call.session_participant_joined
  4. As soon as Ben accepts the call Sara also joins: call.session_participant_joined
  5. Jane declines the call: call.rejected event with reason: "decline" from Jane
  6. Everyone has answered, so the ring settles: call.ring_settled with outcome: "answered_some", settled_by: "jane" and settle_reason: "rejected"
  7. Ben and Sara leave the call: 2x call.session_participant_left
  8. call.session_ended when inactivity_timeout_seconds elapsed since last participant left the call

All reject

Example: Sara calls Ben and Jane. Both Jane and Ben reject the call.

  1. Sara rings Ben and Jane (with ring: true): call.created and call.ring events from Sara to all call members - ringing flow starts
  2. Ben declines the call: call.rejected event with reason: "decline" from Ben
  3. Jane is online, but doesn't interact with the incoming call screen: call.rejected event with reason: "timeout" from Jane after incoming_call_timeout_ms elapsed
  4. call.missed is sent to Jane
  5. Everyone has answered, so the ring settles: call.ring_settled with outcome: "mixed" (one decline, one miss), settled_by: "jane" and settle_reason: "timeout"
  6. Since both Ben and Jane rejected the call Sara will reject as well (this is sent from SDKs): call.rejected event with reason: "cancel" from Sara
  7. On a single_use call, Sara's reject ends the call: call.ended with reason: "RejectedByCaller"

Caller cancels

Example: Sara calls Ben and Jane, but Sara cancels before anyone answers.

  1. Sara rings Ben and Jane (with ring: true): call.created and call.ring events from Sara to all call members - ringing flow starts
  2. Sara changes her mind, and cancels: call.rejected event with reason: "cancel" from Sara, then call.ring_settled with outcome: "cancelled", settled_by: "sara" and settle_reason: "cancel", and call.missed sent to Ben and Jane
  3. On a single_use call, the call ends after the call.missed events: call.ended with reason: "RejectedByCaller". A late accept from Ben or Jane now fails with HTTP 400, and a late join with HTTP 403

No one answers the call

Example: Sara calls Ben and Jane, but no one answers.

  1. Sara rings Ben and Jane (with ring: true): call.created and call.ring events from Sara to all call members - ringing flow starts
  2. No one accepts or rejects the call in auto_cancel_timeout_ms time (this can happen if users are offline, and away from their devices): call.rejected event with reason: "timeout" from Sara (the iOS SDK sends reason: "cancel")
  3. The ring settles, because Sara's SDK closed it: call.ring_settled with outcome: "cancelled", settled_by: "sara" and settle_reason: "cancel". Then call.missed events are sent to both Ben and Jane
  4. On a single_use call, Sara's reject ends the call: call.ended with reason: "RejectedByCaller"

If Sara's app is closed while it rings, her SDK never sends that reject. If Ben's and Jane's devices don't reject either, for example because they are offline, the server closes the ring at its missed-call timeout: call.ring_settled with outcome: "missed_all", settled_by: "server" and settle_reason: "timeout", then call.missed events to Ben and Jane. On a single_use call, the server then ends the call: call.ended with reason: "RingNotAnswered". A late accept from Ben or Jane fails with HTTP 400, and a late join with HTTP 403. A reusable call stays open.

Call lifecycle

The lifecycle.mode call setting decides whether a call can be used again after it ends. It has two values:

  • reusable is the default for custom call types and for the built-in default, audio_room, livestream and development types, and the behavior every call had before the setting existed. After the ring, the call stays open: it can be rung again, and users with the join-ended-call permission can join it after it has ended. This suits calls that are places, such as a meeting room or a recurring meeting.
  • single_use gives a call phone-call semantics. It is the recommended mode for ringing apps that create a new call for every ring, and the default for the built-in audio_call and agent types.

What single_use changes

The caller's reject ends the call. The caller is the user who created the call. When the caller rejects the ring, with any reason, the call is ended with reason: "RejectedByCaller" on the call.ended event. This holds even if a callee has already accepted the ring or joined the call. The SDKs send this reject when the caller hangs up while it rings, and also when nobody accepts within auto_cancel_timeout_ms. call.ended comes after the call.rejected and call.missed events for that ring. If the caller had already joined the call session while it was ringing, the session ends too.

A ring that times out on the server ends the call. If the caller's app never sends that reject, for example because it was closed while ringing, the server closes the ring at its missed-call timeout and then ends the call with reason: "RingNotAnswered". The server ends it, so this call.ended has no user. Your backend doesn't need to end these calls itself; if it does, check ended_at first, because ending a call that has already ended still succeeds and sends another call.ended. Ordered by created_at, call.ended comes after the call.missed events and call.ring_settled for that ring. The call is not ended if someone accepted the ring or joined the call, if the call was rung again and that ring is still open, if a new call session has started, or if auto_cancel_timeout_ms is longer than the missed-call timeout, because the caller may then still be waiting. The missed-call timeout is missed_call_timeout_ms (30 s when unset), except that a call whose settings_override sets incoming_call_timeout_ms uses that value plus 3 seconds.

An ended call stays ended. Once a single_use call has ended, whether by the caller's reject, by the ring timing out or by end call:

  • Accepting or rejecting it fails with HTTP 400, as on any ended call.
  • Joining it fails with HTTP 403, whatever the user's permissions. The join-ended-call permission has no effect on a single_use call.
  • Get-or-create on its call ID fails with HTTP 400 instead of returning the ended call. Create a new call with a new ID instead.
  • Ringing it fails with HTTP 400: the ring endpoint refuses any ended call, in either mode, and get call with ring or notify refuses an ended single_use call.
  • Get call still returns it, so you can read its final state.

What it does not change:

  • A callee's reject never ends the call, whatever the reason.
  • Calls that never ring, such as meetings and livestreams, behave the same in both modes until they end. Once ended, a single_use call is closed for good whether it rang or not.

Setting the mode

Set the mode on the call type, so every new call of that type uses it:

await client.video.updateCallType({
  name: callTypeName,
  settings: {
    lifecycle: { mode: "single_use" },
  },
});

Or set it for one call when you create it, with settings_override. This example rings a video call on the built-in default type:

const call = client.video.call("default", crypto.randomUUID());
await call.getOrCreate({
  ring: true,
  video: true,
  data: {
    created_by_id: "myself",
    members: [{ user_id: "myself" }, { user_id: "my friend" }],
    settings_override: {
      lifecycle: { mode: "single_use" },
    },
  },
});

The lifecycle setting needs the Node.js SDK v0.8.14 or later, or the Go SDK v8.0.0 or later. The Python and Java SDKs don't have it yet, so use the REST API from those languages.

A call that doesn't set its own mode follows its call type, so changing the call type's mode also changes it for the existing calls of that type. A mode set in settings_override when the call is created stays with the call. No call can change its mode afterwards: updating a call with a lifecycle.mode different from its current one fails with call lifecycle cannot be changed after creation, and an update that repeats the current mode stores it on the call, which then stops following its call type. A settings_override sent from a client needs the update-call-settings permission, like any other settings override.

The call's resolved mode is returned in call.settings.lifecycle.mode, and is always present: a call type that never set the mode reports single_use on the built-in audio_call and agent types and reusable on every other type, including a custom type created under one of those names before it became built-in.

Ring call settings

Timeouts

You can control how much time to wait before automatic call rejections and missed call events are dispatched.

RingSettingsRequest

NameTypeDescriptionConstraints
auto_cancel_timeout_msintegerWhen none of the callees accept a ring call in this time a rejection will be sent by the caller with reason 'timeout' by the SDKsRequired, Minimum: 5000, Maximum: 180000
incoming_call_timeout_msintegerWhen a callee is online but doesn't answer a ring call in this time a rejection will be sent with reason 'timeout' by the SDKsRequired, Minimum: 5000, Maximum: 180000
missed_call_timeout_msintegerWhen a callee doesn't accept or reject a ring call in this time a missed call event will be sentMinimum: 5000, Maximum: 180000

These can be set on the call type level or on the call level:

// on call type level
await client.video.updateCallType({
  name: callTypeName,
  settings: {
    ring: {
      incoming_call_timeout_ms: 10000,
      auto_cancel_timeout_ms: 15000,
      missed_call_timeout_ms: 15000,
    },
  },
});

// or on call level
await call.update({
  settings_override: {
    ring: {
      incoming_call_timeout_ms: 10000,
      auto_cancel_timeout_ms: 15000,
      missed_call_timeout_ms: 15000,
    },
  },
});

Push notifications

You can enable/disable push notifications for the call.ring and call.missed events. Provider configuration (APNs certificates and Firebase credentials) is covered in push notifications.

await client.video.createCallType({
  name: "<call type name>",
  notification_settings: {
    enabled: true,
    call_notification: {
      apns: {
        title: "{{ user.display_name }} calls you",
        body: "{{ user.display_name }} calls you",
      },
      enabled: true,
    },
    call_ring: {
      apns: {
        title: "{{ user.display_name }} calls you",
        body: "{{ user.display_name }} calls you",
      },
      enabled: true,
    },
    call_live_started: {
      enabled: true,
      apns: {
        title: "{{ call.display_name }} started",
        body: "{{ user.display_name }} started",
      },
    },
    call_missed: {
      enabled: true,
      apns: {
        title: "missed call from {{ user.display_name }}",
        body: "missed call from {{ user.display_name }}",
      },
    },
    session_started: {
      enabled: true,
      apns: {
        title: "{{ call.display_name }} started",
        body: "{{ call.display_name }} started",
      },
    },
  },
});