Webhook API (Parksby -> Your Server)
OutboundUse this when Parksby should notify your backend about booking and payment lifecycle events.
Parksby Outbound Webhook API
Version: 1.0
Direction: Parksby -> Your server
Protocol: HTTPS / REST
Authentication: HMAC-SHA256 signature header
Overview
Parksby webhooks notify your backend when important platform events happen (bookings, payments, session updates, reviews, etc.).
You configure one or more webhook endpoints in the dashboard. Parksby then POSTs event payloads to those URLs.
Endpoint (Your Server)
You provide the endpoint URL in the dashboard, for example:
https://api.yourcompany.com/webhooks/parksby
Parksby sends POST requests to this endpoint.
Headers Sent By Parksby
| Header | Description |
|---|---|
Content-Type |
application/json |
X-Parksby-Timestamp |
Unix timestamp (seconds) when request was signed |
X-Parksby-Signature |
HMAC-SHA256 signature (hex) |
X-Parksby-Event-ID |
Unique Parksby event UUID |
Payload Shape
{
"event_id": "4c16c6dd-b2ca-4208-91d2-26eb2b4834f3",
"event_type": "booking.completed",
"occurred_at": "2026-03-20T13:00:00.000Z",
"data": {
"booking_id": "...",
"parking_id": "...",
"tenant_id": "..."
}
}
Field notes:
event_id: globally unique event identifier. Use for idempotency.event_type: domain event name from Parksby.occurred_at: ISO timestamp when event occurred.data: event-specific payload object.
Signature Verification
Parksby signs the raw JSON body using your webhook secret.
signature = hex( HMAC-SHA256(webhook_secret, raw_body) )
Important:
- Signature input is raw body bytes only.
X-Parksby-Timestampis provided for replay protection and freshness checks.- Reject requests with stale timestamps (recommended window: <= 5 minutes).
Node.js Example
import crypto from "crypto";
function verifyParksbyWebhook(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "utf8"),
Buffer.from(signatureHeader ?? "", "utf8")
);
}
Python Example
import hmac
import hashlib
def verify_parksby_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header or "")
Delivery Semantics
- Delivery is considered successful when your endpoint returns 2xx.
4xx(except429) is treated as permanent failure (no retries).429,5xx, and network/transport failures are retried with backoff.- Maximum attempts: 5.
Retry backoff schedule uses exponential minutes with cap:
- attempt 1 retry: 2 minutes
- attempt 2 retry: 4 minutes
- attempt 3 retry: 8 minutes
- attempt 4 retry: 16 minutes
- capped at 60 minutes
Idempotency Requirements
Your webhook consumer must be idempotent.
Recommended approach:
- Read
event_idfrom payload (orX-Parksby-Event-IDheader). - If already processed, return
200immediately. - Otherwise process and persist
event_idin your dedupe store.
Recommended Receiver Behavior
- Capture raw body bytes.
- Verify timestamp freshness.
- Verify HMAC signature.
- Parse JSON only after signature passes.
- Idempotency check on
event_id. - Process event.
- Return
200quickly.
Security Checklist
- Keep webhook secret in server-side secret manager / env var.
- Never log full secrets.
- Use HTTPS only.
- Enforce signature verification for every request.
- Enforce timestamp freshness window to reduce replay risk.
Troubleshooting
- Signature mismatch:
- Ensure you verify against raw body bytes, not re-serialized JSON.
- Ensure correct secret (rotate safely in dashboard and backend together).
- High retry counts:
- Check your endpoint latency and non-2xx responses.
- Ensure endpoint is publicly reachable and TLS-valid.
- Duplicate deliveries:
- Expected under retries; enforce idempotency with
event_id.
- Expected under retries; enforce idempotency with
Hardware Ingest API (Hardware -> Parksby)
InboundUse this when on-site hardware sends ANPR, entry, exit, and heartbeat events into Parksby.
Parksby Hardware Integration API
Version: 1.1 Protocol: HTTPS / REST Authentication: HMAC-SHA256
Overview
The Parksby Hardware Integration API allows parking hardware — ANPR cameras, barrier controllers, and loop sensors — to communicate vehicle entry and exit events to the Parksby platform in real time.
When an event is received, Parksby:
- Authenticates the request cryptographically
- Matches the vehicle plate against active bookings and passes
- Creates or updates an access session
- Returns a barrier control instruction synchronously in the HTTP response
All of this happens within a single request-response cycle. The hardware does not need to poll for decisions.
Prerequisites
Before any hardware can send events, you must complete the following steps in the Parksby dashboard:
- Create an integration — navigate to Integrations and create a new integration for your parking site
- Enable the integration — toggle Integration Status to enabled and save
- Register your devices — add each physical device (camera, barrier, sensor) under the Devices tab; copy the assigned Device ID for use in your hardware configuration
- Copy your credentials — from the Credentials tab, copy your Integration ID and Signing Secret
- Set your automation mode — under Configuration, choose the mode appropriate for your site:
- Notify-only — Parksby records events and notifies staff; no barrier commands are issued
- Assisted — Parksby matches events and flags anomalies; staff make final decisions
- Full automation — Parksby issues barrier open/close commands autonomously
Endpoint
POST /api/v1/ingest
This endpoint accepts hardware events. It is publicly reachable without a session cookie — authentication is handled via the request signature described below.
Production base URL: https://spaces.parksby.io
Production ingest URL: https://spaces.parksby.io/api/v1/ingest
Use the Spaces dashboard domain endpoint above for hardware integrations. Do not call Supabase edge URLs directly from hardware.
Authentication
Every request must be signed using HMAC-SHA256. Parksby verifies the signature before processing any event.
Signing algorithm
signature = hex( HMAC-SHA256( signing_secret, raw_body + "." + timestamp ) )
Where:
signing_secret— the secret from the Credentials tab (treat as a password; never expose it)raw_body— the exact JSON string you will send as the request body (byte-for-byte, no reformatting)timestamp— the ISO 8601 UTC timestamp you will send inX-Parksby-Timestamp"."— a literal period character as separator
The signature must be the lowercase hex-encoded digest.
Critical: The body used to compute the signature must be identical to the body transmitted over the wire. Do not re-serialize or reformat JSON after signing.
Required headers
| Header | Description |
|---|---|
X-Parksby-Integration-Id |
Your integration UUID |
X-Parksby-Timestamp |
Current UTC time in ISO 8601 format (e.g. 2026-03-15T10:00:00.000Z) |
X-Parksby-Signature |
Hex-encoded HMAC-SHA256 signature (see above) |
Content-Type |
Must be application/json |
Clock skew
Parksby enforces a ±5 minute clock skew tolerance. Requests with a timestamp outside this window are rejected. Ensure your hardware clock is synchronised (NTP recommended).
Secret rotation
When you rotate your signing secret from the dashboard, the previous secret remains valid for a configurable grace period (default: 5 minutes). This allows you to deploy the new secret to hardware without downtime.
Request body
{
"event_id": "a3f1c2d4-...",
"event_type": "vehicle.entry",
"event_time": "2026-03-15T10:00:00.000Z",
"device_id": "10d6a3b8-...",
"plate_number": "KDA 123A"
}
Required fields
| Field | Type | Description |
|---|---|---|
event_id |
string | Unique identifier for this event (UUID recommended). Used for deduplication — sending the same event_id twice is safe and idempotent. |
event_type |
string | One of vehicle.entry, vehicle.exit, or device.heartbeat |
event_time |
string | ISO 8601 UTC timestamp of when the event occurred on the hardware |
device_id |
string | The Device ID assigned by Parksby (copy from the dashboard Devices tab) |
Optional fields
| Field | Type | Description |
|---|---|---|
plate_number |
string | Vehicle registration plate. Required for vehicle.entry and vehicle.exit events. Omit or use "" for heartbeats. Spaces and case are normalised automatically. |
lane_id |
string | Identifier for the specific lane or gate, if your site has multiple |
confidence |
number | ANPR read confidence as a decimal (e.g. 0.94 = 94%). Used by the confidence threshold policy. |
image_url |
string | URL to the plate capture image, if your system stores images |
ocr_raw_text |
string | Raw OCR output before normalisation, for audit purposes |
metadata |
object | Any additional vendor-specific fields. Stored as-is for debugging. |
Event types
`device.heartbeat`
Sent periodically (recommended: every 60 seconds) to indicate the device is online. Parksby uses this to compute device health status shown on the dashboard.
No plate matching is performed. The response is:
{
"status": "ok",
"event_id": "...",
"event_type": "device.heartbeat"
}
`vehicle.entry`
Sent when a vehicle is detected entering the facility. Parksby matches the plate against active bookings and passes and returns a barrier instruction.
`vehicle.exit`
Sent when a vehicle is detected leaving. Parksby finds the active session for this plate and applies site/payment policies before issuing a barrier decision.
Response
All responses are JSON. The HTTP status code indicates whether Parksby successfully received and processed the event — it does not indicate whether the barrier should be raised (use raise_barrier for that).
Success — `200 OK`
{
"status": "ok",
"event_id": "a3f1c2d4-...",
"event_type": "vehicle.entry",
"match_type": "matched_booking",
"match_reason": "Plate matched single active booking.",
"session_id": "b7e2a1f0-...",
"review_item_id": null,
"raise_barrier": true,
"barrier_action": "open"
}
Duplicate event — `200 OK`
If you send an event with an event_id Parksby has already processed, it is acknowledged without reprocessing:
{
"status": "duplicate",
"event_id": "a3f1c2d4-...",
"message": "Event already processed."
}
Retry contract (important)
If your hardware receives transport errors or HTTP 5xx responses, retry the same event using the same event_id.
- Retries with the same
event_idare idempotent. - Parksby deduplicates by
(integration_id, event_id). - Do not generate a new
event_idfor a retry of the same physical event.
Review required — `200 OK`
When Parksby cannot confidently match an event (e.g. unmatched plate, low ANPR confidence), it logs a review item for an operator and returns:
{
"status": "review",
"event_id": "...",
"event_type": "vehicle.entry",
"match_type": "unmatched",
"match_reason": "No matching booking found.",
"session_id": null,
"review_item_id": "c9d3b2a1-...",
"raise_barrier": false,
"barrier_action": "deny"
}
Denied plate — `200 OK`
If the plate is on the site denylist:
{
"status": "denied",
"match_type": "denied",
"raise_barrier": false,
"barrier_action": "deny"
}
Barrier control
The raise_barrier boolean in every vehicle event response is the authoritative barrier command. Your hardware must act on it immediately and synchronously.
raise_barrier |
barrier_action |
Meaning |
|---|---|---|
true |
"open" |
Raise the barrier — vehicle is authorised |
false |
"deny" |
Keep the barrier closed |
raise_barrier is only ever true when:
- The integration mode is Full automation, AND
- For entry: the plate matched a booking, a pass, or the allowlist (or drive-in is enabled)
- For exit: the plate matched an active session, a pass, or the allowlist, and no deny policy applies
Exit safety gate for postpaid bookings
In full automation mode, if an exit event matches a linked metered postpaid booking that is still unpaid, Parksby returns:
raise_barrier: falsebarrier_action: "deny"
The response match_reason explains that payment is required before barrier open.
In Notify-only or Assisted modes, raise_barrier is always false. Staff receive notifications and operate barriers manually.
Match types
The match_type field in the response tells you how Parksby classified the event:
match_type |
Description |
|---|---|
matched_booking |
Plate matched exactly one active booking within the time window |
matched_session |
Exit matched an active session created at entry |
pass_match |
Plate has a valid parking pass for this site |
allowlist_match |
Plate is on the site allowlist |
drive_in_created |
No booking found; drive-in session created (requires site policy) |
ambiguous |
Plate matched multiple bookings — sent for operator review |
unmatched |
No booking, session, or pass found for this plate |
denied |
Plate is on the site denylist |
low_confidence |
ANPR read confidence below the configured threshold |
Error responses
Errors return a non-2xx status.
| HTTP status | Error code | Cause |
|---|---|---|
400 |
missing_headers |
One or more required headers are absent |
400 |
invalid_timestamp |
X-Parksby-Timestamp is not a valid ISO 8601 string |
400 |
timestamp_skew |
Timestamp is more than ±5 minutes from server time |
400 |
missing_fields |
Required body fields are absent |
400 |
invalid_event_type |
event_type is not one of the allowed values |
400 |
invalid_event_time |
event_time is not a valid ISO 8601 string |
400 |
invalid_json |
Request body is not valid JSON |
401 |
unknown_integration |
No integration found for the provided Integration ID |
401 |
integration_disabled |
Integration exists but is disabled — enable it from the dashboard |
401 |
invalid_signature |
HMAC signature verification failed |
401 |
unknown_device |
Device ID not registered under this integration |
403 |
device_quarantined |
Device has been quarantined — re-activate it from the dashboard |
405 |
method_not_allowed |
Only POST is accepted |
500 |
storage_error |
Internal error storing the event |
500 |
normalization_error |
Internal error storing the normalized event |
500 |
ingest_unhandled_error |
Internal function/runtime error in ingest processing |
502 |
upstream_unreachable |
Spaces API proxy could not reach the upstream ingest function |
5xx |
upstream_non_json_error |
Upstream returned non-JSON error body through the Spaces API proxy |
Error response body:
{
"error": "error_code",
"message": "Human-readable description."
}
Stale events
If event_time is more than 10 minutes before the server receives the request, the event is flagged as stale. It is still processed and stored, but is_stale: true is recorded on the event and visible in the dashboard. This can occur if your hardware buffers events during connectivity loss.
Site policies
The following policies can be configured per integration from the dashboard:
| Policy | Default | Description |
|---|---|---|
booking_match_window_minutes |
60 | How far before and after event_time Parksby searches for a matching booking |
confidence_threshold |
0 (disabled) | Minimum ANPR confidence (0–100). Reads below this threshold are flagged for review. |
allow_drive_in |
false | Whether to accept unbooked vehicles in full automation mode |
allow_auto_checkout |
true | Whether matched exits automatically trigger booking checkout from hardware events |
plate_allowlist |
[] |
Plates that bypass booking matching and are always granted access |
plate_denylist |
[] |
Plates that are always denied, regardless of bookings |
Code examples
Python (Raspberry Pi / edge device)
import hmac
import hashlib
import json
import time
import uuid
from datetime import datetime, timezone
import requests
PARKSBY_URL = "https://spaces.parksby.io/api/v1/ingest"
INTEGRATION_ID = "8d2244f1-dc10-4bdd-aaab-c62638928a18"
SIGNING_SECRET = "your-signing-secret"
DEVICE_ID = "10d6a3b8-ff84-4fa9-a5cc-9713b223ec56"
def send_event(event_type: str, plate: str = "") -> dict:
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
payload = {
"event_id": str(uuid.uuid4()),
"event_type": event_type,
"event_time": timestamp,
"device_id": DEVICE_ID,
"plate_number": plate,
}
# Sign: HMAC-SHA256(secret, body + "." + timestamp)
body = json.dumps(payload, separators=(",", ":"))
message = body + "." + timestamp
signature = hmac.new(
SIGNING_SECRET.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
response = requests.post(
PARKSBY_URL,
data=body, # Send the exact body that was signed
headers={
"Content-Type": "application/json",
"X-Parksby-Integration-Id": INTEGRATION_ID,
"X-Parksby-Timestamp": timestamp,
"X-Parksby-Signature": signature,
},
timeout=5,
)
return response.json()
# Heartbeat
result = send_event("device.heartbeat")
print(result)
# Vehicle entry
result = send_event("vehicle.entry", plate="KDA 123A")
if result.get("raise_barrier"):
open_barrier() # your hardware control function
else:
keep_barrier_closed()
Note on
json.dumps: Useseparators=(",", ":")to avoid whitespace differences between signing and transmission. The body passed torequests.post(data=...)must be the exact same string used to compute the signature.
curl (testing)
Generate a fresh signature for the current time:
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
SECRET="your-signing-secret"
INTEGRATION_ID="8d2244f1-dc10-4bdd-aaab-c62638928a18"
DEVICE_ID="10d6a3b8-ff84-4fa9-a5cc-9713b223ec56"
BODY=$(printf '{"event_id":"%s","event_type":"vehicle.entry","event_time":"%s","device_id":"%s","plate_number":"KDA 123A"}' \
"$(uuidgen | tr '[:upper:]' '[:lower:]')" "$TIMESTAMP" "$DEVICE_ID")
SIGNATURE=$(echo -n "${BODY}.${TIMESTAMP}" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST https://spaces.parksby.io/api/v1/ingest \
-H "Content-Type: application/json" \
-H "X-Parksby-Integration-Id: $INTEGRATION_ID" \
-H "X-Parksby-Timestamp: $TIMESTAMP" \
-H "X-Parksby-Signature: $SIGNATURE" \
-d "$BODY"
Node.js
const crypto = require("crypto");
const PARKSBY_URL = "https://spaces.parksby.io/api/v1/ingest";
const INTEGRATION_ID = "8d2244f1-dc10-4bdd-aaab-c62638928a18";
const SIGNING_SECRET = "your-signing-secret";
const DEVICE_ID = "10d6a3b8-ff84-4fa9-a5cc-9713b223ec56";
async function sendEvent(eventType, plate = "") {
const timestamp = new Date().toISOString();
const payload = {
event_id: crypto.randomUUID(),
event_type: eventType,
event_time: timestamp,
device_id: DEVICE_ID,
plate_number: plate,
};
const body = JSON.stringify(payload);
const message = body + "." + timestamp;
const signature = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(message)
.digest("hex");
const res = await fetch(PARKSBY_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Parksby-Integration-Id": INTEGRATION_ID,
"X-Parksby-Timestamp": timestamp,
"X-Parksby-Signature": signature,
},
body,
});
return res.json();
}
// Example usage
const result = await sendEvent("vehicle.entry", "KDA 123A");
console.log(result);
if (result.raise_barrier) {
openBarrier(); // your hardware control
}
Testing
The Parksby dashboard includes a built-in test tool on the Test tab of each integration. It sends a signed device.heartbeat event using your first registered device and displays the raw response. This is the quickest way to verify your integration is correctly configured before deploying hardware.
For end-to-end testing with real plate events, use the curl script above with a fresh timestamp. If you receive a timestamp_skew error, verify that your system clock is correct (date -u on Linux/macOS).
Security considerations
- Store your signing secret in environment variables or a secrets manager — never in source code
- Rotate your secret periodically from the Integrations dashboard; the grace window ensures zero-downtime rotation
- The
device_idin every event is verified against your integration — events from unregistered or foreign devices are rejected