Quickstart: stream live tag positions
Five steps from credentials to live data: get an OAuth 2.0 token, find your site's server, connect over WebSocket, stream tag positions and load the floor plan.
This tutorial takes you from a client ID and secret to tag positions streaming into your own code, drawn on the site’s floor plan. It follows five steps: get a token, find the real-time server for your site, connect and authenticate, stream positions, and load the layout. The examples use JavaScript for Node.js and curl.
Step 1: Get an access token
Noccela uses the OAuth 2.0 client-credentials flow. Send your client ID and secret to the token endpoint as a form-encoded POST. The grant_type parameter is required.
curl -s https://auth.noccela.io/connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "grant_type=client_credentials"
The response contains a JSON Web Token (JWT) and its lifetime in seconds. Example response (values shortened):
{
"access_token": "eyJhbGciOi...",
"expires_in": 86400,
"token_type": "Bearer",
"scope": "global"
}
The same request in JavaScript:
const AUTH_URL = 'https://auth.noccela.io/connect/token';
async function getToken() {
const response = await fetch(AUTH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET',
grant_type: 'client_credentials',
}),
});
if (!response.ok) throw new Error(`Token request failed: HTTP ${response.status}`);
const { access_token, expires_in } = await response.json();
return { token: access_token, expiresIn: expires_in };
}
Authentication explains token lifetime, permissions and refresh in detail.
Step 2: Find the real-time domain
Real-time data comes directly from the server node that runs your site’s positioning engine. Ask the HTTP API which node that is, with the account ID and site ID. The node can change, so look it up every time before you connect.
curl -s "https://api.noccela.io/realtime/domain?Account=YOUR_ACCOUNT_ID&Site=YOUR_SITE_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Example response:
{ "domain": "api.n1.fi1.noccela.io" }
const API_URL = 'https://api.noccela.io';
async function getDomain(token, accountId, siteId) {
const url = new URL('/realtime/domain', API_URL);
url.search = new URLSearchParams({ Account: accountId, Site: siteId });
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!response.ok) throw new Error(`Domain request failed: HTTP ${response.status}`);
const { domain } = await response.json();
return domain;
}
Step 3: Connect and authenticate
Open a WebSocket to wss:// plus the domain, path /realtime, with the account and site as query parameters. Authentication does not use headers: the first message you send is the raw access token, with no formatting. If the token is accepted, the server answers with a message whose uniqueId is authSuccess (example values):
{
"uniqueId": "authSuccess",
"status": "ok",
"payload": {
"tokenExpiration": 1792742400,
"tokenIssued": 1792656000,
"cloudVersion": "v3.x"
}
}
Three rules keep the connection healthy:
- Ping and pong. The server sometimes sends an empty message. Answer with the string
1. Without an answer, the connection times out after two minutes. - Requests and responses. Requests are JSON objects with
uniqueId,actionandpayload(use an empty object when there is no payload). The response carries the sameuniqueId. - Token refresh. Get a new token when less than half of the current one’s lifetime is left, and send it with the
refreshTokenaction. An expired token closes the connection.
const ACCOUNT_ID = 'YOUR_ACCOUNT_ID';
const SITE_ID = 'YOUR_SITE_ID';
let { token, expiresIn } = await getToken();
const domain = await getDomain(token, ACCOUNT_ID, SITE_ID);
const ws = new WebSocket(`wss://${domain}/realtime?account=${ACCOUNT_ID}&site=${SITE_ID}`);
// Authenticate: the first message is the raw token.
ws.addEventListener('open', () => ws.send(token));
// Send a request and wait for the response with the same uniqueId.
let nextId = 1;
const pending = new Map();
function request(action, payload = {}) {
const uniqueId = String(nextId++);
ws.send(JSON.stringify({ uniqueId, action, payload }));
return new Promise((resolve, reject) => pending.set(uniqueId, { resolve, reject }));
}
// Refresh the token when half of its lifetime has passed.
setTimeout(async function refresh() {
({ token, expiresIn } = await getToken());
await request('refreshToken', { token });
setTimeout(refresh, (expiresIn * 1000) / 2);
}, (expiresIn * 1000) / 2);
ws.addEventListener('message', (event) => {
if (event.data === '') {
ws.send('1'); // pong
return;
}
const message = JSON.parse(event.data);
if (message.uniqueId === 'authSuccess') {
onAuthenticated();
return;
}
if (message.uniqueId && pending.has(message.uniqueId)) {
const { resolve, reject } = pending.get(message.uniqueId);
pending.delete(message.uniqueId);
if (message.status && message.status !== 'ok') reject(new Error(message.status));
else resolve(message.payload);
return;
}
onServerEvent(message); // subscriptions: see step 4
});
ws.addEventListener('close', () => console.log('Connection closed'));
Step 4: Stream tag positions
Register for location updates. A null device list means every tag on the site. From then on, the server sends locationUpdate events with the new coordinates of each tag that moved.
async function onAuthenticated() {
await request('registerTagLocation', { deviceIds: null });
await request('initialTagState', { deviceIds: null });
await loadLayout(); // step 5
}
function onServerEvent(message) {
switch (message.action) {
case 'locationUpdate':
for (const [deviceId, p] of Object.entries(message.payload)) {
console.log(`${deviceId}: x=${p.x} y=${p.y} z=${p.z} at ${new Date(p.timestamp).toISOString()}`);
}
break;
case 'initialTagState':
console.table(decodeTagState(message.payload));
break;
}
}
An example locationUpdate payload, keyed by device ID:
{
"1234567": { "x": 15320, "y": 8410, "z": 1200, "timestamp": 1792656012345, "uncertaintyDistance": 90 }
}
floor is included when the site has more than one floor. timestamp is the time the position was computed, in milliseconds since the Unix epoch.
The initialTagState event carries the complete state of every tag. It is large, so it arrives as Base64-encoded MessagePack in which each tag’s state is an array. Decode it and give the array positions their names:
import { decode } from '@msgpack/msgpack';
const TAG_FIELDS = [null, 'name', 'batteryVoltage', 'batteryStatus', 'status', 'areas', 'wire', 'reed',
'isOnline', 'timestamp', 'x', 'y', 'accelerometer', 'floorId', 'signalLost', 'powerSave',
'deviceModel', 'fwVersion', 'strokeCount', 'z', 'uncertaintyDistance'];
function decodeTagState(base64) {
const states = decode(Buffer.from(base64, 'base64')); // { deviceId: [values] }
return Object.fromEntries(Object.entries(states).map(([deviceId, values]) => [
deviceId,
Object.fromEntries(TAG_FIELDS.map((field, i) => [field, values[i]]).filter(([field]) => field)),
]));
}
To keep the full state current, also register registerTagDiffStream, which sends only the properties that changed. After a reconnect, request the initial state again, because changes made while you were offline are not replayed. The real-time API reference lists every message.
Step 5: Load the site layout
Positions are easier to read on the floor plan. The getSite request returns the site’s layout as floors, each with layers, each with items. Blueprint layers (type 2) hold the floor-plan image as an item whose minX, maxX, minY and maxY give its extent in site coordinates, and whose fileId lets you download the image with getBlueprint.
import { writeFile } from 'node:fs/promises';
let blueprint;
async function loadLayout() {
const site = await request('getSite');
const floor = site.layout.floors[0]; // this example assumes one floor
blueprint = floor.layers.find((layer) => layer.type === 2).items[0];
const image = await request('getBlueprint', { fileId: blueprint.fileId });
await writeFile(image.name ?? 'floor-plan', Buffer.from(image.data, 'base64'));
await request('registerLayoutChanges'); // be told when the layout is edited
}
// Convert site coordinates to pixel positions on the image.
// In site coordinates y grows upwards; in images it grows downwards.
function toPixels(x, y, imageWidth, imageHeight) {
return {
left: ((x - blueprint.minX) / (blueprint.maxX - blueprint.minX)) * imageWidth,
top: (1 - (y - blueprint.minY) / (blueprint.maxY - blueprint.minY)) * imageHeight,
};
}
A real map application should handle several floors, several blueprints per floor and area layers for zones. The data model describes the layout in full.
Try it from a terminal
The HTTP steps work with curl as shown. For the WebSocket, any command-line WebSocket client will do: connect to the URL from step 3, paste the access token as the first message, and then send a request such as this one:
{ "uniqueId": "1", "action": "registerTagLocation", "payload": { "deviceIds": null } }
Remember to answer an empty ping message with 1.
Use the SDK instead
The JavaScript SDK wraps steps 2 to 4: it looks up the domain, authenticates, answers pings, refreshes the token, reconnects and re-registers your subscriptions.
import * as Ncc from '@noccela/ncc-cloud-integration';
// Replace YOUR_ACCOUNT_ID and YOUR_SITE_ID with your numeric account and site IDs.
const channel = new Ncc.EventChannel(YOUR_ACCOUNT_ID, YOUR_SITE_ID);
// The callback returns a fresh access token whenever the SDK needs one.
await channel.connectPersistent(async () => (await getToken()).token);
await channel.registerLocationUpdate((err, payload) => {
if (err) return console.error(err);
for (const [deviceId, { x, y, floor }] of Object.entries(payload)) {
console.log(deviceId, x, y, floor);
}
}, null);
Next steps
Last updated 22 October 2026