API Documentation
v1 — read-only access for ePRO and other consumers
v1 endpoints are live
All read endpoints are deployed. Each request requires an active API key in the X-Api-Key header.
POST apiPing
Connectivity check — call this first to confirm your key works
POST /functions/apiPing
X-Api-Key: <your-api-key>
Response:
{
"ok": true,
"tenant_id": "...",
"tenant_name": "Acme Parking",
"key_name": "ePRO Production",
"key_prefix": "pk_a1b2c3d4",
"consumer": "ePRO",
"server_time": "2026-05-21T03:00:00.000Z"
}Authentication
Every request must carry a tenant-scoped API key in the X-Api-Key header:
X-Api-Key: <your-api-key>
Keys are issued by the ParkOS operator. Each key is tenant-scoped — requests can only access data belonging to the tenant that owns the key. Revoked keys return 401 revoked_api_key.
Endpoint URLs
v1 endpoints are exposed as Base44 backend functions. The base URL is:
https://getparkos.base44.app/functions/<functionName>
A friendlier /api/v1/* alias may be added later; the function URLs are stable.
POST apiComplianceCheck
The headline endpoint — call on every LPR read
Request:
POST /functions/apiComplianceCheck
Content-Type: application/json
X-Api-Key: <your-api-key>
{
"plate": "ABC123",
"location_ref": "L-0001" // optional — external_ref or internal_ref
}Compliant response (active permit):
{
"compliant": true,
"reason": "permit",
"permit": {
"id": "...",
"permit_ref": "PRM-00042",
"valid_from": "2026-05-01T00:00:00Z",
"valid_until": "2026-06-01T00:00:00Z",
"holder_name": "Acme Co"
}
}Generic match shape (match_type + record):
Every compliant response other than pre_enforcement also carries match_type ("permit" | "session" | "whitelist") and record — the matched record itself, including its full validity window. Use these if you want one code path regardless of what covered the vehicle; the type-specific keys (permit / session / whitelist) remain for existing integrations.
// permit match
"match_type": "permit",
"record": {
"id": "...", "permit_ref": "PRM-00042", "permit_name": "Mermaid B1 Permit",
"status": "active",
"plates": ["ABC123"],
"matched_plate": "ABC123",
"queried_plate": "A8C123",
"valid_from": "2026-05-01T00:00:00Z", "valid_until": "2026-06-01T00:00:00Z",
"holder_name": "Acme Co", "location_id": "..."
}
// session match
"match_type": "session",
"record": {
"id": "...", "session_ref": "PKS-00347",
"status": "active", "payment_status": "paid",
"plate": "ABC123",
"matched_plate": "ABC123",
"queried_plate": "A8C123",
"started_at": "2026-05-21T14:00:00Z",
"expires_at": "2026-05-21T18:00:00Z",
"stopped_at": null,
"location_id": "..."
}
// whitelist match
"match_type": "whitelist",
"record": {
"id": "...", "label": "CEO car", "category": "vip",
"status": "active", "valid_from": null, "valid_until": null
}On a matched_via: "plate_variation" match, record.matched_plate is the official plate the misread resolved to and record.queried_plate is the read you sent. Always cite matched_plate (or plates) on appeals and notices — never queried_plate, which may be a camera misread.
match_type and record are only present on a match — they are never returned as null. On compliant: false and on pre_enforcement responses the keys are absent, so treat a missing key as "no window to evaluate" rather than parsing it as a date.
Permit and session matches also return matched_via: "plate" when the read hit an official (entitlement) plate, or "plate_variation" when it only resolved through the failover LPR-misread set. A variation never grants entitlement on its own — it only maps a misread back to a permit/session that is already valid at that location and time.
Compliant response (active session):
{
"compliant": true,
"reason": "session",
"session": {
"id": "...",
"session_ref": "PKS-00347",
"started_at": "2026-05-21T14:00:00Z",
"expires_at": "2026-05-21T18:00:00Z"
}
}Non-compliant response:
{
"compliant": false,
"reason": "no_active_session_or_permit"
}Compliant response (pre-enforcement soft-launch):
{
"compliant": true,
"reason": "pre_enforcement",
"pre_enforcement": {
"location_id": "...",
"location_ref": "L-GAP-AKL-001",
"enforcement_go_live_at": "2026-06-15T00:00:00+12:00"
}
}When a location has enforcement_go_live_at set and we haven't reached that timestamp, the API short-circuits to compliant:true reason:"pre_enforcement" for every plate at that location. This lets enforcement partners pre-stage a Location and have it auto-activate at the agreed go-live datetime — no breach notices issued during soft-launch. After the timestamp passes, normal whitelist / permit / session resolution resumes.
Compliant response (whitelist):
{
"compliant": true,
"reason": "whitelist",
"whitelist": {
"id": "...",
"label": "CEO car",
"category": "vip"
}
}Resolution order (first match wins):
- Pre-enforcement window — if the resolved location has
enforcement_go_live_atin the future, returncompliant:true reason:"pre_enforcement"immediately (skips all other checks) - Active Whitelist entry for the plate (tenant-wide or scoped to location)
- Active Permit covering the plate (scoped to location if provided)
- Active paid Session for the plate (scoped to location if provided)
- No match →
compliant: false
Plates are normalised server-side (uppercased, whitespace and hyphens stripped). Enforcement blockouts will be added to the resolver in a later phase.
Whitelist vs Permit — why both?
Whitelist = operator-granted permanent exemptions (staff, VIPs, emergency vehicles). No price, no holder details, often no expiry. Permit = parker-issued time-bound rights, usually paid, with full holder records. Both produce compliant: true on compliance checks. Use apiListWhitelist if you need to pre-sync the allow-list for offline operation.
List endpoints
Paginated lists, filtered by tenant
| Function | Filters | Purpose |
|---|---|---|
| POSTapiListLocations | status, limit | List tenant locations |
| POSTapiListSessions | status, plate, location_ref, overlaps_from, overlaps_to, limit | List sessions (most recent first) |
| POSTapiListPermits | status, plate, location_ref, limit | List permits |
| POSTapiListWhitelist | status, plate, category, location_ref, limit | List whitelisted plates |
All list endpoints accept limit (default 100, max 500) and respond with { data: [...], count: N }. Filters can be sent either as query-string params or as a JSON body via POST — both are honoured on every list endpoint.
Session overlap filter (apiListSessions)
For appeal investigations and after-the-fact lookups, pass overlaps_from and overlaps_to (ISO 8601) to return every session — regardless of status (active, expired, stopped, cancelled, refunded) — whose lifespan intersects that window.
POST /functions/apiListSessions
X-Api-Key: <your-api-key>
Content-Type: application/json
{
"plate": "ABC123",
"location_ref": "L-0001",
"overlaps_from": "2026-05-08T00:00:00Z",
"overlaps_to": "2026-05-08T23:59:59Z",
"limit": 50
}A session "overlaps" the window when started_at <= overlaps_to AND (stopped_at ?? expires_at ?? now) >= overlaps_from. Both bounds are required when using overlap filtering; the API returns 400 invalid_overlap_window if only one is supplied or timestamps are invalid.
Session response shape
Every session record (list, get, and webhook payloads) includes the following refund fields so consumers can scope post-refund logic to the original session window:
{
"id": "...",
"session_ref": "PKS-00347",
"plate": "ABC123",
"status": "stopped",
"started_at": "2026-05-08T09:00:00Z",
"expires_at": "2026-05-08T13:00:00Z",
"stopped_at": "2026-05-08T11:42:00Z",
"amount_cents": 1200,
"currency": "NZD",
"payment_status": "refunded",
"refund_amount_cents": 1200,
"refund_reason": "Driver appeal upheld",
"refunded_at": "2026-05-09T14:22:00Z",
...
}Permit response shape
Permit records carry the same commercial and operational depth as sessions — ownership, product/rate, pricing and activation state:
{
"id": "...",
"permit_ref": "PRM-00042",
"location_id": "...",
"additional_location_ids": [],
"client_id": "...",
"sub_tenant_id": "...",
"product_name": "Mermaid B1 Permit",
"rate_name": "B1 Monthly",
"cadence": "monthly",
"status": "active",
"activation_status": "activated",
"space_label": "114",
"holder_name": "Acme Co",
"holder_email": "ops@acme.co",
"holder_phone": "+64211234567",
"plates": ["ABC123"],
"plates_normalized": ["ABC123"],
"plate_variations": ["A8C123"],
"valid_from": "2026-06-01T00:00:00Z",
"valid_until": "2026-07-01T00:00:00Z",
"price_cents": 9000,
"currency": "NZD",
"payment_status": "paid",
"paid_at": "2026-06-01T09:15:00Z",
"is_complimentary": false
}apiGetPermit additionally returns standard_value_cents, discount_cents, promo_code_string, waived_value_cents, waiver_reason, refund fields (refund_amount_cents, refund_reason, refunded_at), is_recurring, auto_renew, approval_status, activated_at and notes.
Real-time events (webhooks)
Operators can register webhook URLs and subscribe to lifecycle events. ParkOS will POST signed events to your endpoint:
| Event | When it fires |
|---|---|
| session.created | A new parking session is created |
| session.paid | Payment completes for a session |
| session.stopped | Parker stops a session early |
| session.expired | A session reaches its end time |
| session.cancelled | A session is cancelled by an admin |
| session.refunded | A session payment is refunded |
| permit.created | A new permit is issued |
| permit.updated | Permit details change (holder, plates, dates) |
| permit.cancelled | A permit is cancelled |
| permit.expired | A permit reaches its end date |
Each delivery includes these headers:
X-ParkOS-Event— event name (e.g.session.refunded)X-ParkOS-Tenant— the originatingtenant_id. Use this to look up the per-tenant webhook secret before parsing the body so signature verification doesn't trust caller-controlled JSON.X-ParkOS-Signature— hex HMAC-SHA256 of the raw body using that tenant's webhook secretX-ParkOS-Delivery— unique delivery id for idempotency / replay protection
The tenant_id also appears in the payload body for redundancy. Always verify the signature against the raw body before trusting any payload field.
Refund handling (session.refunded)
ParkOS does not create a separate "refunded session" record. When a refund is processed, the original session is updated in place — payment_status transitions to refunded (or partial_refund), and refund_amount_cents, refund_reason, refunded_at are populated. The session_id on the webhook IS the original session.
To auto-cancel an enforcement violation issued during a now-refunded window: look up the session by id, derive the parking window from started_at through (stopped_at ?? expires_at), and cancel any violation that falls inside it.
Single-record endpoints
Fetch one record by id or human-readable ref
POST /functions/apiGetSession { "ref": "PKS-00347" } or { "id": "..." }
POST /functions/apiGetPermit { "ref": "PRM-00042" } or { "id": "..." }
POST /functions/apiGetLocation { "ref": "L-0001" } or { "id": "..." }ref resolves against the natural reference for each entity (session_ref / permit_ref / external_ref → internal_ref). Returns a single object (not wrapped). Returns 404 if the record isn't found or belongs to another tenant.
Financial reporting
Consolidated money ledger — for settlement & reconciliation
OpenAPI spec for your integration
Hand this to your sister app's developers — import it straight into Postman, Insomnia, or Swagger to scaffold the reporting client. Covers apiPing, apiListTransactions, and apiFinancialSummary with full request/response schemas.
Quick cURL — pull this month's totals
curl -X POST https://getparkos.base44.app/functions/apiFinancialSummary \
-H "X-Api-Key: <your-api-key>" \
-H "Content-Type: application/json" \
-d '{
"from": "2026-06-01T00:00:00Z",
"to": "2026-06-30T23:59:59Z",
"group_by": "location"
}'Two endpoints expose a unified financial view across sessions and permits. Both filter on the canonical transaction date (paid_at — the moment money actually moved),not the session start or permit validity window. Only settled rows appear; unpaid / authorized records are excluded by design.
POST apiListTransactions
Cursor-paginated, newest-first list of settled transactions.
POST /functions/apiListTransactions
X-Api-Key: <your-api-key>
Content-Type: application/json
{
"include": "all", // "all" | "sessions" | "permits"
"from": "2026-06-01T00:00:00Z", // optional — transaction-date window
"to": "2026-06-30T23:59:59Z", // optional
"limit": 100, // default 100, max 500
"cursor": null // pass next_cursor to page
}Response:
{
"data": [
{
"type": "session",
"id": "...",
"reference": "PKS-00347",
"location_ref": "L-0001",
"plate": "ABC123",
"payment_status": "paid",
"payment_method": "card",
"currency": "NZD",
"gross_cents": 1200,
"discount_cents": 0,
"extensions_total_cents": 0,
"refund_amount_cents": 0,
"net_cents": 1200,
"transaction_date": "2026-06-12T14:03:00Z",
"started_at": "2026-06-12T14:00:00Z",
"expires_at": "2026-06-12T18:00:00Z"
},
{
"type": "permit",
"id": "...",
"reference": "PRM-00042",
"location_ref": "L-0001",
"plate": "XYZ789",
"holder_name": "Acme Co",
"payment_status": "paid",
"currency": "NZD",
"gross_cents": 9000,
"net_cents": 9000,
"transaction_date": "2026-06-10T09:15:00Z",
"valid_from": "2026-06-10T00:00:00Z",
"valid_until": "2026-07-10T00:00:00Z"
}
],
"count": 2,
"has_more": true,
"next_cursor": "2026-06-10T09:15:00Z"
}To page, pass the returned next_cursor as cursor on the next call. has_more: false means you've reached the end. net_cents = gross_cents − refund_amount_cents.
POST apiFinancialSummary
Aggregated totals over a transaction-date window, optionally grouped.
POST /functions/apiFinancialSummary
X-Api-Key: <your-api-key>
Content-Type: application/json
{
"from": "2026-06-01T00:00:00Z", // optional
"to": "2026-06-30T23:59:59Z", // optional
"group_by": "location" // "none" | "location" | "payment_account" | "currency"
}Response:
{
"from": "2026-06-01T00:00:00Z",
"to": "2026-06-30T23:59:59Z",
"group_by": "location",
"totals": {
"session_count": 128,
"permit_count": 14,
"gross_cents": 184500,
"discount_cents": 3200,
"refund_cents": 1200,
"net_cents": 183300
},
"groups": [
{
"key": "L-0001",
"session_count": 90,
"permit_count": 8,
"gross_cents": 120000,
"discount_cents": 1500,
"refund_cents": 0,
"net_cents": 120000
}
]
}groups is omitted when group_by is none. Group keys are the location ref, payment account id, or currency respectively; rows with no value bucket under "unknown".
Rate limits
Each API key has its own 60-second window. Default ceiling is 600 requests per minute; configurable per key.
When exceeded, the API returns 429 rate_limited with a Retry-After header indicating how many seconds until the window resets.
HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json
{ "error": { "code": "rate_limited", "message": "Rate limit of 600/min exceeded" } }CORS
All endpoints set Access-Control-Allow-Origin: * and accept OPTIONS preflight requests. You can call the API directly from browser-based applications.
Allowed headers: Content-Type, X-Api-Key. Allowed methods: GET, POST, OPTIONS.
Conventions
- Tenant scoping: automatic. Every query filters by the tenant that owns the API key.
- Location refs:
location_refaccepts the location'sexternal_reffirst, then falls back tointernal_ref(e.g.L-0001). - Plate normalisation: server-side. Send any case/spacing.
- Timestamps: ISO 8601 UTC.
- Errors: JSON body
{ "error": { "code": "...", "message": "..." } }with appropriate HTTP status (401, 404, 400, 500). - Rate limits: 600 req/min per key by default. See Rate limits section above.