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.
How it works
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
| Endpoint | wss://ws.pickpoint.io |
| Path | /v2/tracking |
| Transport | Always set transports: ['websocket'] - skips the polling phase |
| Device auth | client-id = device uid, client-secret = device secret (query params) |
| Subscriber auth | x-api-key = your API key (query param) |
Step-by-Step Integration
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.
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));
}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.
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));
}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
| Event | Direction | Description |
|---|---|---|
track:start | Device → Server | Begin a new tracking session |
track:started | Server → Device | Session started; payload contains trackUid |
location:add | Device → Server | Send a location point |
track:stop | Device → Server | End the session; finalizes the track in storage |
device:subscribe | Subscriber → Server | Start receiving live updates for a device |
location:added | Server → Subscriber | Real-time push for each incoming location |
device:unsubscribe | Subscriber → Server | Stop receiving updates for a device |
error | Server → Client | Auth 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 computingdistance / timefrom 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.