Skip to content

← 8. The dashboard · Contents · Next → 10. Security

9. The API

In production, access rights are not typed by hand: an existing booking system pushes them into wardn. That is what the API is for.

make api                          # authentication, confinement, provisioning, audit
open http://localhost:3000/docs   # the interactive OpenAPI reference

The generated reference at /docs is always exactly what the code serves. The same document, exported to a file, is also embedded in this site: see the OpenAPI reference. This chapter is the part a generated document cannot tell you: the rules, the guarantees, and what to do with them.

Everything lives under /api/v1, except the two health probes.

Authenticating

Every route requires an identity except the health probes.

flowchart TD
    REQ["Incoming request"] --> RL{"Within the<br/>rate limit?"}
    RL -->|"no"| T429["429 Too Many Requests"]
    RL -->|"yes"| H{"Which credential?"}
    H -->|"X-API-Key"| K["Find the row by its indexed prefix,<br/>then verify the bcrypt hash"]
    H -->|"Authorization: Bearer"| J["Verify the OIDC<br/>token signature"]
    H -->|"neither"| U401["401 Unauthorized"]
    K --> KP["API key: confined to ONE workspace"]
    J --> JP["Person: sees ALL workspaces"]
    KP --> SCOPE["Every query takes the caller's scope"]
    JP --> SCOPE

An OIDC token, for a person

Authorization: Bearer <access token>

A person sees every workspace. That is the dashboard operator, who supervises the whole estate.

The local stack's default provider (Keycloak) allows the password grant below for this convenience. Not every provider does. See 10. Security.

