Pre-Call Signalling for Instant Calling

EnableX Video gives your application full control over video sessions once both participants are inside a room. What it does not provide is the mechanism to get them there simultaneously — the moment where one user taps "Call", the other user's phone rings, and acceptance or rejection is communicated back before anyone joins the session. That layer is called pre-call signalling, and it is entirely your responsibility to implement.

This guide explains how pre-call signalling works in the context of an EnableX application, covers the two strategies for delivering room tokens to participants, and walks through four concrete signalling options — Firebase, a custom WebSocket server, Pusher/Ably, and MQTT — including what each looks like for mobile apps and web browsers. For room and token API details, see the Video API Reference. For SDK join methods, see Web, iOS, Android, React Native, or Flutter.

How Instant Calling Works

The Two Phases of an Instant Call

Every instant video call has two distinct phases. EnableX owns the second phase entirely. Your application owns the first.

Phase What happens Who is responsible
Pre-call signalling Caller initiates. Called party is notified. Called party accepts or rejects. Outcome is communicated back to the caller. Room and tokens are provisioned. You — using Firebase, WebSocket, Pusher, MQTT, or Web Push
In-session management Both parties join the same EnableX room with their tokens. Audio/video is established, streams are published and subscribed, recording starts, the session ends. EnableX — via the Video API and client SDKs

The hand-off point between the two phases is the moment both parties have a valid token for the same room and begin the SDK connection sequence.

The Full Call Flow

The sequence below describes what happens from the moment a user taps "Call" to the moment both parties are inside the session. Your signalling channel carries every step until the last two.

  1. Caller initiates — The caller's app sends a request to your App Server: "call user B". This is a normal HTTPS request from the app to your backend.
  2. App Server creates the room — Your server calls the EnableX Video API to create a new room. The response includes a room_id that both parties will use to join.
  3. App Server generates the caller's token — Your server immediately calls the EnableX Token API for the caller and holds it (or returns it to the caller's app now, so the caller is ready to join once the called party accepts).
  4. App Server notifies the called party — Your server sends an incoming-call event to the called party through your chosen signalling channel. The event carries at minimum the room_id and the caller's identity. Optionally, it may carry a pre-generated token for the called party (see Token Delivery Strategy below).
  5. Called party responds — The called party's device shows the incoming call UI. The user accepts or rejects. That outcome is sent back through the signalling channel to your server (and onwards to the caller's app).
  6. On acceptance — If the token was not pre-generated, the called party's app now requests a token from your server. Your server calls the EnableX Token API and returns the token. Both parties now have a token for the same room.
  7. Both parties join the room — The caller's app and the called party's app each use their token to join the room via the EnableX SDK — on Web this is EnxRtc.joinRoom(token, streamOpt, callback); iOS, Android, Flutter, React Native, and Cordova SDKs have equivalent connect methods. EnableX takes over. The session is live.
  8. On rejection — The signalling channel notifies the caller. The caller's app shows a "Call declined" message. No room token is consumed. Because the room was created with adhoc: true and no session has been used yet, the room definition still exists in the system — it can be reused for an immediate re-dial without creating a new room. If a re-dial is not expected, delete the room via the Video API to keep your room inventory clean. Note that an adhoc room is for a single session only; once a session starts (the first participant connects with a valid token), the room cannot host another independent session.
Room creation happens on your server, not in the app

Your App ID and App Key must never leave your server. All EnableX API calls — room creation, token generation — happen server-side. The client app only ever receives the resulting token and uses it to join.

Token Delivery Strategy

There are two ways to get the called party's token to their device. Both work. The right choice depends on your latency and security priorities.

Approach A — Send room_id, called party fetches token on accept

Your signalling notification carries the room_id and caller identity only. No token is embedded. When the called party accepts, their app calls your server, which then calls the EnableX Token API and returns a fresh token.

Notification payload:
{
  "type": "incoming_call",
  "room_id": "abc123",
  "caller_id": "user_alice",
  "caller_name": "Alice"
}

On accept → app calls your server → server calls EnableX Token API → returns token → app joins
AdvantageTrade-off
Token is never exposed in a notification payload. Push notifications can be inspected by the OS and stored in notification history. One extra round-trip after acceptance adds a small delay before the called party joins. Typically 200–400 ms on a good connection — usually imperceptible.
Token is generated at the moment it is needed, so it will not have expired by the time the user accepts (especially important if the called party delays answering). Requires your server to be reachable at acceptance time for the token fetch.

This is the recommended approach for production applications.

Approach B — Pre-generate the token and embed it in the notification

Your server generates a token for the called party at the same time it creates the room, and includes that token directly in the notification payload. When the user accepts, the app joins immediately without an additional server call.

