YipiiYipii IoT Docs

Live Tracking & WebSocket

Stream vehicle positions over a WebSocket — the architecture, the authenticated channel, the public tracking channel, and how it behaves under load.

Share

Yipii streams live vehicle positions over a socket.io connection to the AVL server. This page is the contract: how to connect, what you get, and why what you get may be smaller than you expected.

Architecture

Trackers send positions over cellular to the AVL server, which decodes the protocol, enriches the fix (address, status, trip, sensors) and pushes it straight out to connected socket.io clients. The REST API on api.yipii.io is a separate service reading the stored, grouped history.

What you needWhere it lives
REST API — assets, history, reportshttps://api.yipii.io
Live position streamhttps://avl.dazzlepanel.com (socket.io)

The live stream is still served from the dazzlepanel.com host. It is the same platform — the DNS move to a yipii.io name has not happened yet. Point integrations at avl.dazzlepanel.com and expect an announced change.

The stream is not a Pusher/Reverb channel. ws-live.yipii.io exists, but the only things on it are public tracking links, in-app user notifications and driver messaging. There is no channel that carries a fleet's positions, and subscribing to one gets you a connection that never emits.

Live positions over socket.io

Connect

One socket.io connection per account, carrying every asset you want. The token and the asset list are query parameters on the handshake.

import { io } from "socket.io-client";
 
const socket = io("https://avl.dazzlepanel.com", {
  query: {
    access_token: ACCESS_TOKEN,
    asset_ids: JSON.stringify([516, 517, 908]),
  },
});
 
socket.on("location", (data) => {
  const updates = Array.isArray(data) ? data : [data];
  for (const u of updates) {
    console.log(u.assetId, u.latitude, u.longitude, u.speed);
  }
});

Get the asset ids from GET /api/{account_key}/asset_map — see the integrator setup guide.

Handshake parameters

ParameterRequiredWhat it does
access_tokenyesYour Bearer token, without the Bearer prefix. Validated on every connect against the account's entitlements; a bad token is disconnected, not downgraded
asset_idsyesJSON array of asset ids. Ids your account cannot see are dropped silently before any data flows — an empty result means an empty stream, not an error
stylenodetailed requests the full payload. It only has an effect on a plan that includes Detailed data — see below. Omit it, and on any other plan, you get the standard payload
channelsnoJSON array or comma-separated. location, alert, vehicle_health, driver_behavior. Defaults to location only, and location is always included whatever you ask for

Events

The socket.io event name is the message type, and the payload is the object itself — there is no envelope to unwrap.

EventFires whenNeeds
locationA position is decoded, and on connectAlways on
alertA configured alert fireschannels + an entitled account
vehicle_healthA health snapshot is computedchannels + Mobility on the account
driver_behaviorA behaviour event is scoredchannels + a driver add-on

The first location message is an array — the last known position of every asset you subscribed to. Everything after it is a single object. Handle both, as the example above does.

Out-of-order frames are dropped server-side per asset, so you will not see a position go backwards in time. Do not rely on dateUpdated for ordering; wsSeq is the monotonic sequence the server orders by.

Standard and detailed data

This is the part that surprises integrators. The standard payload is the default, always — a token alone does not buy the detailed one.

Detailed data is a paid tier. Two things have to be true to receive it:

  1. The account's plan includes Detailed data.
  2. The connection asks for it, with style=detailed.

Miss either and you get the standard payload. There is no error and no warning — the connection succeeds and the payload is smaller. Sending style=detailed on a plan without the tier is harmless and changes nothing.

Detailed data, and the alert / vehicle_health / driver_behavior channels, are commercial. If you need them, that is a conversation with Yipii about the plan on the account — not a parameter you can add your way into.

What the standard payload contains

Exactly ten fields, and nothing else:

assetId · trackerImei · latitude · longitude · speed · course · dateUpdated · wsSeq · dateMoved · status

status is reduced to { "name": "Moving" | "Parked", "color": "#333333" } — derived from whether speed is above zero, not the real status engine.