TOKEN=$(curl -s -X POST \
  http://localhost:8080/realms/wardn/protocol/openid-connect/token \
  -d grant_type=password -d client_id=wardn-spa \
  -d username=operator -d password=operator \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')

curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/workspaces

Signing keys are found through OIDC discovery and cached, so a key rotation is picked up without a restart and wardn never holds a signing secret.

An API key, for a machine

X-API-Key: wapi_dev_acme_0000000000000001

A key is confined to a single workspace. Always.

curl -H "X-API-Key: wapi_dev_acme_0000000000000001" \
     http://localhost:3000/api/v1/zones

Why the asymmetry? The two populations do not have the same need. An operator supervises a multi-customer fleet. A booking system at Acme has no reason to see Northwind. Modelling them as one type with an optional workspace would make confinement easy to forget, which is exactly what the code avoids: two distinct types, and every query has to say which scope it applies to.

What roles change

Almost nothing, and that is deliberate.

Caller Reach
Any valid realm token Every workspace, every action except one
A token holding wardn-admin Adds POST /devices/{id}/actions/ota
A workspace API key One workspace, and not POST /maintenance/retention

POST /maintenance/retention is closed to API keys not because of a role but because the sweep is fleet-wide: an action that reaches across every workspace must not be reachable by a caller confined to one, whatever it is allowed to do inside its own.

Confinement in practice

# With Acme's key, only Acme's zones come back.
curl -H "X-API-Key: wapi_dev_acme_0000000000000001" \
     http://localhost:3000/api/v1/zones

# Asking for a Northwind resource with Acme's key: 404.
curl -H "X-API-Key: wapi_dev_acme_0000000000000001" \
     http://localhost:3000/api/v1/zones/0b000000-0000-4000-8000-000000000003

404, not 403. Answering "forbidden" would confirm the resource exists. For a client, another workspace's resource does not exist. A status that distinguishes "does not exist" from "not yours" lets a client map what it cannot read.

The filter is applied inside the query, not after it, so a caller can never be handed a row it should not have seen.

The endpoints

Health, no authentication

Method Path Answer
GET /health {status, instance}: the process is up, and which instance answered
GET /health/ready {status}: the database actually answers, or 503

instance matters behind a load balancer: it is the difference between "the backend is misbehaving" and "one of them is".

Workspaces

Method Path Notes
GET /workspaces Paginated
GET /workspaces/{id}
POST /workspaces {name, description?, defaultTimezone?}
PATCH /workspaces/{id} {name?, description?, defaultTimezone?}. Signed-in operators only — a key acts inside a workspace, it does not edit one
DELETE /workspaces/{id} 204. Signed-in operators only. Refused while it still holds a zone, a user or an API key
GET /workspaces/{id}/api-keys Never reveals a secret
POST /workspaces/{id}/api-keys {name, expiresAt?}: the plaintext is in this response and nowhere else. Signed-in operators only — an API key cannot issue another
DELETE /workspaces/{id}/api-keys/{keyId} 204, effective immediately. Signed-in operators only, like issuing

Zones

Method Path Notes
GET /zones Paginated
GET /zones/{id}
POST /zones {workspaceId, name, description?, externalSiteId?, timezone?}

timezone is an IANA zone and is what every instant belonging to this site is rendered in. Left unset, a zone inherits its workspace's defaultTimezone.

Hardware

Method Path Notes
GET /doors Filters: zoneId, controllerId
GET /doors/{id}
POST /doors {controllerId, name, direction, mode?, relayPulseMs?, osdpAddress?}
DELETE /doors/{id} 204. Refused while it still holds a reader
PATCH /doors/{id} {mode}: normal · unlocked · free_access · locked. Stored here and pushed to the controller. osdpAddress too
POST /doors/{id}/actions/open Opens it now
POST /doors/{id}/actions/osdp {output, value}, when this door has its own osdpAddress
GET /doors/{id}/osdp-report What this door's own node last answered about itself
GET /identification-devices Filters: doorId, zoneId
GET /identification-devices/{id}
POST /identification-devices {doorId, name, readerFormatId, wiring} plus that wiring's own parameters: osdpAddress for bus, wiegandD0+wiegandD1 for wiegand, none for onboard. ledGpio?, buzzerGpio? alongside
DELETE /identification-devices/{id} 204
PATCH /identification-devices/{id} {name?, description?, ledGpio?, buzzerGpio?}. Naming wiring rewrites it whole, parameters included; leaving it out leaves every one of them alone
POST /identification-devices/{id}/actions/osdp {output, value}, when this reader's wiring is bus. See 7. The fleet
GET /identification-devices/{id}/osdp-report What this reader last answered about itself

Fleet

Method Path Notes
GET /devices Paginated. Filter: zoneId
GET /devices/fleet One row per controller with its last report attached
GET /devices/{id}
POST /devices {zoneId, deviceIdentifier, name}. A row only, status provisioning. The certificate is issued separately
DELETE /devices/{id} 204. Refused while it still holds a door
PATCH /devices/{id} {name?, description?, customNotes?}. Wiring facts are set at provisioning, not edited here
GET /devices/{id}/heartbeat The last report. 404 if it has never reported
GET /devices/{id}/telemetry System-metrics history. from/to (ISO 8601, both optional)
GET /devices/{id}/commissioning-report The controller's wiring as a PDF, with a box to tick per value an installer confirms on site
GET /devices/{id}/setup What the cloud knows of the controller's /etc/wardn/edge.env: its identity, the broker as a controller reaches it, and the OTA key. Holds no secret
GET /devices/{id}/links Router admin pages, monitoring boards
GET /devices/{id}/documentation Wiring diagrams, guides
POST /devices/{id}/actions/log-level {level}: applied without restarting
POST /devices/{id}/actions/debug {enabled, durationSeconds?}: capped
POST /devices/{id}/actions/full-resync Reload the whole rights set
POST /devices/{id}/actions/restart Restart the controller service
POST /devices/{id}/actions/reboot Reboot the machine
POST /devices/{id}/actions/ota {version}: wardn-admin only
GET /firmware Published releases, newest first, with their digests. Paged. q searches the version
GET /firmware/{version} One release, read straight from its manifest
GET /alerts limit up to 200, most recent first

All the actions endpoints are asynchronous: they return once the command has been published. The controller's acknowledgement lands in the technical journal, and the next heartbeat is what proves it applied.

commissioning-report is the one endpoint here that answers with application/pdf rather than JSON, as an attachment named after the device identifier. It is generated on demand and stored nowhere: it describes what the database holds right now, so a copy kept on a server could only ever describe a cabinet as it used to be.

setup is deliberately short of a file. The file holds a database key and, on a plain listener, a broker password, and neither may reach the server. The dashboard assembles it in the browser from these values and what is typed there.

Breaking change. GET /devices/{id}/telemetry and the telemetry field on /devices/fleet were renamed to heartbeat, to free the name "telemetry" for the historized metrics stream now served at GET /devices/{id}/telemetry?from=&to= — a different shape (a list of samples, not the latest report) at the same path the old endpoint used to occupy. Any integration reading either must be updated; there is no alias.

Identity and rights

Method Path Notes
GET /users Paginated. q searches name, description and external subject id
GET /users/{id}
POST /users {workspaceId, name?, description?, externalSubjectId?}
DELETE /users/{id} 204. Refused while they still hold an access right, revoked ones included — erase them instead
GET /users/{id}/personal-data Everything held about one person
DELETE /users/{id}/personal-data Erase them, anonymise their passages
GET /access-rights Filters: zoneId, userId, activeOnly. q searches the holder's name or any credential value
GET /access-rights/{id}
POST /access-rights Creates a right and its credentials
POST /access-rights/{id}/credentials Supersedes it with new credentials
DELETE /access-rights/{id} 204, revokes it
POST /provisioning/access-rights Idempotent create-or-replace

name is optional: a visitor may legitimately be known only by the QR code they were sent.

Test wallet

Method Path Notes
GET /test-wallet-candidates?workspaceId= Who a wallet can carry: {capacity, structure, people: [{id, name, credentials, bytes}]}. No credential value. Not open to an API key
POST /test-wallet-exports {workspaceId, userIds?}. Not open to an API key. 422 when the wallet does not fit in one QR code

The people of a workspace, what each of their rights grants, and the credentials a phone can present for it (badge number, QR code, PIN code, mobile ID), in clear text, packed into the content of one QR code for a test phone to scan. Every current right is included, denied and expired ones too, since watching a controller refuse them is half of what the phone is for. Replaced and revoked rights are not.

userIds narrows the export to some of the people, everyone when it is absent. An id from another workspace carries no one. The readers are described from every credential of the workspace all the same, so narrowing the people never changes what a door is said to read.

structure is the size of a wallet holding no one: the controllers, zones, doors and readers. A candidate's bytes is what they add to it alone. Together people add less than the sum, since the compression shares what repeats, so structure plus the selected bytes is an upper bound.

The zones come with their doors and each door's mode. Every mode but normal answers regardless of the right, so the phone needs it to tell what a door should do with what it presents.

Each door also carries its controller and its readers. A reader comes with its wiring, written the way a simulated frame names it, and with reads: each credential type its format decodes, and which frame gets it there. raw is the value itself, pin is PIN: and the value, qr is QR and the value, and stamped is the hex of the badgeless reader's QR:, BLE: or NFC: stamp and the value. The server finds them by running the reader's own format on a frame of each kind, and keeps a type only when the value decoded is the one the controller would look up. It tries one value per type, taken from the workspace's own credentials, so a format that keeps only digits reads numeric badges and not BDG-1000, depending on which one it was tried with. A type missing from reads is one no frame gets there.

controllers gives each controller's LAN address from its last heartbeat and its SIMULATION_LISTEN, null when that port is closed. That is where the phone sends a simulated read.

The response carries qr, people and credentials. qr is the bytes the QR code holds, in base64: the ASCII prefix WARDN-WALLET:2:, then the wallet in the compact layout below, raw-deflated with no zlib header. They go into the code as one byte segment rather than as text, which base64 would make a third larger. The wallet, field by field:

{"workspace": "Gondor", "exportedAt": "2026-10-08T09:00:00.000Z",
 "controllers": [{"name": "Minas Tirith — main gate", "address": "192.168.1.20", "simulation": "0.0.0.0:9000"}],
 "zones": [{"name": "Minas Tirith",
            "doors": [{"name": "Great Gate — entry", "mode": "normal",
                       "controller": "Minas Tirith — main gate",
                       "readers": [{"name": "Gate reader", "wiring": "bus:1",
                                    "reads": {"badgeNumber": "raw"}}]},
                      {"name": "Postern gate", "mode": "unlocked",
                       "controller": "Minas Tirith — main gate",
                       "readers": [{"name": "Postern gate — badgeless reader", "wiring": "bus:15",
                                    "reads": {"badgeNumber": "raw", "qrCode": "stamped",
                                              "pinCode": "pin", "mobileId": "stamped"}}]}]}],
 "people": [{"name": "Frodo Baggins",
             "rights": [{"zone": "Minas Tirith", "granted": true,
                         "validFrom": "2025-10-08T09:00:00.000Z", "validTo": "2027-10-08T09:00:00.000Z",
                         "credentials": [{"type": "mobileId", "value": "FRODO-PHONE"}]}]}]}

The layout trades those field names for positions. Names stay text, everything that repeats becomes an index, and instants become seconds from the export:

[workspace, exportedAt,
 controllers: [[name, address, simulation]],
 profiles:    [[encoding per type, 0 for none]],
 zones:       [[name, [[door, mode, controller, [[reader, wiring, profile]]]]]],
 people:      [[name, [[zone, granted, validFrom, validTo, [[type, value]]]]]]]

Types are numbered badgeNumber, qrCode, pinCode, mobileId from 0, modes normal, unlocked, free_access, locked from 0, and encodings raw, pin, qr, stamped from 1. A reader points at a profile, since most readers of a workspace share one format. backend/src/test-wallet/wallet-codec.ts writes it, the app reads it, and both test against the same fixture, so neither can drift alone.

Why a POST for a read. The audit trail records what changes state, and so it records only what is not a GET. An export that hands every credential of a workspace over in clear text is exactly what the trail has to keep, with the name of who asked. It is also why the path is its own resource rather than a workspace's subpath: the trail names an action after the first segment, and POST_TEST-WALLET-EXPORTS says what happened where POST_WORKSPACES would not.

A QR code holds at most 2953 bytes at the lowest error correction. The seeded Gondor workspace takes about 900 of them.

What the phone then does with those credentials at a door is in the phone reference.

Activity

Method Path Filters
GET /activity-logs zoneId, doorId, controllerId, level, deniedOnly, from, to, plus pagination

Instants in the response are UTC. Each row carries zoneTimezone, which is the zone it should be rendered in.

Maintenance

Method Path Notes
POST /maintenance/retention Runs the purge now, or 409 while a sweep already holds the lease. Idempotent. Not open to an API key

Provisioning

POST /api/v1/provisioning/access-rights is the front door for integrations, one right per call.

curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
     -d '{
           "userId":     "0f000000-0000-4000-8000-000000000001",
           "zoneId":  "0b000000-0000-4000-8000-000000000001",
           "granted":    true,
           "validFrom":  "2026-08-11T07:00:00Z",
           "validTo":    "2026-08-11T19:00:00Z",
           "externalId": "booking-88213",
           "credentials":[{"type":"qrCode","value":"QR-VISIT-0042"}]
         }' \
     http://localhost:3000/api/v1/provisioning/access-rights