Notification payload:
{
  "type": "incoming_call",
  "room_id": "abc123",
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "caller_id": "user_alice",
  "caller_name": "Alice"
}

On accept → app uses token directly → joins the room
AdvantageTrade-off
Zero additional latency after acceptance. The called party starts joining the moment they tap Accept. A JWT token is visible in the notification payload. If the OS or a device backup stores notifications, the token is exposed. For most applications this risk is acceptable, but it is worth knowing.
No server round-trip needed at acceptance time. Works even if your server is temporarily unreachable. EnableX tokens expire exactly 15 minutes after creation — there is no TTL option. If the called party's phone is offline when the notification arrives and they accept after that window, the token will already be invalid. For calls where the called party might be slow to respond, use Approach A instead.

Suitable for prototypes and internal tools. Evaluate the token exposure risk before using in a consumer-facing app.

Signalling Channels

Choosing a Signalling Channel

The signalling channel is the transport layer your application uses to deliver incoming-call notifications and carry accept/reject responses. Every option in the table below can implement the same logical flow — they differ in infrastructure burden, offline notification support, and ecosystem fit.

Option Best for Offline push? Infrastructure Complexity
Firebase (FCM + Firestore) Mobile apps. The most common choice for consumer apps. Yes — FCM delivers to devices even when the app is not running Google-managed Low
Custom WebSocket / Socket.IO Teams that want full control over the signalling protocol or have custom auth requirements. No — unless paired with FCM for offline delivery Self-hosted Medium
Pusher / Ably Teams who want managed pub/sub WebSockets without running infrastructure. Limited — can trigger mobile push via FCM integration Managed SaaS Low
MQTT Teams with an existing MQTT broker, or IoT/embedded contexts. No — unless app is online; QoS 1 ensures delivery once connected Self-hosted or managed Medium
Web Push + Service Worker Web browser apps where users may have the tab closed. Yes — browser delivers to Service Worker regardless of tab state Browser-native + your server Medium

Firebase (FCM + Firestore)

Firebase Cloud Messaging (FCM) delivers push notifications to Android and iOS devices even when your app is not in the foreground or is fully killed. Firestore (or the Realtime Database) acts as a shared state store so both parties can observe call state changes in real time. Together they cover the full pre-call signalling flow without you running any persistent connection infrastructure.

Architecture

Your App Server uses the Firebase Admin SDK to send an FCM message to the called party's device token and writes the call state to Firestore. The called party's app receives the FCM notification, renders the incoming call UI, and listens to the Firestore document for state updates. When the user accepts or rejects, the app updates the document, and the caller's app — which is also listening to the same document — reacts accordingly.

App Server — Create room and notify called party

const axios  = require('axios');
const admin  = require('firebase-admin');

// Initialise Firebase Admin once at startup
admin.initializeApp({ credential: admin.credential.applicationDefault() });
const db = admin.firestore();

async function initiateCall({ callerUserId, calledFcmToken, calledUserId, callerName }) {

  // 1. Create the EnableX room
  const roomRes = await axios.post(
    'https://api.enablex.io/video/v2/rooms',
    {
      name: `call-${Date.now()}`,
      owner_ref: callerUserId,
      settings: { mode: 'group', scheduled: false, adhoc: true }
    },
    { auth: { username: process.env.ENX_APP_ID, password: process.env.ENX_APP_KEY } }
  );
  const roomId = roomRes.data.room.room_id;

  // 2. Pre-generate caller's token (moderator role so they control the session)
  const callerToken = await generateToken(roomId, callerUserId, callerName, 'moderator');

  // 3. Write call state to Firestore so both sides can observe it
  await db.collection('calls').doc(roomId).set({
    state:       'ringing',
    caller_id:   callerUserId,
    caller_name: callerName,
    called_id:   calledUserId,
    room_id:     roomId,
    created_at:  admin.firestore.FieldValue.serverTimestamp()
  });

  // 4. Send FCM push to called party's device
  await admin.messaging().send({
    token: calledFcmToken,
    data: {
      type:        'incoming_call',
      room_id:     roomId,
      caller_id:   callerUserId,
      caller_name: callerName
    },
    android: { priority: 'high' },
    apns: {
      headers:  { 'apns-priority': '10' },
      payload:  { aps: { contentAvailable: true } }
    }
  });

  return { roomId, callerToken };  // return to caller's app
}

// Helper — generate an EnableX token; role is 'moderator' for caller, 'participant' for called party
async function generateToken(roomId, userId, userName, role = 'participant') {
  const res = await axios.post(
    `https://api.enablex.io/video/v2/rooms/${roomId}/tokens`,
    { name: userName, role, user_ref: userId },
    { auth: { username: process.env.ENX_APP_ID, password: process.env.ENX_APP_KEY } }
  );
  return res.data.token;  // API returns { result: 0, token: "JWT..." }
}
import os, time
import requests
from requests.auth import HTTPBasicAuth
import firebase_admin
from firebase_admin import credentials, messaging, firestore

