# Plate-based access

> Let known vehicles in by their license plate: an LPR camera reads the plate, Parklio
> checks it against your **lot whitelist**, and opens the way. This guide sets up the
> whitelist, binds plates to products, and shows how to follow each decision.

## Prerequisites

- An API token; see [Authentication](/api/authentication).
- An LPR camera installed and assigned to a **zone** of the lot. Readings from a camera
  with no zone are rejected.
- For products to open: the product in the **same zone** as the camera, paired, and
  seen by an online Gateway. See [Remote control](/guides/remote-control).

## What happens on a read

1. The camera reads a plate and Parklio records a **plate reading**
   (`plate_reading_create` in realtime).
2. If the same plate (give or take one character) was read in the same zone in the last
   20 seconds, the reading is denied with reason `debounce` and nothing else happens.
3. Parklio looks for an **active** whitelist entry for the plate on the camera's lot,
   whose window includes now. Matching tolerates one misread character by default.
4. **On the whitelist:**
   - the decision is `triggered`, reason `whitelist`;
   - the camera's own output is triggered (for a gate or barrier wired to the camera);
   - for a vehicle coming **in**, Parklio opens the first **bound** product in the
     camera's zone that is **online and closed**, through a Gateway;
   - in starts a [session](/guides/sessions-and-tariffs), out ends it.
5. **Not on the whitelist:** [tariffs](/guides/sessions-and-tariffs) decide.
6. The decision is recorded on the reading (`plate_reading_update` in realtime).

A plate opens a bound product at most once every **2 minutes**, so a camera reading the
same car twice doesn't open two barriers.

## 1. Add plates to the whitelist

[`POST /v2/lot-whitelists`](/api/endpoints/lot-whitelist-create) adds up to 20 plates to
a lot for a time window. Always send `lot_id`.

```bash title="Whitelist two plates for March"
curl -s -X POST https://api.parklio.com/v2/lot-whitelists \
  -H "Authorization: Bearer $PARKLIO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "lot_id": 7,
    "name": "Tenants",
    "license_plates": ["ZG1234AB", "ST-555-CD"],
    "start_time": "2026-03-01T00:00:00.000Z",
    "end_time": "2026-04-01T00:00:00.000Z"
  }'
```

- Plates are 3–16 characters. Parklio stores them in uppercase, without hyphens:
  `ST-555-CD` becomes `ST555CD`.
- Send **either** `end_time` **or** `"is_unlimited": true`, not both.
- `end_time` must be in the future, and after `start_time`.

The response has one item per plate. Keep each **`license_id`**: you need it to bind
the plate to products.

```json title="Response"
{
  "success": true,
  "data": [
    { "success": true, "whitelist_id": 501, "license_id": 88, "plate": "ZG1234AB", "is_unlimited": 0 },
    { "success": true, "whitelist_id": 502, "license_id": 89, "plate": "ST555CD", "is_unlimited": 0 }
  ]
}
```

A plate that couldn't be added has `"success": false`; check every item.

For many plates, upload a CSV to
[`POST /v2/lot-whitelists/upload/lot/{id}`](/api/endpoints/lot-whitelist-create-from-file);
[`GET /v2/lot-whitelists/sample/export`](/api/endpoints/lot-whitelist-export-sample-csv)
gives you the format.

Entries become `expired` (checked hourly) after `end_time`. To remove access earlier,
delete the entry with [`DELETE /v2/lot-whitelists/{id}`](/api/endpoints/lot-whitelist-remove).
[`GET /v2/lot-whitelists`](/api/endpoints/lot-whitelist-find-all) lists entries, with
their `license_plate_id` (the same as `license_id`) and `bound_products`.

## 2. Bind plates to products

Binding tells Parklio which products a plate may open. Use the `license_id` values from
step 1:

```bash title="Bind two plates to product 123"
curl -s -X POST https://api.parklio.com/v2/products/123/bind-licenses \
  -H "Authorization: Bearer $PARKLIO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"license_ids": [88, 89]}'
```

To bind the same plates to several products, use
[`POST /v2/products/bind-licenses`](/api/endpoints/products-bind-license-for-products)
with `product_ids` and `license_ids`.

:::warning Unknown ids are ignored silently
The bind call succeeds even if a `license_id` doesn't exist. Check the result with
[`GET /v2/products/{id}/bound-licenses`](/api/endpoints/products-find-all-binds).
:::

A binding belongs to the **plate**, not to one whitelist entry: it stays when an entry
expires, and applies again when you whitelist the plate later. It only opens anything
while the plate is on an active whitelist entry. Remove a binding with
[`DELETE /v2/products/{id}/bound-licenses/{licenseId}`](/api/endpoints/products-unbind-license-for-product).

A plate on the whitelist but bound to nothing still gets `triggered` and starts a
session; only the bound-product step is skipped.

## 3. Follow decisions

Each reading ends with an `access_decision` and an `access_decision_reason`:

| Decision | Reason | When |
| --- | --- | --- |
| `triggered` | `whitelist` | Active whitelist entry on the lot. |
| `denied` | `debounce` | Same plate, same zone, within 20 seconds. |
| `triggered` | `tariff`, `session_exit` | Let in by a tariff, or out with an open session. See [Sessions and tariffs](/guides/sessions-and-tariffs). |
| `denied` | `unknown_direction`, `direction_mismatch`, `no_tariff`, `max_capacity`, `no_active_session` | Not whitelisted, and no tariff or session allows it. |

In realtime, listen for `plate_reading_update`; its `access_decision` is set (see
[Events](/realtime/events#plate_reading_update)). Over REST, filter
[`GET /v2/plate-readings`](/api/endpoints/plate-readings-find-all):

```bash title="Denied readings at lot 7"
curl -s "https://api.parklio.com/v2/plate-readings?filter=lot_id:eq:7&filter=access_decision:eq:denied&sort=created_at:desc" \
  -H "Authorization: Bearer $PARKLIO_API_TOKEN"
```

Also filterable: `plate`, `zone_id`, `lpr_camera_id`, `direction`,
`access_decision_reason` and `created_at`.

When a bound product opens, its result arrives as a `lot_change`, with an `event_id`
you didn't request. If it fails, a `gateway_error` arrives, but the reading still says
`triggered`: the decision is about the plate, not the product.

## Verify it worked

1. Drive a whitelisted car past the camera, going in.
2. `plate_reading_create` arrives, then `plate_reading_update` with
   `access_decision: "triggered"` and reason `whitelist`.
3. A `lot_change` shows the bound product `opened`, and a `session_create` arrives.
4. [`GET /v2/products/{id}/bound-licenses`](/api/endpoints/products-find-all-binds)
   lists the plate.

## Common errors

| Symptom | Cause | Fix |
| --- | --- | --- |
| Reason `no_tariff` or `no_active_session` for a plate you whitelisted | No active entry on **this lot** right now, or the plate was read differently. | Check the entry's `lot_id`, `status` and window; compare `plate` on the reading. |
| `triggered` but the product didn't open | Not bound, not in the camera's zone, offline, already open, or opened for this plate in the last 2 minutes. | Check the binding, the product's `zone_id`, `status` and state. |
| Reason `debounce` | The camera read the same plate within 20 seconds. | Expected. |
| A plate item with `"success": false` | That plate couldn't be added. | Check its length and the window. |
| `403` code `402` | Neither `end_time` nor `is_unlimited`. | Send one of them. |
| `403` code `403` | `end_time` is in the past. | Use a future `end_time`. |

## Next

- [Sessions and tariffs](/guides/sessions-and-tariffs): what happens to cars that
  aren't whitelisted.
- [Events](/realtime/events#plate_reading_create)