It is idempotent on (zoneId, externalId): the identifier the caller uses on its own side. Replaying the same request returns the same right instead of creating a second one.

An integrator that no longer knows what it has already sent can therefore resend everything without breaking anything, which is the normal operating mode of a booking system.

If the credentials differ, the existing right is superseded rather than edited, and the externalId follows the new revision. Differ means the set of them differs, compared in canonical form: sending the same credential twice, or writing one of them differently, asks for nothing a scan could tell apart and supersedes nothing. See 5. Access rights.

Omitting externalId makes the call an ordinary create, with no idempotency.

The fields

Field Required Notes
userId yes Must belong to the caller's workspace
zoneId yes Must belong to the caller's workspace
granted no, default true false is an explicit denial, which is not the same as no right
validFrom / validTo yes ISO 8601. Inclusive / exclusive. validTo must be after validFrom
sourceTimezone no The zone the window was expressed in, kept for display
externalId no The idempotency key
metadata no Free-form JSON, yours
credentials yes, at least one [{type, value}]

credentials[].type is free-form. Readers in the field produce vocabularies nobody controls, so a closed enum would mean a migration for each new reader.

Use text or number for an identification number whose structure wardn cannot interpret: unlike every other type, these two are matched exactly as given, with no trimming and no case-folding. See 5. Access rights.

