Skip to main content

Overview

MechZie audio calls use 100ms for the actual audio room and a custom FCM + Redis signaling layer for ringing. 100ms has no concept of “ringing” — it only knows join/leave room. The signaling layer bridges that gap.
Who can call whom? Either party (customer or mechanic) can initiate a call on a job once it is in accepted, en_route, arrived, in_progress, completed, or disputed status.

Architecture

Either party taps Call


POST /call/initiate ──► API creates 100ms room (if needed)
                    ──► Generates single-use nonce (Redis, 5 min TTL)
                    ──► Schedules BullMQ timeout job (30 s)
                    ──► Sends data-only FCM to receiver's device
                    ──► Returns HMS token + room ID to caller


              Receiver's phone wakes up
              (Flutter background handler fires)
              Shows native incoming call UI

              ┌─────────┴─────────┐
              │                   │
         Tap Accept           Tap Decline
              │                   │
              ▼                   ▼
   POST /call/accept      POST /call/decline
   (nonce exchange)        ──► socket call:declined
   ──► HMS token                 to caller
   ──► joins 100ms room

              │  (call in progress)

   Either party hangs up


   POST /call/end ──► clears Redis active state
                  ──► emits call:ended to both parties
FCM payloads are data-only (no notification key). This ensures Flutter’s background message handler fires even when the app is killed. Adding a notification key will break call wake-up on Android when the app is backgrounded.

Call Flow — Step by Step

1. Caller initiates

POST /api/v1/jobs/{jobId}/call/initiate
Authorization: Bearer ACCESS_TOKEN
Response 201:
{
  "data": {
    "hms_room_id": "64b1a2c3d4e5f6a7b8c9d0e1",
    "auth_token": "eyJhbGci...",
    "role": "mechanic"
  }
}
The caller immediately uses hms_room_id + auth_token to join the 100ms room and wait. The other party’s phone rings via FCM.

2. Receiver’s FCM payload

The receiver’s Flutter background handler receives:
{
  "type": "incoming_call",
  "job_id": "uuid-of-job",
  "caller_role": "mechanic",
  "caller_name": "Ravi Kumar",
  "nonce": "a1b2c3d4e5f6..."
}
Store the nonce in memory only — never log or persist it. It is single-use and expires in 5 minutes.

3. Receiver accepts

POST /api/v1/jobs/{jobId}/call/accept
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json

{ "nonce": "a1b2c3d4e5f6..." }
Response 200:
{
  "data": {
    "hms_room_id": "64b1a2c3d4e5f6a7b8c9d0e1",
    "auth_token": "eyJhbGci...",
    "role": "customer"
  }
}
The nonce is consumed (single-use). A fresh HMS token is generated for the receiver. The caller receives a call:accepted Socket.io event. Both parties are now in the 100ms room.

4. Receiver declines

POST /api/v1/jobs/{jobId}/call/decline
Authorization: Bearer ACCESS_TOKEN
Response 200:
{ "data": { "status": "declined" } }
Caller receives call:declined on user:{callerId} Socket.io room.

5. Caller cancels (before answer)

POST /api/v1/jobs/{jobId}/call/cancel
Authorization: Bearer ACCESS_TOKEN
Response 200:
{ "data": { "status": "cancelled" } }
A cancellation FCM is sent to dismiss the receiver’s native call UI. Caller receives no socket event (they initiated the cancel).

6. Either party ends the call (hangup)

Call this from leaveCall() in both Flutter apps whenever a party hangs up:
POST /api/v1/jobs/{jobId}/call/end
Authorization: Bearer ACCESS_TOKEN
Response 200:
{ "data": { "status": "ended" } }
The active call state is cleared from Redis immediately. Both parties receive a call:ended Socket.io event so each app can return to its idle UI. Idempotent — safe to call multiple times (no-ops if call already ended).
Always call /call/end from your leaveCall() handler regardless of who initiates the hangup. If both parties call it simultaneously, only the first clears Redis; the second is a silent no-op.

7. Missed call (timeout)

If the receiver doesn’t answer within 30 seconds, a BullMQ worker fires:
  • Deletes the ringing state from Redis
  • Emits call:missed to the caller on user:{callerId}
  • Writes a call_missed audit entry to job_messages

Socket.io Events (Call Signaling)

Listen on your connected Socket.io instance. All events are delivered to the user:{userId} room (auto-joined on connect).

call:accepted

socket.on('call:accepted', (data) {
  // data = { "job_id": "uuid" }
  // Receiver joined — call is live, both parties in 100ms room
  updateCallUI(CallState.connected);
});