firebase_admin.initialize_app(credentials.ApplicationDefault())
db = firestore.client()

ENX_AUTH = HTTPBasicAuth(os.environ['ENX_APP_ID'], os.environ['ENX_APP_KEY'])

def initiate_call(caller_user_id, called_fcm_token, called_user_id, caller_name):
    # 1. Create the EnableX room
    room_res = requests.post(
        'https://api.enablex.io/video/v2/rooms',
        json={'name': f'call-{int(time.time())}',
              'owner_ref': caller_user_id,
              'settings': {'mode': 'group', 'scheduled': False, 'adhoc': True}},
        auth=ENX_AUTH
    )
    room_id = room_res.json()['room']['room_id']

    # 2. Pre-generate caller's token (moderator role so they control the session)
    caller_token = generate_token(room_id, caller_user_id, caller_name, role='moderator')

    # 3. Write call state to Firestore
    db.collection('calls').document(room_id).set({
        'state':       'ringing',
        'caller_id':   caller_user_id,
        'caller_name': caller_name,
        'called_id':   called_user_id,
        'room_id':     room_id,
        'created_at':  firestore.SERVER_TIMESTAMP
    })

    # 4. Send FCM push to called party's device
    messaging.send(messaging.Message(
        token=called_fcm_token,
        data={'type': 'incoming_call', 'room_id': room_id,
              'caller_id': caller_user_id, 'caller_name': caller_name},
        android=messaging.AndroidConfig(priority='high'),
        apns=messaging.APNSConfig(headers={'apns-priority': '10'},
                                  payload=messaging.APNSPayload(
                                      aps=messaging.Aps(content_available=True)))
    ))

    return {'room_id': room_id, 'caller_token': caller_token}

def generate_token(room_id, user_id, user_name, role='participant'):
    res = requests.post(
        f'https://api.enablex.io/video/v2/rooms/{room_id}/tokens',
        json={'name': user_name, 'role': role, 'user_ref': user_id},
        auth=ENX_AUTH
    )
    return res.json()['token']  # API returns { "result": 0, "token": "JWT..." }

App Server — Token endpoint for called party (Approach A)

When using Approach A (room_id in notification), the called party's app calls this endpoint after the user accepts.

// Express.js route — called party fetches their token after accepting
app.post('/api/call/token', async (req, res) => {
  const { room_id, user_id, user_name } = req.body;

  // Verify the call is still ringing (not cancelled or already accepted)
  const callDoc = await db.collection('calls').doc(room_id).get();
  if (!callDoc.exists || callDoc.data().state !== 'ringing') {
    return res.status(409).json({ error: 'Call no longer available' });
  }

  const token = await generateToken(room_id, user_id, user_name);

  // Update Firestore state so caller knows the call is accepted
  await db.collection('calls').doc(room_id).update({ state: 'accepted' });

  res.json({ token });
});

Client app — Handle incoming call and accept

// React Native — Firebase Messaging handler (works in foreground and background)
import messaging from '@react-native-firebase/messaging';
import firestore from '@react-native-firebase/firestore';

// Register background handler (called when app is killed or in background)
messaging().setBackgroundMessageHandler(async remoteMessage => {
  if (remoteMessage.data?.type === 'incoming_call') {
    const { room_id, caller_id, caller_name } = remoteMessage.data;
    // Show a local notification or a native incoming-call UI
    showIncomingCallScreen({ room_id, caller_id, caller_name });
  }
});

// When user taps Accept
async function onAccept(roomId, myUserId, myName) {
  // Approach A: fetch token from your server
  const res   = await fetch('/api/call/token', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({ room_id: roomId, user_id: myUserId, user_name: myName })
  });
  const { token } = await res.json();

  // Join the EnableX room using the SDK
  joinEnableXRoom(token);
}

// Caller watches Firestore for the called party's response
function watchCallState(roomId, onAccepted, onRejected) {
  return firestore()
    .collection('calls').doc(roomId)
    .onSnapshot(doc => {
      const state = doc.data()?.state;
      if (state === 'accepted')  onAccepted();
      if (state === 'rejected')  onRejected();
      if (state === 'cancelled') onRejected();
    });
}
Store FCM tokens server-side

Each user's FCM registration token must be stored in your database when they log in or when the token refreshes. On Android and iOS, the FCM token can change — your app should call messaging().onTokenRefresh() and update your server whenever this happens. Sending to a stale token results in a silent delivery failure.

Custom WebSocket Server (Socket.IO)

If you need full control over the signalling protocol, custom authentication, or integration with an existing backend, running your own WebSocket server is the most flexible option. Socket.IO adds reliable event delivery and automatic reconnection on top of WebSockets.