Errors

Every failure is application/problem+json (RFC 9457), with the same shape:

{
  "type": "urn:wardn:problem:not-found",
  "title": "not found",
  "status": 404,
  "detail": "no zone 0b000000-0000-4000-8000-000000000003",
  "instance": "/api/v1/zones/0b000000-0000-4000-8000-000000000003",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

detail is the sentence written for a human, and the one the dashboard shows. On a validation failure it is joined from the individual messages, which are also listed in errors.

Anything unrecognised is a bug on our side, and the client learns nothing about it beyond the status.

Status When
400 The payload failed validation, or a cursor is malformed
401 No credential, or one that does not verify
403 wardn-admin required, or a workspace key on a fleet-wide action
404 Does not exist, or belongs to another workspace
409 Superseding a right that is not the version in force, one another request just superseded included
429 Past the rate limit
500 A bug here
503 A dependency did not answer (object storage, the database on /health/ready)

Unknown properties in a request body are rejected, not ignored: a typo in a field name is a silent no-op otherwise.

Clearing a field

A PATCH distinguishes a field left out from a field sent as null. Leaving it out keeps what the row holds; sending null erases it.

# Rename, and keep the note that is already there.
curl -X PATCH ... -d '{"name": "North gate"}'

# Keep the name, and remove the note.
curl -X PATCH ... -d '{"customNotes": null}'

Only fields that are genuinely optional can be cleared — a description, a custom note, a zone's external site id, a document's file size, a door's relay terminal or OSDP address. A field a row cannot do without, such as a name, keeps its value: null there is ignored, not an erasure.

Pagination

Collections answer:

{ "items": [ … ], "nextCursor": "MjAyNi0wOC0xMFQwNzoxNDoyMi4xMDRafDBkNWI…" }

Pass nextCursor back as cursor to get the next page. Its absence means this was the last one.

limit defaults to API_DEFAULT_PAGE_SIZE (50) and is capped at API_MAX_PAGE_SIZE (500), so a caller cannot ask for a million rows at once.

Why a cursor rather than an offset? Rows are inserted constantly here. An offset would silently skip or repeat entries as the table grows underneath a client walking it. The cursor is a keyset position: an instant and an id, which is stable whatever happens around it.

GET /firmware pages the same way, over the object store rather than a table: its cursor is the version the next page starts after, and only the manifests of the page you ask for are read. A release deleted between two pages shifts nothing, for the same reason a keyset cursor never does.

Its q searches the version, and only the version — a substring, case insensitive, so rc and 0.3 both work. That limit is the same trade: which versions exist is a listing of short names, while channel, target and commit each live inside a manifest, and matching on one of those would mean reading every manifest in the bucket to answer for a single page. q is applied before the cursor, so paging walks the matches rather than the whole catalogue.

curl -H "Authorization: Bearer $TOKEN" \
  'http://localhost:3000/api/v1/activity-logs?deniedOnly=true&limit=100'

Rate limiting

A global ceiling applies before authentication: RATE_LIMIT_MAX (3000) requests per RATE_LIMIT_WINDOW_MS (60 000 ms).

Why before? Verifying a key costs a deliberately slow hash, and verifying a token costs a signature check. Without a ceiling, an unauthenticated caller can spend that work at will.

The live socket carries its own, lower ceiling: WS_HANDSHAKE_LIMIT (60) per address per window, because the upgrade is handled below the backend's routing and the first guard never sees it. A dashboard opens two sockets and keeps them. Anything approaching that rate is a client in a reconnect loop or somebody trying keys.

Tracing

Every response carries X-Trace-Id, and every error carries the same id in its body.

curl -i -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
     -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/zones

The backend joins the caller's trace rather than starting its own, so an id minted by a gateway or by a test survives the whole way through.

An operator saying "it failed around two" gives support a haystack. The same operator quoting a trace id gives them the request, its queries and its broker calls.

Spans are created whether or not anything collects them, which is what keeps the id on every response. Set OTEL_EXPORTER_OTLP_ENDPOINT to export them, or OTEL_TRACES_CONSOLE=true to print them next to the logs.

Everything that changes state is audited

Every non-GET request is recorded in control_plane_audit_logs with the caller's identity (person or key), the action, the target, the request payload and the source address. The row is written before the response goes out, so an action a caller was told succeeded is already in the trail.

This is driven by the HTTP method and registered globally, not by a decorator on each route: an action is otherwise audited only if somebody remembered to mark it, and the ones people forget are exactly the ones worth having in the trail.

Fields whose name contains password, secret, token, key or apiKey are replaced by [redacted].

A refused request is recorded too, at warn and with the status it was refused with in changes.refusedWith. Who tried is a question the trail exists to answer, and the level is what keeps the attempts from burying the actions that did happen: WHERE level = 'info' reads the trail exactly as it read before.

What lands there are the refusals a route's handler raised — the workspace-confinement 404s above among them, which is the case worth having. Authentication, the rate limiter and the @AllWorkspaces guard turn a request away before any interceptor runs, so a 401, a 429 and that one 403 leave no row at all.

A failed write to this trail is the one failure the trail cannot report, and its usual cause is the database that would have to hold the report. It goes to Sentry rather than to a log line nobody reads.

That trail is immutable at the database level: the application role has neither UPDATE nor DELETE on it. See 11. Personal data.

Managing API keys

curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"acme-booking-connector","expiresAt":"2027-01-01T00:00:00Z"}' \
  http://localhost:3000/api/v1/workspaces/$WORKSPACE/api-keys

