Skip to main content

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:
RoomAuto-joined?WhoPurpose
user:{userId}✅ AlwaysAll usersUser-scoped events (payment failures, job status)
mechanic:{mechanicProfileId}✅ If mechanicVerified mechanicsDispatch offers (job:offer, job:taken)
job:{jobId}❌ ManualCustomer + MechanicLive 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.