The limitation compared to Firebase is that WebSocket delivery requires the client to be online and connected. For mobile apps, a background or killed app will not receive events via WebSocket alone. The common solution is to pair Socket.IO with FCM for offline delivery: when the called user is online, the Socket.IO event reaches them directly; if they are offline, your server falls back to sending an FCM notification.

Signalling Server

const http    = require('http');
const express = require('express');
const { Server } = require('socket.io');
const axios   = require('axios');

const app    = express();
const server = http.createServer(app);
const io     = new Server(server, { cors: { origin: '*' } });

const onlineUsers = new Map(); // userId -> socket

io.on('connection', socket => {

  // Client registers its userId after connecting
  socket.on('register', userId => {
    onlineUsers.set(userId, socket);
    socket.userId = userId;
  });

  // Caller requests a call
  socket.on('initiate_call', async ({ calledId, callerName }) => {
    const callerId = socket.userId;

    // 1. Create EnableX room
    const roomRes = await axios.post(
      'https://api.enablex.io/video/v2/rooms',
      { name: `call-${Date.now()}`, owner_ref: callerId, settings: { mode: 'group', scheduled: false, adhoc: true } },
      { auth: { username: process.env.ENX_APP_ID, password: process.env.ENX_APP_KEY } }
    );
    const roomId = roomRes.data.room.room_id;

    // 2. Notify called party if they are online via WebSocket
    const calledSocket = onlineUsers.get(calledId);
    if (calledSocket) {
      calledSocket.emit('incoming_call', { roomId, callerId, callerName });
    } else {
      // Fallback: send FCM push if user is offline
      await sendFcmFallback(calledId, { roomId, callerId, callerName });
    }

    // 3. Confirm to caller
    socket.emit('call_initiated', { roomId });
  });

  // Called party accepts
  socket.on('accept_call', async ({ roomId }) => {
    const userId = socket.userId;
    const token  = await generateToken(roomId, userId, userId);

    // Notify caller of acceptance
    // (caller's userId was stored when the room was created — simplified here)
    socket.emit('call_token', { roomId, token });
  });

  // Called party rejects
  socket.on('reject_call', ({ roomId, callerId }) => {
    const callerSocket = onlineUsers.get(callerId);
    if (callerSocket) callerSocket.emit('call_rejected', { roomId });
  });

  socket.on('disconnect', () => {
    if (socket.userId) onlineUsers.delete(socket.userId);
  });
});

async function generateToken(roomId, userId, userName, role = 'participant') {
  const res = await axios.post(
    `https://api.enablex.io/video/v2/rooms/${roomId}/tokens`,
    { name: userName, role, user_ref: userId },
    { auth: { username: process.env.ENX_APP_ID, password: process.env.ENX_APP_KEY } }
  );
  return res.data.token;  // API returns { result: 0, token: "JWT..." }
}

server.listen(3000);

Client

import { io } from 'socket.io-client';

const socket = io('https://your-signalling-server.com');

// Register after authentication
socket.emit('register', myUserId);

// Caller initiates
function startCall(calledId) {
  socket.emit('initiate_call', { calledId, callerName: myName });
}

// Called party receives notification
socket.on('incoming_call', ({ roomId, callerId, callerName }) => {
  showIncomingCallUI({
    roomId,
    callerName,
    onAccept: () => socket.emit('accept_call', { roomId }),
    onReject: () => socket.emit('reject_call', { roomId, callerId })
  });
});

// Called party receives their token after accepting
socket.on('call_token', ({ roomId, token }) => {
  joinEnableXRoom(token);
});

// Caller learns the call was rejected
socket.on('call_rejected', ({ roomId }) => {
  showCallDeclinedUI();
});
WebSocket alone cannot reach an offline mobile app

If the called party's app is in the background or killed, the WebSocket connection is closed and the incoming_call event is never received. Implement an FCM fallback: check whether the target user has an active socket connection before emitting, and if not, send a Firebase Cloud Messaging notification instead. The FCM notification can include the room_id so the app can connect to your Socket.IO server and complete the flow once it wakes.

Pusher or Ably

Pusher and Ably are managed pub/sub WebSocket platforms. Your server publishes events to named channels; subscribed clients receive them in real time. You pay per message and connection rather than running infrastructure. Both services also offer server-triggered mobile push (via FCM/APNs) as a built-in feature, which partially closes the offline delivery gap.

The signalling logic is identical to the Socket.IO approach — the difference is that you replace the Socket.IO server with an API call to Pusher or Ably, and the client uses their respective JavaScript SDK.

App Server

const Pusher = require('pusher');
const axios  = require('axios');