The response contains plainKey. That is the only time it exists. Only a bcrypt hash is stored, and it is computed inside PostgreSQL so the plaintext never travels further than that response.

A key looks like wapi_live_ followed by 24 random bytes in base64url. The first sixteen characters are stored in the clear and indexed.

flowchart LR
    K["wapi_live_AbCdEf…"] --> P["first 16 characters<br/>indexed lookup"]
    P --> ROW["exactly one row"]
    ROW --> H["bcrypt compares<br/>the full key"]
    H --> OK["accepted / refused"]

Why that detail matters. Without a usable prefix, verifying a key means comparing it against every active key, each at the price of a deliberately slow hash. At a hundred customers that is a hundred computations per authenticated request. And since an invalid key is only known to be invalid after the last comparison, an unauthenticated caller could set that work going at will.

The prefix narrows. It never decides. Somebody who guesses one gains a row lookup and still has to produce the key.

A key can carry an expiry, and revoking it takes effect immediately.

The live socket

Two paths, both taking the same credentials as the REST surface.

Path Who What it carries
/ws/activity Person or API key ready, activity, fleet messages
/ws/debug?device=edge-01 Person only ready, debug messages from that one controller
wscat -c "ws://localhost:3000/ws/activity?token=$TOKEN"
wscat -c "ws://localhost:3000/ws/activity?apiKey=$KEY"

