Overview
MechZie uses Socket.io (not raw WebSocket) for all real-time communication. You must use the socket.io-client package — native WebSocket connections will fail.
Do not use raw WebSocket. Socket.io has its own handshake protocol with rooms, namespaces, and automatic reconnection. Install socket_io_client for Flutter or socket.io-client for web.
Connecting
Flutter (Dart)
import 'package:socket_io_client/socket_io_client.dart' as io;
final socket = io.io(
'https://mechzie-api-production.run.app',
io.OptionBuilder()
.setTransports(['websocket'])
.disableAutoConnect()
.setAuth({'token': accessToken}) // JWT access token
.build(),
);
socket.onConnect((_) => print('Connected: ${socket.id}'));
socket.onDisconnect((_) => print('Disconnected'));
socket.onConnectError((err) => print('Connection error: $err'));
socket.connect();
JavaScript / TypeScript
import { io } from 'socket.io-client';
const socket = io('https://mechzie-api-production.run.app', {
transports: ['websocket'],
auth: {
token: accessToken, // JWT access token
},
});
socket.on('connect', () => console.log('Connected:', socket.id));
socket.on('disconnect', () => console.log('Disconnected'));
socket.on('connect_error', (err) => console.error('Auth failed:', err.message));
Authentication
The server verifies the JWT from auth.token on connection. If the token is invalid or expired, the connection is rejected with a connect_error event.
On token refresh, disconnect and reconnect with the new token:
socket.disconnect();
socket.io.options['auth'] = {'token': newAccessToken};
socket.connect();
Rooms
When connected, the server automatically joins you to rooms based on your identity:
| Room | Auto-joined? | Who | Purpose |
|---|
user:{userId} | ✅ Always | All users | User-scoped events (payment failures, job status) |
mechanic:{mechanicProfileId} | ✅ If mechanic | Verified mechanics | Dispatch offers (job:offer, job:taken) |
job:{jobId} | ❌ Manual | Customer + Mechanic | Live location tracking for a specific job |
Joining a Job Room
To receive live location updates for an active job, join its room:
// Join
socket.emit('job:join', {'jobId': 'uuid-of-the-job'});
// Leave when done
socket.emit('job:leave', {'jobId': 'uuid-of-the-job'});
Events Reference
Client → Server (Emit)
location:update
Who: Mechanic only (when en_route, arrived, or in_progress)
Send GPS position updates. Emit every ~5 seconds while actively on a job.
socket.emit('location:update', {
'lat': 12.9716,
'lng': 77.5946,
'heading': 180.5, // degrees, 0-360
'speed': 25.3, // km/h
});
Rate limit: 60 updates/minute. Exceeding this silently drops updates. The server debounces database writes to every 30 seconds but updates Redis/Valkey immediately for real-time delivery.
job:join
Who: Customer or assigned mechanic
Join a job’s tracking room to receive location:updated and job:status events for that job.
socket.emit('job:join', {'jobId': 'a1b2c3d4-...'});
Rate limit: 10/minute
job:leave
Who: Customer or assigned mechanic
Leave a job’s tracking room. Do this when navigating away from the tracking screen.
socket.emit('job:leave', {'jobId': 'a1b2c3d4-...'});
Rate limit: 10/minute
Server → Client (Listen)
job:offer
Room: mechanic:{mechanicProfileId}
When: A new job is dispatched to nearby mechanics.
socket.on('job:offer', (data) {
// data = {
// "jobId": "uuid",
// "category": "Flat Tire",
// "pickupAddress": "MG Road, Bangalore",
// "basePriceDisplay": "₹350",
// "distanceKm": 2.4
// }
showJobOfferDialog(data);
});
The mechanic has a limited window to accept (30–45s depending on dispatch tier). After timeout, the offer moves to the next group of mechanics.
job:taken
Room: mechanic:{mechanicProfileId}
When: A job the mechanic was offered has been accepted by another mechanic.
socket.on('job:taken', (data) {
// data = { "jobId": "uuid" }
dismissJobOfferDialog(data['jobId']);
});
job:status
Room: job:{jobId} or user:{userId}
When: Any job status transition occurs.
socket.on('job:status', (data) {
// data = {
// "jobId": "uuid",
// "status": "en_route",
// "mechanic": { "id": "uuid", "name": "Ravi", "phone": "+91..." }, // on accepted
// "finalPrice": 45000 // in paisa, on completed
// }
updateJobStatus(data);
});
job:line-item-added
Room: job:{jobId}
When: Mechanic adds a part or labor charge during the job.
socket.on('job:line-item-added', (data) {
// data = {
// "jobId": "uuid",
// "lineItem": {
// "id": "uuid",
// "description": "Spark plug replacement",
// "quantity": 2,
// "unit_price": 15000, // paisa
// "type": "part"
// }
// }
addLineItemToUI(data['lineItem']);
});
location:updated
Room: job:{jobId}
When: Mechanic’s position is updated (every ~5s during active job).
socket.on('location:updated', (data) {
// data = {
// "lat": 12.9716,
// "lng": 77.5946,
// "heading": 180.5,
// "speed": 25.3,
// "etaSeconds": 420
// }
updateMapMarker(data['lat'], data['lng'], data['heading']);
updateEtaDisplay(data['etaSeconds']);
});
ETA is calculated as distance / 30 km/h (assumed urban India speed) and cached with a 30s TTL. Use this for the “X min away” display.
payment:failed
Room: user:{userId}
When: A Razorpay payment attempt fails.
socket.on('payment:failed', (data) {
// data = {
// "jobId": "uuid",
// "razorpayOrderId": "order_xyz"
// }
showPaymentFailedDialog(data);
});
Call signaling events
Call signaling uses 4 additional socket events: call:accepted, call:declined, call:missed, and call:cancelled (FCM only). See the Voice Calls guide for the full call flow, FCM payloads, and nonce exchange.
Complete Flutter Integration Example
class SocketService {
late io.Socket socket;
void connect(String accessToken) {
socket = io.io(
'https://mechzie-api-production.run.app',
io.OptionBuilder()
.setTransports(['websocket'])
.disableAutoConnect()
.setAuth({'token': accessToken})
.build(),
);
socket.onConnect((_) => _onConnected());
socket.onDisconnect((_) => _onDisconnected());
socket.onConnectError((err) => _handleAuthError(err));
// Listen to all server events
socket.on('job:offer', _handleJobOffer);
socket.on('job:taken', _handleJobTaken);
socket.on('job:status', _handleJobStatus);
socket.on('job:line-item-added', _handleLineItem);
socket.on('location:updated', _handleLocationUpdate);
socket.on('payment:failed', _handlePaymentFailed);
// Call signaling (see voice-calls guide)
socket.on('call:accepted', _handleCallAccepted);
socket.on('call:declined', _handleCallDeclined);
socket.on('call:missed', _handleCallMissed);
socket.connect();
}
void joinJob(String jobId) => socket.emit('job:join', {'jobId': jobId});
void leaveJob(String jobId) => socket.emit('job:leave', {'jobId': jobId});
void sendLocation(double lat, double lng, double heading, double speed) {
socket.emit('location:update', {
'lat': lat, 'lng': lng, 'heading': heading, 'speed': speed,
});
}
void reconnectWithNewToken(String newToken) {
socket.disconnect();
socket.io.options['auth'] = {'token': newToken};
socket.connect();
}
void disconnect() => socket.disconnect();
}
HTTP Fallback for Tracking
If Socket.io is unavailable, you can poll the mechanic’s location via REST:
GET /api/v1/tracking/jobs/{jobId}
Authorization: Bearer ACCESS_TOKEN
Response:
{
"mechanicLat": 12.9716,
"mechanicLng": 77.5946,
"etaSeconds": 420,
"updatedAt": "2026-05-21T10:30:00.000Z"
}
HTTP tracking is rate-limited to 1 request every 2 seconds. Prefer Socket.io for real-time updates — it pushes updates every ~5 seconds without polling overhead.