# Events

> Every event the realtime connection sends, with its payload. Connect first; see
> [Connecting](/realtime/connecting).

## Before you read the payloads

- **Payloads are JSON strings.** Parse them first. The shapes below are the parsed
  objects.
- **Keys are snake_case**, dates are ISO-8601 strings, and many flags are `0` or `1`.
- **A key whose value is missing may be left out** rather than sent as `null`. The
  interfaces mark those keys with `?`.
- **Who receives what:** every event below except `authorized` goes to the lot it
  concerns and to the account that owns the lot. With an API token you get the events
  for your whole account; with a user token, those for the lots the user is assigned
  to. Something that isn't in a lot (a product with no lot, for example) sends nothing.

| Event | Sent when |
| --- | --- |
| [`authorized`](#authorized) | Your token is accepted |
| [`lot_change`](#lot_change) | A command succeeded, or a Gateway reported a product's state |
| [`gateway_error`](#gateway_error) | A command failed |
| [`gateway_status`](#gateway_status) | A Gateway went online or offline |
| [`plate_reading_create`](#plate_reading_create) | An LPR camera read a plate |
| [`plate_reading_update`](#plate_reading_update) | An access decision was made for a reading |
| [`session_create`](#session_create) | A parking session started |
| [`session_update`](#session_update) | A parking session changed or ended |
| [`lpr_camera_status`](#lpr_camera_status) | An LPR camera reported its status |
| [`lot_detection_change`](#lot_detection_change) | A lot's occupied count changed |
| [`zone_change`](#zone_change) | A zone's occupied count changed |
| [`parking_place_change`](#parking_place_change) | A parking place became free or occupied |
| [`parking_gateway_state_change`](#parking_gateway_state_change) | A parking-detection gateway went online or offline |
| [`lpr_event`](#lpr_event) | Legacy: a lot entry was recorded from a plate reading |

## Connection

### `authorized`

Sent once, only to your connection, when the server accepts your token. The payload is
an empty string. See [Authentication](/realtime/connecting#authentication).

## Products and Gateways

### `lot_change`

The state of one product. Despite the name, it's about a product, not a lot. It's sent
in two cases:

- **A command succeeded.** After
  [`POST /v2/products/{id}/state`](/api/endpoints/products-change-state) or
  [`POST /v2/products/{id}/settings`](/api/endpoints/products-change-settings), the
  result carries the same `event_id` the endpoint returned. You may also see
  `event_id`s for operations you didn't start; ignore them.
- **A Gateway reported what it sees.** These have **no `event_id`**. They're sent for
  every product in each report, whether or not anything changed, and **each one is sent
  twice**. See [Deduplicate status updates](/realtime/patterns#deduplicate-status-updates).

The product carries one block that matches its type: `barrier`, `gate`, `chain`,
`bollard` or `brain`.

```ts title="lot_change"
interface LotChange {
  lot_id: number | null;
  event_id?: string; // 8 hex characters; only for command results
  data: {
    id: number;
    device_id: string;
    name: string;
    lot_id: number | null;
    product_type_id: number;
    latitude: number | null;
    longitude: number | null;
    status: 'online' | 'offline';
    battery_status: number | null;
    rssi: number | null;
    product_error_id?: number; // only when the product reports an error
    product_error?: ProductError; // only when the product reports an error
    deleted_at: null;
    created_at: string;
    updated_at: string;
    barrier?: BarrierBlock;
    gate?: BrainBlock;
    chain?: BrainBlock;
    bollard?: BrainBlock;
    brain?: BrainBlock;
    lot: {
      id: number;
      lot_cluster_id: number; // same as id
      account_id: number;
      name: string;
      address: string;
      latitude: number | null;
      longitude: number | null;
      timezone: string; // IANA name, e.g. "Europe/Zagreb"
      created_at: string;
      updated_at: string;
    } | null;
  };
}

interface BarrierBlock {
  id: number;
  product_id: number;
  state: 'opened' | 'closed';
  is_auto_close: 0 | 1; // REST calls this is_autoclose
  is_car_on_top: 0 | 1;
  auto_close_time: number; // seconds; REST calls this autoclose_time
  create_at: string; // sic: REST calls this created_at
  updated_at: string;
}

interface BrainBlock {
  id: number;
  product_id: number;
  state: 'opened' | 'closed' | 'error' | 'unknown' | 'reserved' | 'disabled';
  permanent_position: 'disabled' | 'stay_open' | 'stay_close';
  deleted_at: string | null;
  created_at: string;
  updated_at: string;
}

interface ProductError {
  id: number;
  error_code: string;
  jammed: 0 | 1;
  sensor_dirty: 0 | 1;
  pin_broken: 0 | 1;
  rtcc_invalid: 0 | 1;
  battery_empty: 0 | 1;
  master_key_not_set: 0 | 1;
  created_at: string;
  updated_at: string;
}
```

For a barrier, `opened` means the arm is **down** (cars can pass over it) and `closed`
means the arm is **up**. See [Products and operations](/concepts/products-and-operations).

:::warning The barrier block's keys differ from the REST API
In `lot_change`, the barrier block uses `is_auto_close`, `auto_close_time` and
`create_at`. The REST API's product responses use `is_autoclose`, `autoclose_time` and
`created_at`. Read the realtime keys as they are shown here.
:::

```json title="lot_change: a command result for a barrier"
{
  "lot_id": 7,
  "event_id": "a1b2c3d4",
  "data": {
    "id": 123,
    "device_id": "B1234567",
    "name": "Entrance barrier",
    "lot_id": 7,
    "product_type_id": 2,
    "latitude": 45.815,
    "longitude": 15.982,
    "status": "online",
    "battery_status": 80,
    "rssi": -70,
    "deleted_at": null,
    "created_at": "2026-01-15T09:30:00.000Z",
    "updated_at": "2026-03-02T08:12:44.000Z",
    "barrier": {
      "id": 41,
      "product_id": 123,
      "state": "opened",
      "is_auto_close": 1,
      "is_car_on_top": 0,
      "auto_close_time": 45,
      "create_at": "2026-01-15T09:30:00.000Z",
      "updated_at": "2026-03-02T08:12:44.000Z"
    },
    "lot": {
      "id": 7,
      "lot_cluster_id": 7,
      "account_id": 3,
      "name": "Main garage",
      "address": "Example street 1",
      "latitude": 45.815,
      "longitude": 15.982,
      "timezone": "Europe/Zagreb",
      "created_at": "2026-01-10T10:00:00.000Z",
      "updated_at": "2026-01-10T10:00:00.000Z"
    }
  }
}
```

### `gateway_error`

A command failed. `event_id` matches the one
[`POST /v2/products/{id}/state`](/api/endpoints/products-change-state) or
[`POST /v2/products/{id}/settings`](/api/endpoints/products-change-settings) returned.
See [Asynchronous operations](/concepts/asynchronous-operations).

```ts title="gateway_error"
interface GatewayError {
  type: number | 'Timeout'; // see the table below
  event_id: string; // 8 hex characters
  data: {
    id: number; // product id
    name: string; // product name
  };
  message?: string; // absent when type is "Timeout"
}
```

```json title="gateway_error"
{
  "type": 6,
  "event_id": "a1b2c3d4",
  "data": { "id": 123, "name": "Entrance barrier" },
  "message": "Car on top"
}
```

When no Gateway got an answer from the product in time, `type` is the **string**
`"Timeout"` and there's no `message`. Otherwise `type` is one of these numbers. They are
**not** the HTTP [error codes](/api/errors), even where the numbers overlap.

| `type` | `message` |
| --- | --- |
| `0` | Auth error |
| `1` | Invalid guest key |
| `2` | Device disconnected |
| `3` | Bluetooth error |
| `4` | Device not found |
| `5` | Device jammed |
| `6` | Car on top |
| `7` | Sensor dirty |
| `8` | Device not initialized |
| `9` | Rtcc not set |
| `10` | Device busy |
| `11` | Unknown error |

### `gateway_status`

A Gateway connected to or disconnected from Parklio. Only Gateways assigned to a lot
send it. The payload identifies the Gateway by its `uuid`, not its numeric id.

```ts title="gateway_status"
interface GatewayStatus {
  uuid: string;
  status: 'online' | 'offline';
}
```

```json title="gateway_status"
{ "uuid": "xgobgwEG7rmZ", "status": "online" }
```

## License plates and sessions

### `plate_reading_create`

An LPR camera read a license plate. When it's sent, the access decision hasn't been made
yet: `access_decision`, `access_decision_reason` and `session_id` are `null`. Plate
readings created by
[`POST /v2/sessions`](/api/endpoints/sessions-create) don't send it.

```ts title="plate_reading_create and plate_reading_update"
interface PlateReading {
  id: number;
  plate: string;
  confidence: number | null;
  image_url: string | null; // temporary link to the full image
  cropped_image_url: string | null; // temporary link to the plate crop
  direction: 'in' | 'out' | 'undefined';
  type: 'bus' | 'car' | 'motorcycle' | 'truck' | 'van' | null;
  make: string | null;
  color: string | null;
  model: string | null;
  country_code: string | null;
  session_id: number | null;
  access_decision: 'denied' | 'triggered' | null;
  access_decision_reason:
    | 'debounce'
    | 'direction_mismatch'
    | 'max_capacity'
    | 'no_active_session'
    | 'no_tariff'
    | 'session_exit'
    | 'tariff'
    | 'unknown_direction'
    | 'whitelist'
    | null;
  lpr_camera_id: number;
  zone_id: number;
  plate_side: 'front' | 'rear';
  lpr_camera_direction: 'in' | 'out' | 'in/out'; // the camera's position at read time
  created_at: string;
  updated_at: string;
}
```

```json title="plate_reading_create"
{
  "id": 9001,
  "plate": "ZG1234AB",
  "confidence": 0.92,
  "image_url": null,
  "cropped_image_url": null,
  "direction": "in",
  "type": "car",
  "make": null,
  "color": null,
  "model": null,
  "country_code": "HR",
  "session_id": null,
  "access_decision": null,
  "access_decision_reason": null,
  "lpr_camera_id": 15,
  "zone_id": 9,
  "plate_side": "front",
  "lpr_camera_direction": "in",
  "created_at": "2026-03-02T08:12:40.000Z",
  "updated_at": "2026-03-02T08:12:40.000Z"
}
```

`direction` is the string `'undefined'` when the camera couldn't tell; such readings get
no access decision.

### `plate_reading_update`

Same shape as `plate_reading_create`. Sent when:

- an access decision is recorded: `triggered` (reason `whitelist`, `tariff` or
  `session_exit`) or `denied` (any other reason, including `debounce` for a repeat read
  of the same plate);
- the camera refines a reading after the fact (new plate, confidence or direction).

`session_id` is usually still `null` here: the decision is recorded before the reading
is linked to a session. To follow the session, listen for
[`session_create`](#session_create) and [`session_update`](#session_update).
Editing a reading through
[`PATCH /v2/plate-readings/{id}`](/api/endpoints/plate-readings-update) doesn't send
this event.

### `session_create`

A parking session started: either you or a user created it through
[`POST /v2/sessions`](/api/endpoints/sessions-create), or an LPR camera let a vehicle in
(by whitelist or tariff).

```ts title="session_create and session_update"
interface Session {
  id: number;
  public_id: string; // 10 characters, A-Z and 0-9
  ended_at?: string | null; // left out while the session is new
  duration: number; // seconds
  discount_time?: number | null; // minutes; -1 means unlimited
  amount_paid: number;
  possibly_exited?: boolean | null;
  exit_allowed: boolean; // true while the session hasn't ended
  entry_license_plate: {
    id: number;
    plate: string;
    image_url: string | null;
    cropped_image_url: string | null;
  } | null;
  created_at: string;
  entry: { id: number; type: SessionSourceType };
  exit?: { id: number; type: SessionSourceType } | null; // left out or null while open
  zone_id: number;
}

// entry.id / exit.id is the id of that source: the camera, the account or the user
type SessionSourceType = 'lpr_camera' | 'account' | 'attendant' | 'terminal' | 'product' | 'payment';
```

`entry.type` is `account` when an API token created the session, `attendant` when a
user did, and `lpr_camera` when a camera let the vehicle in.

```json title="session_create"
{
  "id": 501,
  "public_id": "K7Q2M9X4TB",
  "duration": 0,
  "amount_paid": 0,
  "exit_allowed": true,
  "entry_license_plate": {
    "id": 9001,
    "plate": "ZG1234AB",
    "image_url": null,
    "cropped_image_url": null
  },
  "created_at": "2026-03-02T08:12:41.000Z",
  "entry": { "id": 15, "type": "lpr_camera" },
  "zone_id": 9
}
```

### `session_update`

Same shape as `session_create`. Sent when:

- a session is changed through
  [`PATCH /v2/sessions/{id}`](/api/endpoints/sessions-update) (discount time or entry
  plate);
- a session is closed through
  [`POST /v2/sessions/{id}/close`](/api/endpoints/sessions-close): `ended_at` and `exit`
  are set, with `exit.type` `account` or `attendant`;
- an LPR camera matches a vehicle leaving to an open session: `ended_at` is set,
  `possibly_exited` is `true` and `exit.type` is `lpr_camera`.

### `lpr_camera_status`

An LPR camera reported its status. It's sent for every camera in each report, even when
the status didn't change, so keep the last value per camera. Not every camera model
sends it.

```ts title="lpr_camera_status"
interface LprCameraStatus {
  id: number;
  device_id: string;
  status: 'online' | 'offline';
}
```

```json title="lpr_camera_status"
{ "id": 15, "device_id": "L123456789", "status": "online" }
```

## Occupancy

A parking-detection sensor that changes state sends three events, in this order:
`lot_detection_change`, `zone_change`, then `parking_place_change`. Occupied counts
also change when LPR sessions start and end, and those changes **don't** send any
event; re-read the lot or zone over REST if you need exact counts.

### `lot_detection_change`

```ts title="lot_detection_change"
interface LotDetectionChange {
  lots: [
    {
      id: number; // lot id
      detection_status: 'online';
      occupied_count: number;
    },
  ]; // always one element
}
```

```json title="lot_detection_change"
{ "lots": [{ "id": 7, "detection_status": "online", "occupied_count": 42 }] }
```

### `zone_change`

```ts title="zone_change"
interface ZoneChange {
  zones: [
    {
      id: number;
      account_id: number;
      lot_id: number;
      name: string;
      description: string | null;
      spot_count: number;
      occupied_count: number;
      deleted_at: string | null;
      created_at: string;
      updated_at: string;
    },
  ]; // always one element
}
```

```json title="zone_change"
{
  "zones": [
    {
      "id": 9,
      "account_id": 3,
      "lot_id": 7,
      "name": "Level -1",
      "description": null,
      "spot_count": 120,
      "occupied_count": 42,
      "deleted_at": null,
      "created_at": "2026-01-10T10:00:00.000Z",
      "updated_at": "2026-03-02T08:12:40.000Z"
    }
  ]
}
```

### `parking_place_change`

```ts title="parking_place_change"
interface ParkingPlaceChange {
  places: [
    {
      id: number;
      lot_id: number;
      zone_id: number;
      name: string | null;
      occupancy_status: 'free' | 'occupied';
    },
  ]; // always one element
}
```

```json title="parking_place_change"
{
  "places": [
    { "id": 301, "lot_id": 7, "zone_id": 9, "name": "Spot 12", "occupancy_status": "occupied" }
  ]
}
```

### `parking_gateway_state_change`

A parking-detection gateway (not a product Gateway) went online or offline. Not every
parking-detection gateway model sends it.

```ts title="parking_gateway_state_change"
interface ParkingGatewayStateChange {
  gateways: [{ id: number; status: 'online' | 'offline' }]; // always one element
}
```

```json title="parking_gateway_state_change"
{ "gateways": [{ "id": 4, "status": "offline" }] }
```

## Legacy

### `lpr_event`

:::warning Legacy
Use [`plate_reading_create`](#plate_reading_create) and
[`plate_reading_update`](#plate_reading_update) instead. `lpr_event` is still sent, but
lot entries are deprecated.
:::

Sent when a plate reading with a known direction, not debounced, is recorded as a lot
entry. An `out` entry that matches an earlier `in` has `related_lot_entry` and
`duration` set.

```ts title="lpr_event"
interface LprEvent {
  lot_id: number;
  data: LotEntry & {
    lpr_camera: {
      id: number;
      device_id: string;
      device_serial: string;
      name: string;
      account_id: number;
      zone_id: number;
      status: 'online' | 'offline' | 'auth' | 'sleep' | 'reset';
      lpr_position: 'in' | 'out' | 'in/out';
      connected_device_type: 'gate' | 'chain' | 'bollard' | 'other' | 'standalone';
      mac: string;
      deleted_at: string | null;
      created_at: string | null;
      updated_at: string | null;
    };
    related_lot_entry:
      | (LotEntry & { lpr_camera: { id: number; device_id: string; name: string } })
      | null;
  };
}

interface LotEntry {
  id: number;
  scan_time: string;
  lpr_camera_id: number;
  lot_id: number;
  related_lot_entry_id: number | null;
  duration: number | null; // seconds; out entries only
  is_allowed: 0 | 1;
  direction: 'in' | 'out';
  license_plate: string;
  license_plate_image_url: string | null; // a storage key, not a usable URL
  full_image_url: string | null; // a storage key, not a usable URL
  make: string; // "" when unknown
  color: string; // "" when unknown
  model: string; // "" when unknown
  color_depth: number | null;
  created_at: string;
  updated_at: string;
}
```

```json title="lpr_event"
{
  "lot_id": 7,
  "data": {
    "id": 320,
    "scan_time": "2026-03-02T08:12:40.000Z",
    "lpr_camera_id": 15,
    "lot_id": 7,
    "related_lot_entry_id": null,
    "duration": null,
    "is_allowed": 1,
    "direction": "in",
    "license_plate": "ZG1234AB",
    "license_plate_image_url": null,
    "full_image_url": null,
    "make": "",
    "color": "",
    "model": "",
    "color_depth": 1,
    "created_at": "2026-03-02T08:12:40.000Z",
    "updated_at": "2026-03-02T08:12:40.000Z",
    "lpr_camera": {
      "id": 15,
      "device_id": "L123456789",
      "device_serial": "123456789",
      "name": "Entrance camera",
      "account_id": 3,
      "zone_id": 9,
      "status": "online",
      "lpr_position": "in",
      "connected_device_type": "standalone",
      "mac": "AA:BB:CC:DD:EE:FF",
      "deleted_at": null,
      "created_at": "2026-01-10T10:00:00.000Z",
      "updated_at": "2026-01-10T10:00:00.000Z"
    },
    "related_lot_entry": null
  }
}
```

## Next

- [Patterns](/realtime/patterns): correlating results, deduplicating, resyncing.
- [Asynchronous operations](/concepts/asynchronous-operations)