A browser cannot set headers on a WebSocket handshake, so a credential travels in the query string one way or another, and the connection is expected to be TLS in production. token/apiKey above is the direct route: fine for a script that already holds the credential in hand, like wscat.

The dashboard takes a different one. A long-lived bearer token sitting in a URL is also sitting in every proxy and load balancer's access log between the browser and the backend, so it mints a ticket first, over a normal authenticated HTTPS request, and puts that in the URL instead:

curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/realtime/tickets
# {"ticket":"…","expiresAt":"…"}
wscat -c "ws://localhost:3000/ws/activity?ticket=$TICKET"

A ticket is good for a few seconds and for one handshake, spent on first use, valid or not. Whatever a proxy logs about the connection attempt is not something a second attempt can reuse.

Messages are filtered per subscriber as they are sent, with the same workspace confinement the REST surface applies.

The debug console is closed to API keys (403): raw frames carry no workspace, and confining what cannot be confined is worse than saying no.

The socket keeps no history. On attach, re-read what you missed over REST.

A worked integration

# 1. Get a key for your workspace (once, by an operator).
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"booking-connector"}' \
  http://localhost:3000/api/v1/workspaces/$WORKSPACE/api-keys

# 2. Find the zone you are provisioning for.
curl -H "X-API-Key: $KEY" http://localhost:3000/api/v1/zones

# 3. Create the person, if they are new to you.
curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"workspaceId":"'$WORKSPACE'","name":"Sophie Martin"}' \
  http://localhost:3000/api/v1/users

# 4. Provision the right. Replay this as often as you like.
curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"userId":"'$USER'","zoneId":"'$ZONE'",
       "validFrom":"2026-08-11T07:00:00Z","validTo":"2026-08-11T19:00:00Z",
       "externalId":"booking-88213",
       "credentials":[{"type":"licensePlate","value":"1-ABC-123"}]}' \
  http://localhost:3000/api/v1/provisioning/access-rights

# 5. Read back what happened at the doors.
curl -H "X-API-Key: $KEY" \
  'http://localhost:3000/api/v1/activity-logs?zoneId='$ZONE'&limit=100'

The right reaches the controller within seconds of step 4, and works at the door even if the site loses its uplink immediately afterwards.


The interface is understood. Let us look at what protects the whole.

Next → 10. Security