{
  "assetId": 908,
  "trackerImei": "352094087883606",
  "latitude": 35.9873716,
  "longitude": 14.3283183,
  "speed": 13,
  "course": 146,
  "dateUpdated": 1745333252000,
  "dateMoved": 1745333252000,
  "wsSeq": 84213,
  "status": { "name": "Moving", "color": "#333333" }
}

The standard payload is also throttled to one frame per asset per 25 seconds, and carries no alert, vehicle_health or driver_behavior whatever you subscribe to.

What detailed adds

The complete decoded record: the real status object with its thresholds and colour, the resolved geoPoint address, sensors, beacons, the full ioData list, startStopTrip with its coordinate trail and scores, power, battery, gpsSignal, driverId, protocol, tracker, and more. No throttle. A worked example is in the integrator setup guide.

Which channels you get

Channels come with the plan, per account rather than per key:

ChannelIncluded with
locationEvery plan
alertDetailed data
vehicle_healthDetailed data, on an account with Yipii Mobility
driver_behaviorDetailed data, plus a driver add-on

Channels you ask for but are not on your plan are dropped silently at connect. If you subscribed to vehicle_health and never see one, check the plan before the code.

Why the default is the standard payload

The stream is one connection carrying a whole account's traffic at whatever rate the trackers report. The detailed record is roughly an order of magnitude larger and includes a trip's entire coordinate trail on every frame. The standard payload exists so that a consumer who only draws dots on a map costs what drawing dots on a map should cost.

Connection etiquette

The stream is built for one persistent connection per account, holding every asset you care about. It is not built for a connection per asset, and a fleet opened that way will be throttled or refused.

  • Persist the connection. Reconnect with backoff; do not reconnect per request.
  • Batch the asset list. One socket with 300 ids, not 300 sockets.
  • Cache the token. It is valid for a year; fetching a new one per connect is unnecessary load on the auth server.
  • Expect the first frame to be a batch, and size your handler for it.

Public Tracking WebSocket

For public tracking links where viewers don't have full API credentials. Uses session-based authentication with the same WebSocket server.

Overview

1. GET  /api/public/tracking/{uuid}/info      → Link details + access mode
2. POST /api/public/tracking/{uuid}/verify     → Session token (4h validity)
3. Connect WebSocket with session token
4. Subscribe to private-tracking.{uuid}
5. Listen for .location.updated events

Check the tracking link's access mode and status (no auth required):

GET https://api.yipii.io/api/public/tracking/{uuid}/info
{
  "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "display_name": "Bus Route 42",
  "access_mode": "public",
  "is_active": true,
  "is_expired": false,
  "is_within_schedule": true,
  "branding": {
    "logo_url": "https://...",
    "primary_color": "#E8C547",
    "company_name": "Yipii"
  }
}

Access modes:

ModeVerification Required
publicNone — call verify with empty body
emailEmail address
email_codeEmail + 6-digit code (sent via email)
authorized_onlyWhitelisted email address

Step 2: Verify and Get Session Token

POST https://api.yipii.io/api/public/tracking/{uuid}/verify

Public mode (no credentials):

{}

Email mode:

{
  "email": "viewer@example.com"
}

Email + code mode (two requests):

// First: request code
{ "email": "viewer@example.com" }
 
// Second: submit code
{ "email": "viewer@example.com", "access_code": "123456" }

Response:

{
  "success": true,
  "session": {
    "token": "abc123xyz...",
    "expires_at": "2026-01-30T15:00:00Z",
    "link": {
      "uuid": "a1b2c3d4-...",
      "title": "Bus Route 42"
    }
  }
}

The session token is valid for 4 hours by default.

Step 3: Connect WebSocket

import Pusher from 'pusher-js';
 
