DocsDevice Tracking

Device Tracking

Real-time location tracking is deceptively hard to do well over HTTP. The naive approach - polling GET /location every second - works for demos but falls apart in production: you're paying for requests whether or not the device moved, you can't go faster than your polling interval without hammering the server, and every poll adds a round-trip of latency between the device and anyone watching it.

PickPoint's Device Tracking API uses a persistent WebSocket connection instead. The device keeps a single TCP connection open and pushes location events as they happen - no polling, no wasted requests, and latency measured in milliseconds rather than seconds. Every location is also persisted and queryable via REST for historical analysis and replay.

Device Tracking requires a Pro or Ultra plan. Three actors are involved: a device that sends locations, the PickPoint server that stores and fans them out, and optional subscribers (your backend or dashboard) that receive a live feed.

How it works

Deviceyour app / hardwarePickPointws.pickpoint.ioSubscriberyour backend / dashboardREGISTERCONNECT & TRACKSUBSCRIBESTREAMPOST /v2/devices (register)201 { uid, secret }WebSocket connect (client-id, secret)emit: track:startemit: track:started { trackUid }WebSocket connect (x-api-key)emit: device:subscribe { deviceUid }emit: location:add { lat, lon, … }emit: location:added { lat, lon, … }emit: track:stop
Request / connectResponseWebSocket event (device → server)Push event (server → subscriber)

Socket.IO vs raw WebSocket

The API supports both, but Socket.IO is strongly recommended for device clients. Despite what the name implies, Socket.IO is not a thin wrapper over WebSocket - it's a custom protocol (Engine.IO) layered on top, with its own handshake, heartbeat, packet framing, and reconnection logic. What that buys you in practice:

  • Automatic reconnection with exponential backoff - WebSocket connections break constantly. Mobile users switch between Wi-Fi and cellular, laptops sleep, servers deploy. A connection that doesn't auto-reconnect silently stops working. Socket.IO handles this out of the box.
  • Acknowledgements - you can confirm that a specific event was received by the server, useful for critical location updates.
  • HTTP long-polling fallback - if a WebSocket upgrade is blocked (corporate proxies, captive portals), the connection silently stays on HTTP polling. Your code never needs to know.

One important configuration: always set transports: ['websocket']. By default Socket.IO starts every connection with HTTP long-polling and upgrades later - adding an extra round-trip. Forcing WebSocket from the start is faster and avoids sticky-session complexity on load balancers.

Raw WebSocket clients are supported if you're building for an embedded platform without a Socket.IO library (C++, bare Rust, microcontrollers). You'll need to implement reconnection yourself.

Devices and tracks

A device is a persistent entity with a stable uid and secret - it represents a vehicle, phone, watch, or any tracked asset. Devices live in your dashboard and accumulate a history over their lifetime.

A track is a single continuous session: the sequence of location points between one track:start and the corresponding track:stop. Every time a device connects and starts tracking, a new track is created. If the connection drops and the device reconnects, a new track is created - there's a clean gap in the history reflecting the disconnection period. This makes it easy to distinguish a deliberate stop from a network interruption.

The trackUid returned in track:started is session-scoped. It's not just a routing key - it's the primary identifier for the historical track record and for correlating location points when multiple devices are tracked in parallel.

Connection

Endpointwss://ws.pickpoint.io
Path/v2/tracking
TransportAlways set transports: ['websocket'] - skips the polling phase
Device authclient-id = device uid, client-secret = device secret (query params)
Subscriber authx-api-key = your API key (query param)

Step-by-Step Integration

1

Register the device

Each tracked unit needs a uid / secret pair. Create one in the Tracking section of your dashboard, or via REST:

curl -X POST "https://api.pickpoint.io/v2/devices" \
  -H "X-Api-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Vehicle 42"}'

Response:

{
  "uid":       "d_7f3a9b2c",
  "secret":    "sk_4e1d8f0a2c9b7e3d",
  "name":      "Vehicle 42",
  "createdAt": "2024-09-01T10:00:00Z"
}