const pusher = new Pusher({
  appId:   process.env.PUSHER_APP_ID,
  key:     process.env.PUSHER_KEY,
  secret:  process.env.PUSHER_SECRET,
  cluster: process.env.PUSHER_CLUSTER,
  useTLS:  true
});

async function initiateCall({ calledUserId, callerId, callerName }) {
  // 1. Create EnableX room
  const roomRes = await axios.post(
    'https://api.enablex.io/video/v2/rooms',
    { name: `call-${Date.now()}`, owner_ref: callerId, settings: { mode: 'group', scheduled: false, adhoc: true } },
    { auth: { username: process.env.ENX_APP_ID, password: process.env.ENX_APP_KEY } }
  );
  const roomId = roomRes.data.room.room_id;

  // 2. Publish incoming-call event to the called user's private channel
  await pusher.trigger(`private-user-${calledUserId}`, 'incoming-call', {
    room_id:     roomId,
    caller_id:   callerId,
    caller_name: callerName
  });

  return { roomId };
}

// Receive the called party's response (accept/reject) via a Pusher webhook
// or via a regular HTTP POST to your server from the client
app.post('/api/call/accept', async (req, res) => {
  const { room_id, user_id, user_name, caller_id } = req.body;
  const token = await generateToken(room_id, user_id, user_name);

  // Notify caller of acceptance on their channel
  await pusher.trigger(`private-user-${caller_id}`, 'call-accepted', { room_id });

  res.json({ token });
});
const Ably  = require('ably');
const axios = require('axios');

const ably = new Ably.Rest(process.env.ABLY_API_KEY);

async function initiateCall({ calledUserId, callerId, callerName }) {
  // 1. Create EnableX room
  const roomRes = await axios.post(
    'https://api.enablex.io/video/v2/rooms',
    { name: `call-${Date.now()}`, owner_ref: callerId, settings: { mode: 'group', scheduled: false, adhoc: true } },
    { auth: { username: process.env.ENX_APP_ID, password: process.env.ENX_APP_KEY } }
  );
  const roomId = roomRes.data.room.room_id;

  // 2. Publish incoming-call event on the called user's channel
  const channel = ably.channels.get(`user:${calledUserId}`);
  await channel.publish('incoming_call', {
    room_id:     roomId,
    caller_id:   callerId,
    caller_name: callerName
  });

  return { roomId };
}

Client

import Pusher from 'pusher-js';

const pusher  = new Pusher(process.env.PUSHER_KEY, { cluster: process.env.PUSHER_CLUSTER });
// Subscribe to this user's private channel (requires server-side auth endpoint)
const channel = pusher.subscribe(`private-user-${myUserId}`);

channel.bind('incoming-call', async ({ room_id, caller_id, caller_name }) => {
  showIncomingCallUI({
    callerName: caller_name,
    onAccept: async () => {
      // Post accept to your server; receive token in response
      const res = await fetch('/api/call/accept', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ room_id, user_id: myUserId, user_name: myName, caller_id })
      });
      const { token } = await res.json();
      joinEnableXRoom(token);
    },
    onReject: () => fetch('/api/call/reject', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ room_id, caller_id })
    })
  });
});

// Caller listens for acceptance on their own channel
channel.bind('call-accepted', ({ room_id }) => {
  // Caller already has their token — join now
  joinEnableXRoom(callerToken);
});
const Ably   = require('ably');
const client  = new Ably.Realtime(process.env.ABLY_CLIENT_KEY);
const channel = client.channels.get(`user:${myUserId}`);

channel.subscribe('incoming_call', async ({ data }) => {
  const { room_id, caller_id, caller_name } = data;
  showIncomingCallUI({
    callerName: caller_name,
    onAccept: async () => {
      const res = await fetch('/api/call/accept', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ room_id, user_id: myUserId, user_name: myName, caller_id })
      });
      const { token } = await res.json();
      joinEnableXRoom(token);
    }
  });
});

MQTT

MQTT is a lightweight publish/subscribe messaging protocol designed for constrained networks and devices. If you already operate an MQTT broker in your infrastructure, it can serve as the signalling channel with minimal additional setup. It also works well in environments where battery and bandwidth efficiency matter more than feature richness.

Each user subscribes to a personal topic (e.g. users/{userId}/incoming_call). Your server publishes to that topic when a call is initiated. The called party's client receives the message and shows the incoming call UI. Accept/reject responses travel back on a different topic.

MQTT requires the app to be connected

Unlike FCM, MQTT delivery requires the client to be actively connected to the broker. For mobile apps where the OS may close background connections, QoS 1 guarantees delivery once the client reconnects — but there will be a delay if the device is offline at notification time. For consumer mobile apps, pair MQTT with FCM for guaranteed delivery to sleeping devices.

App Server

const mqtt  = require('mqtt');
const axios = require('axios');

