Skip to main content

Realtime patterns

Three things every realtime client needs: matching command results to the commands you sent, ignoring repeated status updates, and catching up after a disconnect. And one thing Parklio doesn't have: webhooks.

Correlate results by event_id​

POST /v2/products/{id}/state and POST /v2/products/{id}/settings return an event_id. The result arrives later as a lot_change (success) or gateway_error (failure) with the same event_id. See Asynchronous operations.

The helper below handles the cases that trip clients up:

  • the result can arrive before the HTTP response, so unmatched results are kept for a while;
  • your connection also gets results for operations you didn't send, which are ignored;
  • a result may never come, so each wait has a timeout.
pending-operations.ts
import type { Socket } from 'socket.io-client';

export interface OperationEvent {
event_id?: string;
type?: number | 'Timeout';
message?: string;
data: Record<string, any>;
}

export type OperationResult =
| { ok: true; event: OperationEvent }
| { ok: false; event: OperationEvent }
| { ok: false; timedOut: true };

// Matches realtime results to the event_ids your commands returned.
export class PendingOperations {
#waiting = new Map<string, { resolve: (r: OperationResult) => void; timer: NodeJS.Timeout }>();
#early = new Map<string, OperationResult>(); // results that arrived before we asked

constructor(socket: Socket, { keepEarlyMs = 60_000 } = {}) {
const settle = (ok: boolean) => (raw: string) => {
const event: OperationEvent = JSON.parse(raw);
if (!event.event_id) return; // a status update, not a command result
const eventId = event.event_id;
const result: OperationResult = ok ? { ok: true, event } : { ok: false, event };
const waiter = this.#waiting.get(eventId);
if (waiter) {
clearTimeout(waiter.timer);
this.#waiting.delete(eventId);
waiter.resolve(result);
} else {
this.#early.set(eventId, result);
setTimeout(() => this.#early.delete(eventId), keepEarlyMs).unref();
}
};
socket.on('lot_change', settle(true));
socket.on('gateway_error', settle(false));
}

// Resolves with { ok, event }, or { ok: false, timedOut: true }.
wait(eventId: string, timeoutMs: number): Promise<OperationResult> {
const early = this.#early.get(eventId);
if (early) {
this.#early.delete(eventId);
return Promise.resolve(early);
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.#waiting.delete(eventId);
resolve({ ok: false, timedOut: true });
}, timeoutMs);
this.#waiting.set(eventId, { resolve, timer });
});
}
}
Usage
const pending = new PendingOperations(socket); // create it right after connecting

const res = await fetch(`https://api.parklio.com/v2/products/${productId}/state`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ operation: 'open' }),
});
const { data } = await res.json();
const result = await pending.wait(data.event_id, 120_000);

When a wait times out, the operation may still have run. Re-read the product with GET /v2/products/{id} before deciding what to do.

Deduplicate status updates​

lot_change events without an event_id come from Gateways reporting what they see over Bluetooth. Two things to know:

  • Each one is sent twice, with identical payloads.
  • They repeat even when nothing changed: a Gateway's report re-sends every product it sees.

So treat them as "current state of product X", not as "product X changed". Keep the last state per product, and act only when something you care about differs:

Ignore repeats
const lastState = new Map<number, string>(); // product id -> fingerprint

socket.on('lot_change', (raw: string) => {
const { event_id, data } = JSON.parse(raw);
if (event_id) return; // command results are handled by PendingOperations
const block = data.barrier ?? data.gate ?? data.chain ?? data.bollard ?? data.brain;
const fingerprint = JSON.stringify([data.status, block?.state, data.product_error]);
if (lastState.get(data.id) === fingerprint) return;
lastState.set(data.id, fingerprint);
onProductChanged(data); // your code
});

Pick the fields for the fingerprint from what you act on; battery_status and rssi change often and are rarely worth an alert.

Resync after a reconnect​

There's no replay: anything sent while you were disconnected is gone. So every time you connect, re-read the state you track over REST, page by page. That covers the first connect too, where events sent in the first moment can be missed (see Authentication).

Resync products of a lot on every connect
socket.on('connect', () => resyncLot(lotId)); // fires on the first connect and after each reconnect

async function resyncLot(lotId: number) {
for (let page = 1, last = 1; page <= last; page++) {
const url = `https://api.parklio.com/v2/products?filter=lot_id:eq:${lotId}&page=${page}&size=100`;
const body = await (await fetch(url, { headers: { Authorization: `Bearer ${token}` } })).json();
for (const product of body.data) {
lastState.delete(product.id);
onProductChanged(product); // note: REST and realtime product shapes differ slightly
}
last = body.meta.last_page;
}
}

Also treat every command that was pending during the disconnect as unknown: re-read those products too.

In Python, python-socketio calls your connect handler on the first connect and after each reconnect too; start the resync from there.

No webhooks​

Parklio doesn't call your URLs. Realtime events are delivered only over this Socket.IO connection, to clients that are connected at the time. If other systems need the events, run one small service that holds the connection and forwards what you need (to a queue, a database or your own webhooks), and resyncs after reconnects as above.

Next​