call:declined

socket.on('call:declined', (data) {
  // data = { "job_id": "uuid" }
  endCallScreen();
  showSnackbar('Call declined');
});

call:missed

socket.on('call:missed', (data) {
  // data = { "job_id": "uuid" }
  // Fired 30s after initiate if no answer
  endCallScreen();
  showSnackbar('No answer');
});

call:ended

Fired to both parties when either one calls /call/end. Use this to return your UI to idle when the remote party hangs up first.
socket.on('call:ended', (data) {
  // data = { "job_id": "uuid" }
  _hms.leaveRoom();   // leave 100ms room if still in it
  updateCallUI(CallState.idle);
});

call:cancelled (FCM only)

Delivered as a data-only FCM message (not a Socket.io event) so it wakes the app even when killed:
{
  "type": "call_cancelled",
  "job_id": "uuid-of-job"
}
// In your FirebaseMessaging.onBackgroundMessage handler:
if (message.data['type'] == 'call_cancelled') {
  dismissIncomingCallScreen(message.data['job_id']);
}

Flutter Integration Example

class CallService {
  final SocketService _socket;
  final HmsService _hms;

  CallService(this._socket, this._hms);

  /// Caller: initiate a call
  Future<void> initiateCall(String jobId) async {
    final res = await api.post('/jobs/$jobId/call/initiate');
    final data = res.data['data'];

    // Join 100ms room immediately and wait for other party
    await _hms.joinRoom(
      roomId: data['hms_room_id'],
      authToken: data['auth_token'],
    );

    // Listen for signaling results
    _socket.socket.once('call:accepted', (_) {
      // Other party joined — call is live
      updateUI(CallState.connected);
    });
    _socket.socket.once('call:missed', (_) async {
      await leaveCall(jobId);
    });
    _socket.socket.once('call:declined', (_) async {
      await leaveCall(jobId);
    });
    _socket.socket.once('call:ended', (_) async {
      // Remote party hung up — clean up local state
      await _hms.leaveRoom();
      updateUI(CallState.idle);
    });
  }

  /// Receiver: accept via nonce (called from FCM handler)
  Future<void> acceptCall(String jobId, String nonce) async {
    final res = await api.post(
      '/jobs/$jobId/call/accept',
      data: {'nonce': nonce},
    );
    final data = res.data['data'];

    await _hms.joinRoom(
      roomId: data['hms_room_id'],
      authToken: data['auth_token'],
    );

    // Listen for remote hangup once connected
    _socket.socket.once('call:ended', (_) async {
      await _hms.leaveRoom();
      updateUI(CallState.idle);
    });

    updateUI(CallState.connected);
  }

  /// Receiver: decline
  Future<void> declineCall(String jobId) async {
    await api.post('/jobs/$jobId/call/decline');
    updateUI(CallState.idle);
  }

  /// Caller: cancel before answered
  Future<void> cancelCall(String jobId) async {
    _socket.socket.off('call:accepted');
    _socket.socket.off('call:missed');
    _socket.socket.off('call:declined');
    _socket.socket.off('call:ended');
    await api.post('/jobs/$jobId/call/cancel');
    await _hms.leaveRoom();
    updateUI(CallState.idle);
  }

  /// Either party: hang up during an active call
  /// Call this whenever the local party taps the hang-up button.
  Future<void> leaveCall(String jobId) async {
    _socket.socket.off('call:ended'); // avoid self-loop
    await api.post('/jobs/$jobId/call/end');
    await _hms.leaveRoom();
    updateUI(CallState.idle);
  }
}

Error Codes

HTTPCodeMeaning
409CONFLICTCall already ringing or active for this job
403FORBIDDENInvalid or expired nonce (accept), or not the caller (cancel)
404NOT_FOUNDJob not found
403FORBIDDENJob status doesn’t allow calls
409CONFLICTCall no longer ringing when accept is sent (timed out / cancelled)

Redis State Machine

KeyTTLPurpose
call:ringing:{jobId}35 sRinging state — caller, nonce, BullMQ job ID
call:active:{jobId}4 hActive call state — set on accept, cleared by /call/end
call:nonce:{nonce}5 minSingle-use nonce → validates accept request
The 35 s TTL on call:ringing is a safety net — the BullMQ timeout fires at 30 s and cleans up Redis itself. The extra 5 s prevents a race where Redis expiry fires before the worker. The 4 h TTL on call:active is a safety net only/call/end clears it immediately on hangup. The TTL prevents the key from persisting indefinitely if both clients crash without calling the endpoint.