const broker = mqtt.connect('mqtts://your-broker.example.com', {
  username: process.env.MQTT_USER,
  password: process.env.MQTT_PASS,
  clientId: 'app-server'
});

async function initiateCall({ calledUserId, callerId, callerName }) {
  // 1. Create EnableX room
  const roomRes = await axios.post(
    'https://api.enablex.io/video/v2/rooms',
    { name: `call-${Date.now()}`, owner_ref: callerId, settings: { mode: 'group', scheduled: false, adhoc: true } },
    { auth: { username: process.env.ENX_APP_ID, password: process.env.ENX_APP_KEY } }
  );
  const roomId = roomRes.data.room.room_id;

  // 2. Publish incoming-call event to the called user's topic
  broker.publish(
    `users/${calledUserId}/incoming_call`,
    JSON.stringify({ room_id: roomId, caller_id: callerId, caller_name: callerName }),
    { qos: 1, retain: false }
  );

  return { roomId };
}

// Listen for accept/reject responses
broker.subscribe('calls/+/response', { qos: 1 });
broker.on('message', async (topic, payload) => {
  const { room_id, user_id, action, caller_id } = JSON.parse(payload.toString());

  if (action === 'accept') {
    const token = await generateToken(room_id, user_id, user_id);
    broker.publish(
      `users/${user_id}/call_token`,
      JSON.stringify({ room_id, token }),
      { qos: 1 }
    );
    // Notify caller
    broker.publish(
      `users/${caller_id}/call_accepted`,
      JSON.stringify({ room_id }),
      { qos: 1 }
    );
  } else if (action === 'reject') {
    broker.publish(
      `users/${caller_id}/call_rejected`,
      JSON.stringify({ room_id }),
      { qos: 1 }
    );
  }
});

Client

import mqtt from 'mqtt';

const client = mqtt.connect('wss://your-broker.example.com', {
  username: myUsername,
  password: myAuthToken,
  clientId: `client-${myUserId}-${Date.now()}`
});

// Subscribe to personal topics on connect
client.on('connect', () => {
  client.subscribe(`users/${myUserId}/incoming_call`, { qos: 1 });
  client.subscribe(`users/${myUserId}/call_token`,    { qos: 1 });
  client.subscribe(`users/${myUserId}/call_accepted`, { qos: 1 });
  client.subscribe(`users/${myUserId}/call_rejected`, { qos: 1 });
});

client.on('message', (topic, payload) => {
  const data = JSON.parse(payload.toString());

  if (topic === `users/${myUserId}/incoming_call`) {
    showIncomingCallUI({
      callerName: data.caller_name,
      onAccept: () => {
        client.publish(
          `calls/${data.room_id}/response`,
          JSON.stringify({ room_id: data.room_id, user_id: myUserId, action: 'accept', caller_id: data.caller_id }),
          { qos: 1 }
        );
      },
      onReject: () => {
        client.publish(
          `calls/${data.room_id}/response`,
          JSON.stringify({ room_id: data.room_id, user_id: myUserId, action: 'reject', caller_id: data.caller_id }),
          { qos: 1 }
        );
      }
    });
  }

  if (topic === `users/${myUserId}/call_token`) {
    joinEnableXRoom(data.token);
  }

  if (topic === `users/${myUserId}/call_rejected`) {
    showCallDeclinedUI();
  }
});
Platform Implementation

Calling from a Web Browser

Web browsers present two distinct scenarios depending on whether the user has your app open in a tab when the call arrives. When the tab is open, any of the WebSocket-based options (Socket.IO, Pusher, Ably) work directly. When the tab is closed or the browser is minimised, a Service Worker and the Web Push API are required to reach the user.

When the tab is open

Use a WebSocket connection — Socket.IO, Pusher, or Ably — to deliver the incoming call event. The JavaScript running in the tab receives the event and can show a custom in-page UI: a modal, a banner, or a dedicated incoming-call overlay. This works the same way as the mobile client examples shown above.

When the tab is closed — Web Push + Service Worker

Web Push allows your server to send a notification to a browser even when the tab is closed, as long as the user has granted notification permission and a Service Worker is registered. The Service Worker receives the push event, displays a system notification with Accept/Decline buttons, and handles the user's action — opening the tab to the call URL or posting a rejection to your server.

Step 1 — Register the Service Worker and subscribe to Web Push

// In your main app JavaScript
async function registerPushSubscription() {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;

  const registration = await navigator.serviceWorker.register('/sw.js');

  // Request notification permission
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;

  // Subscribe to Web Push using your VAPID public key
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly:      true,
    applicationServerKey: urlBase64ToUint8Array(process.env.VAPID_PUBLIC_KEY)
  });

  // Send the subscription to your server so it can push to this browser
  await fetch('/api/push/subscribe', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({ subscription, user_id: myUserId })
  });
}