const pusher = new Pusher('public-tracking', {
  wsHost: 'ws-live.yipii.io',
  wsPort: 443,
  wssPort: 443,
  forceTLS: true,
  enabledTransports: ['ws', 'wss'],
  disableStats: true,
  cluster: '',
  authEndpoint: 'https://api.yipii.io/broadcasting/auth',
  auth: {
    headers: {
      'X-Session-Token': sessionToken,
    },
  },
});

Step 4: Subscribe to Channel

const channel = pusher.subscribe(`private-tracking.${uuid}`);
 
channel.bind('pusher:subscription_succeeded', () => {
  console.log('Subscribed — listening for location updates');
});

Step 5: Listen for Events

EventDescription
.location.updatedVehicle position changed
.link.expiredTracking link expired or disabled
.schedule.changedEntered or exited schedule window
.vehicle.changedRoute-based link reassigned to different vehicle
channel.bind('.location.updated', (data) => {
  const { location } = data;
 
  console.log(`Position: ${location.latitude}, ${location.longitude}`);
  console.log(`Speed: ${location.speed_kmh} km/h`);
  console.log(`Moving: ${location.is_moving}, Ignition: ${location.ignition}`);
 
  if (location.distance_km !== undefined) {
    console.log(`Distance to destination: ${location.distance_km} km`);
  }
  if (location.eta_minutes !== undefined) {
    console.log(`ETA: ${location.eta_minutes} minutes`);
  }
});
 
channel.bind('.link.expired', (data) => {
  console.log(`Link expired: ${data.reason}`);
  // reason: "arrival", "time_expired", "disabled", "schedule_ended"
  pusher.disconnect();
});
 
channel.bind('.schedule.changed', (data) => {
  if (!data.is_active) {
    console.log(data.message); // "Tracking available Mon-Fri 7am-4pm"
  }
});
 
channel.bind('.vehicle.changed', (data) => {
  console.log(data.message);
  if (data.new_location) {
    // Update map to new vehicle's position
  }
});

Location Event Payload

{
  "location": {
    "latitude": 35.9023,
    "longitude": 14.5134,
    "speed_kmh": 45,
    "heading": 180,
    "timestamp": "2026-01-30T10:30:00+00:00",
    "is_moving": true,
    "ignition": true,
    "street": "Triq il-Kbira",
    "town": "Iż-Żejtun",
    "country": "Malta",
    "distance_km": 0.8,
    "eta_minutes": 1
  }
}

Note: No internal identifiers (asset_id, IMEI, account info) are exposed. The distance_km and eta_minutes fields are only present when the link has arrival-based expiration configured.

Polling Fallback

If WebSocket is unavailable, poll the location endpoint every 30 seconds:

GET https://api.yipii.io/api/public/tracking/{uuid}/location
X-Session-Token: {session_token}

The response includes asset data with location fields matching the WebSocket payload structure.

Swift (iOS)

import PusherSwift
 
let options = PusherClientOptions(
    authMethod: .authRequestBuilder(authRequestBuilder: { request in
        var req = request
        req.addValue(sessionToken, forHTTPHeaderField: "X-Session-Token")
        return req
    }),
    host: .host("ws-live.yipii.io"),
    port: 443,
    useTLS: true
)
 
let pusher = Pusher(key: "public-tracking", options: options)
let channel = pusher.subscribe("private-tracking.\(uuid)")
 
channel.bind(eventName: "location.updated") { event in
    guard let data = event.data else { return }
    // Parse and update map
}
 
channel.bind(eventName: "link.expired") { event in
    // Show expired state
    pusher.disconnect()
}
 
pusher.connect()

Kotlin (Android)

val authorizer = HttpAuthorizer("https://api.yipii.io/broadcasting/auth").apply {
    setHeaders(mapOf("X-Session-Token" to sessionToken))
}
 
val options = PusherOptions().apply {
    setHost("ws-live.yipii.io")
    setWsPort(443)
    setWssPort(443)
    isUseTLS = true
    setAuthorizer(authorizer)
}
 
val pusher = Pusher("public-tracking", options)
val channel = pusher.subscribePrivate("private-tracking.$uuid")
 