Store uid and secret securely on the device. They are static - rotation requires creating a new device.

2

Connect and start a track session

Connect using the device's uid as client-id and secret as client-secret. After connecting, emit track:start - the server replies with track:started and a session-scoped trackUid. Include it in every subsequent location:add.

// npm install socket.io-client

import { io } from 'socket.io-client';

const socket = io('wss://ws.pickpoint.io', {
  transports: ['websocket'],
  path:       '/v2/tracking',
  query: {
    'client-id':     'CLIENT_ID',
    'client-secret': 'CLIENT_SECRET',
  },
});

socket.on('error', (error) => {
  console.error(JSON.stringify(error));
});

socket.on('track:started', ({ trackUid }) => {
  console.log('Track started:', trackUid);
  socket.emit('location:add', {
    trackUid,
    latitude:  55.7558,
    longitude: 37.6173,
  });
});

socket.emit('track:start', {});
// https://github.com/socketio/socket.io-client-java

import io.socket.client.IO
import io.socket.client.Socket
import io.socket.engineio.client.transports.WebSocket
import org.json.JSONObject

fun main() {
    val options = IO.Options.builder()
        .setTransports(arrayOf(WebSocket.NAME))
        .setPath("/v2/tracking")
        .setQuery("client-id=CLIENT_ID&client-secret=CLIENT_SECRET")
        .build()

    val socket: Socket = IO.socket("wss://ws.pickpoint.io", options)

    socket.once(Socket.EVENT_CONNECT) {
        socket.on("track:started") { args ->
            val data = args[0] as JSONObject
            val loc  = JSONObject()
                .put("trackUid",   data.getString("trackUid"))
                .put("latitude",   55.7558)
                .put("longitude",  37.6173)
            socket.emit("location:add", loc)
        }
        socket.emit("track:start", JSONObject())
    }

    socket.connect()
}
// https://github.com/socketio/socket.io-client-swift

import Foundation
import SocketIO

let manager = SocketManager(socketURL: URL(string: "wss://ws.pickpoint.io")!, config: [
    .forceWebsockets(true),
    .path("/v2/tracking"),
    .compress,
    .connectParams([
        "client-id":     "CLIENT_ID",
        "client-secret": "CLIENT_SECRET",
        "transports":    ["websocket"],
    ]),
])

let socket = manager.defaultSocket

socket.on(clientEvent: .connect) { _, _ in
    socket.on("track:started") { payload, _ in
        let data     = payload[0] as! [String: Any]
        let trackUid = data["trackUid"] as! String
        socket.emit("location:add", [
            "trackUid":  trackUid,
            "latitude":  55.7558,
            "longitude": 37.6173,
        ])
    }
    socket.emit("track:start", [String: Any]())
}

socket.connect()
CFRunLoopRun()
// https://crates.io/crates/rust_socketio

use rust_socketio::{ClientBuilder, Payload, RawClient, TransportType};
use serde_json::{json, Value};
use std::{thread::sleep, time::Duration};

fn main() {
    let url = "wss://ws.pickpoint.io/v2/tracking/               ?client-id=CLIENT_ID&client-secret=CLIENT_SECRET";

    let socket = ClientBuilder::new(url)
        .transport_type(TransportType::Websocket)
        .on("track:started", |payload: Payload, socket: RawClient| {
            if let Payload::Text(text) = payload {
                let data: Value = serde_json::from_str(&text[0]).unwrap();
                let loc = json!({
                    "trackUid":  data["trackUid"].as_str().unwrap(),
                    "latitude":  55.7558,
                    "longitude": 37.6173,
                });
                socket.emit("location:add", loc.to_string()).ok();
            }
        })
        .connect()
        .expect("Connection failed");

    sleep(Duration::from_secs(1));
    socket.emit("track:start", "{}").expect("Server unreachable");
    sleep(Duration::from_secs(10));
}
3

Send location updates

Emit location:add as frequently as needed - up to 50 Hz. Only trackUid, latitude, and longitude are required:

socket.emit('location:add', {
  trackUid:  'TRACK_UID',
  latitude:   55.7558,
  longitude:  37.6173,
  timestamp: '2024-09-01T12:00:00.000Z',
  accuracy:   4.2,
  speed:      8.3,
  heading:    270,
  altitude:   144
});

If you omit timestamp, the server records the receive time. Provide it explicitly when batching points offline before sending.

4

Subscribe to live updates (optional)

Your backend or dashboard can receive real-time events for any device. Connect with your API key (not the device secret), then emit device:subscribe. The server pushes location:added for every incoming point. Multiple subscribers can watch the same device simultaneously.

import { io } from 'socket.io-client';

const socket = io('wss://ws.pickpoint.io', {
  transports: ['websocket'],
  path:       '/v2/tracking',
  query:      { 'x-api-key': 'API_KEY' },
});

socket.on('error', (error) => {
  console.error(JSON.stringify(error));
});

socket.on('location:added', (data) => {
  console.log('Location update:', data);
});

socket.emit('device:subscribe', { deviceUid: 'DEVICE_UID' });
import 'dart:convert';
import 'package:socket_io_client/socket_io_client.dart';

void main() async {
  final socket = io(
    'wss://ws.pickpoint.io',
    OptionBuilder()
      .setQuery({'x-api-key': 'API_KEY'})
      .setTransports(['websocket'])
      .setPath('/v2/tracking')
      .build(),
  );

  socket.on('location:added', (data) => print('Location: $data'));
  socket.onConnect((_) {
    socket.emit('device:subscribe', jsonEncode({'deviceUid': 'DEVICE_UID'}));
  });
}
import io.socket.client.IO
import io.socket.client.Socket
import io.socket.engineio.client.transports.WebSocket
import org.json.JSONObject

fun main() {
    val options = IO.Options.builder()
        .setTransports(arrayOf(WebSocket.NAME))
        .setPath("/v2/tracking")
        .setQuery("x-api-key=API_KEY")
        .build()

    val socket: Socket = IO.socket("wss://ws.pickpoint.io", options)
    socket.once(Socket.EVENT_CONNECT) {
        socket.on("location:added") { args -> println(args[0]) }
        socket.emit("device:subscribe", JSONObject().put("deviceUid", "DEVICE_UID"))
    }
    socket.connect()
}
import Foundation
import SocketIO

let manager = SocketManager(socketURL: URL(string: "wss://ws.pickpoint.io")!, config: [
    .forceWebsockets(true),
    .path("/v2/tracking"),
    .connectParams(["x-api-key": "API_KEY", "transports": ["websocket"]]),
])

let socket = manager.defaultSocket
socket.on(clientEvent: .connect) { _, _ in
    socket.on("location:added") { payload, _ in NSLog("\(payload[0])") }
    socket.emit("device:subscribe", ["deviceUid": "DEVICE_UID"])
}
socket.connect()
CFRunLoopRun()
use rust_socketio::{ClientBuilder, Payload, TransportType};
use serde_json::json;
use std::{thread::sleep, time::Duration};

fn main() {
    let socket = ClientBuilder::new(
        "wss://ws.pickpoint.io/v2/tracking/?x-api-key=API_KEY"
    )
    .transport_type(TransportType::Websocket)
    .on("location:added", |payload: Payload, _| {
        println!("Location: {:#?}", payload);
    })
    .connect()
    .expect("Connection failed");

    socket
        .emit("device:subscribe", json!({ "deviceUid": "DEVICE_UID" }))
        .expect("Server unreachable");

    sleep(Duration::from_secs(300));
}
5

Query track history

All location updates are stored indefinitely. Query them per device with optional time range filtering:

curl "https://api.pickpoint.io/v2/devices/d_7f3a9b2c/tracks?from=2024-09-01T00:00:00Z&to=2024-09-01T23:59:59Z" \
  -H "X-Api-Key: YOUR_KEY"

Response:

{
  "tracks": [
    {
      "uid":       "t_1a2b3c4d",
      "startedAt": "2024-09-01T10:15:00Z",
      "endedAt":   "2024-09-01T11:42:00Z",
      "points": [
        {
          "latitude":  55.7558,
          "longitude": 37.6173,
          "timestamp": "2024-09-01T10:15:00.000Z",
          "accuracy":  4.2,
          "speed":     8.3,
          "heading":   270,
          "altitude":  144
        }
      ]
    }
  ]
}

Each track maps to one track:start / track:stop session. A device that reconnected twice in a day will have two or more track entries.

Reconnection handling

Socket.IO reconnects automatically - but your application code needs to handle the consequences. After a reconnect, the old trackUid is gone and you need a new one. The cleanest pattern is to emit track:start on every connect event, not just the first one. This way each connection gap appears as a clean boundary between tracks in history - which is exactly what it is:

const socket = io('wss://ws.pickpoint.io', {
  transports:          ['websocket'],
  path:                '/v2/tracking',
  query:               { 'client-id': DEVICE_UID, 'client-secret': DEVICE_SECRET },
  reconnection:        true,
  reconnectionDelay:    1000,
  reconnectionDelayMax: 30000,
});

let currentTrackUid = null;

socket.on('connect', () => {
  socket.emit('track:start', {});   // new track session for every (re)connect
});

socket.on('track:started', ({ trackUid }) => {
  currentTrackUid = trackUid;
  startSendingLocations();
});

socket.on('disconnect', () => {
  currentTrackUid = null;           // reset - will be assigned again on reconnect
});

Events Reference

EventDirectionDescription
track:startDevice → ServerBegin a new tracking session
track:startedServer → DeviceSession started; payload contains trackUid
location:addDevice → ServerSend a location point
track:stopDevice → ServerEnd the session; finalizes the track in storage
device:subscribeSubscriber → ServerStart receiving live updates for a device
location:addedServer → SubscriberReal-time push for each incoming location
device:unsubscribeSubscriber → ServerStop receiving updates for a device
errorServer → ClientAuth or protocol error; see payload for details

Location data and what the fields mean

Only trackUid, latitude, and longitude are required. The optional fields are worth understanding:

  • timestamp - the moment the measurement was taken on the device, not when the server received it. Network latency and batching mean these can differ significantly. Always provide it when the device has a reliable clock; omit it only for live streaming where the receive time is good enough.
  • accuracy - horizontal accuracy radius in metres (the 68th-percentile circle in which the true position lies, per the standard GPS HDOP definition). Consumer smartphone GPS is typically 3–10 m in open sky, degrading to 30–100 m indoors or in dense urban canyons. Use this field to filter out poor-quality fixes before plotting them on a map.
  • speed - ground speed in m/s derived by the GPS chipset from Doppler shift, not from successive coordinates. It's more accurate than computing distance / time from two points and works even when the device is stationary.
  • heading - direction of travel in degrees clockwise from true north (0–360). Only meaningful when the device is moving; GPS heading at standstill is effectively random.
  • altitude - height above the WGS 84 reference ellipsoid, not above mean sea level. The difference (geoid height) varies from −100 m to +90 m depending on where you are. For most applications this doesn't matter; for aviation or precise surveying it does.

Update frequency

The API supports up to 50 Hz (50 location updates per second) per device. In practice, the right rate depends on your hardware and use case:

  • 1 Hz - consumer smartphones and most fleet trackers. Matches standard GPS chip output and is sufficient for delivery tracking, geofencing, and most field-service applications.
  • 5–10 Hz - sports GPS watches (Garmin, Polar, Suunto) and dedicated fitness hardware. Needed for accurate pace calculation on curves and for biomechanical analysis.
  • 10–50 Hz - GNSS modules in autonomous vehicles, racing telemetry, and drone flight controllers. At these rates the bottleneck is the hardware, not the API.

Note that "GPS" in common usage usually means GNSS - a combination of GPS (US), GLONASS (Russia), Galileo (EU), and BeiDou (China). Modern chipsets use all four constellations simultaneously, which improves accuracy and fix time especially in urban canyons where some satellites are obscured.

API Reference ↗GeocodingRouting