// Utility — required to convert the VAPID key
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64  = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
}

Step 2 — Service Worker: receive push and handle user action

// sw.js — Service Worker

self.addEventListener('push', event => {
  const data = event.data.json();

  if (data.type === 'incoming_call') {
    event.waitUntil(
      self.registration.showNotification(`${data.caller_name} is calling…`, {
        body:             'Tap to answer',
        icon:             '/icons/call-192.png',
        badge:            '/icons/badge-72.png',
        data:             { room_id: data.room_id, caller_id: data.caller_id },
        actions:          [
          { action: 'accept', title: '✓  Accept' },
          { action: 'reject', title: '✕  Decline' }
        ],
        requireInteraction: true   // keeps the notification visible until the user acts
      })
    );
  }
});

self.addEventListener('notificationclick', event => {
  event.notification.close();
  const { room_id, caller_id } = event.notification.data;

  if (event.action === 'accept') {
    // Open the call tab (or focus it if already open)
    event.waitUntil(
      clients.matchAll({ type: 'window' }).then(windowClients => {
        const callUrl = `/call?room=${room_id}`;
        for (const client of windowClients) {
          if (client.url.includes('/call') && 'focus' in client) return client.focus();
        }
        return clients.openWindow(callUrl);
      })
    );
  } else {
    // Rejection — tell your server so the caller is notified
    event.waitUntil(
      fetch('/api/call/reject', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ room_id, caller_id })
      })
    );
  }
});

Step 3 — App Server: send the Web Push notification

const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendWebPush(browserSubscription, payload) {
  await webpush.sendNotification(
    browserSubscription,
    JSON.stringify(payload)
  );
}

// Called after creating the EnableX room
await sendWebPush(storedSubscription, {
  type:        'incoming_call',
  room_id:     roomId,
  caller_id:   callerId,
  caller_name: callerName
});
Combine Web Push with a WebSocket for the best experience

Web Push handles the case where the tab is closed. Once the user opens the call tab (via the notification), establish a WebSocket connection so subsequent signalling events (caller cancelled, token delivery, call state) travel over the faster, lower-latency socket rather than through push.

Web Push browser support

Web Push is supported by all major desktop browsers (Chrome, Firefox, Edge, Safari 16.4+). On mobile, Chrome and Firefox for Android support it. Safari on iOS supports Web Push from iOS 16.4 in Home Screen web apps only. For maximum iOS mobile reach, pair a web app with native mobile apps that use FCM.

Mobile Background Notification Handling

On mobile devices, the OS aggressively manages background processes to preserve battery life. Your signalling channel must be able to wake the app or display a notification even when the app is not in the foreground.

EnableX Calling UI Framework

Instead of building the incoming-call screen from scratch, you can use the EnableX Calling UI Framework — a pre-built SDK for Android and iOS that renders a native calling screen (Accept / Decline / Timeout) when a push notification arrives. The framework slots directly between your push handler and the EnableX Video SDK: on answer, you start the video session; on reject, you clean up. It pairs naturally with the Video UI Kit to give you a complete end-to-end calling experience.

Android

FCM delivers two categories of messages to Android:

  • Notification messages — The FCM SDK renders a system notification automatically when the app is in the background. The user taps the notification to open the app. On tap, getInitialNotification() provides the data payload.
  • Data messages (recommended for calls) — No automatic notification is shown. Your app's background handler receives the payload and can display a custom incoming-call UI using a full-screen intent or heads-up notification. This is required to show the ringing screen over the lock screen. The EnableX Android Calling UI SDK provides a ready-made implementation of this screen.

Set android.priority: 'high' in your FCM message to ensure delivery when the device is in Doze mode. Without high priority, FCM messages may be deferred.

// FCM message — Android data-only message for incoming call
await admin.messaging().send({
  token: calledFcmToken,
  data: {
    type:        'incoming_call',
    room_id:     roomId,
    caller_name: callerName,
    caller_id:   callerId
  },
  android: {
    priority:     'high',
    ttl:          30000   // discard after 30 seconds if not delivered (call timeout)
  }
});

iOS

FCM on iOS wraps Apple Push Notification service (APNs). When your app is in the background or killed, APNs delivers the notification and the OS shows it. When the app is in the foreground, the FCM message handler in your app fires directly.

For a data-only (silent) FCM message to reach a background iOS app, set content-available: 1 in the APNs payload. This wakes the app for up to 30 seconds to process the notification. Without this flag, a silent push will not be delivered to an iOS app in the background.