channel.bind("location.updated") { event ->
    val data = JSONObject(event.data)
    val location = data.getJSONObject("location")
    // Update map
}
 
channel.bind("link.expired") { event ->
    // Show expired state
    pusher.disconnect()
}
 
pusher.connect()

Security

Data Exposed to Public Tracking Viewers

ExposedNOT Exposed
Position (lat/lng)IMEI number
Speed and headingInternal device IDs
Moving/stopped statusAccount information
Address (street, town)Track history
ETA and distance to destinationOther assets
Display nameDriver personal data

Session Security

  • Session tokens are high-entropy random strings tied to a specific tracking link UUID
  • Cross-link protection — a session for link A cannot access link B's channel
  • Expiration — sessions expire after a configurable period (default 4 hours)
  • TLS required — all connections use WSS (port 443)
  • Revocation — disabling a link immediately disconnects all viewers

Access Control for Public Tracking

ModeSecurity LevelUse Case
publicLowestCustomer delivery tracking
emailMediumKnown recipients (parents, partners)
email_codeHighSensitive cargo, compliance
authorized_onlyHighestRestricted access (warehouse staff)

Live stream security

  • The token is validated on every connect, not only when you ask for detail. A rejected token is disconnected — it is never quietly downgraded to a smaller payload.
  • asset_ids is treated as hostile input. It is intersected with the assets your account can actually see, and anything else is dropped before you are registered for broadcasts.
  • Non-granted channels are dropped, silently, at connect.
  • It fails closed. If the auth service cannot be reached and there is no recent cached result for your token, the connection is refused rather than served unscoped. A previously-validated token keeps working across a short outage.

Scaling & Performance

Ask for the assets you need, on one connection

Each asset produces a position every 10-30 seconds while moving. Subscribing to 1,000 when you draw 10 wastes bandwidth at both ends.

Narrow the asset_ids list, not the number of connections. There is no per-asset channel, and opening a socket per asset is the one pattern this server is not built for:

// Good — one connection, only the assets on screen
const socket = io("https://avl.dazzlepanel.com", {
  query: { access_token: TOKEN, asset_ids: JSON.stringify(visibleAssetIds) },
});
 
// Changing the set means reconnecting with a new asset_ids list.
socket.disconnect();

Deduplication

GPS trackers may send duplicate positions. Deduplicate by assetId + wsSeqwsSeq is the server's monotonic counter, and unlike dateUpdated it never goes backwards:

const seen = new Map();
const MAX_AGE_MS = 5000;
 
function isDuplicate(update) {
  const key = `${update.assetId}-${update.wsSeq}`;
  if (seen.has(key)) return true;
 
  seen.set(key, Date.now());
 
  // Cleanup old entries periodically
  if (seen.size > 1000) {
    const cutoff = Date.now() - MAX_AGE_MS;
    for (const [k, time] of seen) {
      if (time < cutoff) seen.delete(k);
    }
  }
 
  return false;
}

Reconnection

Pusher clients reconnect automatically with exponential backoff. For public tracking, validate the session before reconnecting:

pusher.connection.bind('disconnected', () => {
  fetch(`https://api.yipii.io/api/public/tracking/${uuid}/info`)
    .then(res => res.json())
    .then(info => {
      if (info.is_active && !info.is_expired) {
        pusher.connect(); // Session still good
      } else {
        showExpiredMessage();
      }
    });
});

Rate Limits

Public Tracking:

EndpointLimit
POST /verify30 requests/minute per IP
GET /location (polling)60 requests/minute per session
WebSocket connections per IP100
Messages per second per channel10

Typical Update Frequency:

Vehicle StateUpdate Interval
MovingEvery 10-30 seconds
Stopped, ignition onEvery 60 seconds
Stopped, ignition offEvery 5-10 minutes

WebSocket Authentication Errors

CodeMeaning
4001Invalid session token
4002Session expired
4003Tracking link not found
4004Link expired or inactive
4005Outside schedule window

Next Steps

Was this page helpful?