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
| HTTP | Code | Meaning |
|---|
409 | CONFLICT | Call already ringing or active for this job |
403 | FORBIDDEN | Invalid or expired nonce (accept), or not the caller (cancel) |
404 | NOT_FOUND | Job not found |
403 | FORBIDDEN | Job status doesn’t allow calls |
409 | CONFLICT | Call no longer ringing when accept is sent (timed out / cancelled) |
Redis State Machine
| Key | TTL | Purpose |
|---|
call:ringing:{jobId} | 35 s | Ringing state — caller, nonce, BullMQ job ID |
call:active:{jobId} | 4 h | Active call state — set on accept, cleared by /call/end |
call:nonce:{nonce} | 5 min | Single-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.