// FCM message — iOS configuration for incoming call
await admin.messaging().send({
  token: calledFcmToken,
  data: {
    type:        'incoming_call',
    room_id:     roomId,
    caller_name: callerName,
    caller_id:   callerId
  },
  apns: {
    headers: { 'apns-priority': '10' },
    payload: {
      aps: {
        contentAvailable: true,  // wakes the app silently
        alert: {
          title: `${callerName} is calling`,
          body:  'Tap to answer'
        },
        sound: 'default'
      }
    }
  }
});
Native call screen on iOS — PushKit + CallKit or EnableX Calling UI Framework

The FCM configuration above delivers a standard notification that opens your app when tapped. For a native iOS calling experience — where the full-screen system call UI appears over the lock screen, the same way as a phone call — you have two paths:

  • Apple PushKit + CallKit — VoIP pushes are delivered with the highest priority and can wake the app even in a killed state, triggering the system call UI. This is a separate, more involved integration not covered in this guide.
  • EnableX iOS Calling UI SDK — A ready-made alternative that uses EnxCallKit to render a native calling screen on top of your FCM or PushKit push, handling Answer, Reject, Timeout, Hold, and End callbacks. See the Calling UI Framework overview for the full flow.
Guidance & Security

Decision Guide

Use this guide to narrow down the right combination for your situation. Most production applications pair a real-time channel (for when the app is open) with a push notification service (for when it is not).

Your situation Recommended approach
Consumer mobile app (iOS + Android), no existing infrastructure Firebase FCM + Firestore. Handles offline delivery natively. Lowest operational overhead for mobile-first teams.
Mobile app where you already run a backend and want custom auth over the signalling channel Socket.IO on your server + FCM as offline fallback. Full control; use FCM only when the socket is not connected.
Web app only; users always have the tab open during working hours Pusher or Ably. No infrastructure, easy integration, real-time delivery. Add Web Push for edge cases when the tab is closed.
Web app; users may have the tab closed when a call arrives Web Push + Service Worker for offline delivery, combined with WebSocket (Pusher/Ably/Socket.IO) for when the tab is open.
Existing MQTT broker already in production; IoT or embedded context MQTT. Reuse existing infra. Pair with FCM if mobile users may be offline.
Cross-platform: web + mobile FCM for mobile push + WebSocket (Pusher/Ably/Socket.IO) for web. Your server sends to both channels; each client uses whichever is appropriate for its platform.

Security Considerations

Pre-call signalling introduces several attack surfaces that are not present in a scheduled-room model. Review each point before going to production.

Authenticate the signalling channel

Any user who can send an arbitrary message to another user's signalling channel can trigger a fake incoming-call notification. Guard against this at the channel level:

  • Firebase — Use Firestore Security Rules to ensure only authenticated users can write to call documents, and only the intended recipient can read their incoming-call document. Never expose your Firebase service account credentials in client code.
  • Socket.IO — Require a valid JWT from your auth system on the register event before associating a socket with a user ID. Reject any initiate_call that specifies a caller ID which does not match the authenticated socket.
  • Pusher / Ably — Use private or presence channels, which require server-side authentication. Do not use public channels for signalling.
  • MQTT — Use per-user credentials and ACLs on your broker so that each client can only publish to its own response topics and subscribe to its own incoming topics.

Validate room membership before issuing a token

Before your token endpoint returns a token for a given room_id, verify that the requesting user is actually a party to that call. A user who learns a room_id from any source should not be able to join a call they were not invited to.

app.post('/api/call/token', async (req, res) => {
  const { room_id, user_id } = req.body;

  // Fetch the call record from your database
  const call = await db.collection('calls').doc(room_id).get();
  if (!call.exists) return res.status(404).json({ error: 'Call not found' });

  const { caller_id, called_id, state } = call.data();

  // Only the two parties involved may receive a token
  if (user_id !== caller_id && user_id !== called_id) {
    return res.status(403).json({ error: 'Not a participant in this call' });
  }

  // Only issue tokens for calls that are still active
  if (state !== 'ringing' && state !== 'accepted') {
    return res.status(409).json({ error: 'Call is no longer active' });
  }

  const token = await generateToken(room_id, user_id, user_id);
  res.json({ token });
});

Set a call timeout

If the called party does not respond within a reasonable window (typically 30–60 seconds), your server should cancel the call: update the call state to cancelled, notify the caller, and optionally delete the EnableX room. This prevents stale rooms from accumulating and stops the caller from waiting indefinitely.

Token lifetime

If you use Approach B (pre-generated token in the notification), generate tokens with a TTL that matches your expected answer time plus a small buffer. A token that expires while the called party is deciding whether to answer will cause a join failure. A token with an excessively long TTL increases the window in which a leaked token could be used by an unintended party.

Use HTTPS and WSS everywhere

All communication between your clients and your App Server — whether REST calls to get tokens, WebSocket connections to the signalling server, or MQTT over TLS — must use encrypted transports. Plain HTTP or unencrypted WebSocket connections expose token values and call metadata to network observers.