1. What this is, and what it is not
This is the reference for v1 of the Parkena operator API. It is the alternative to running your inventory in the Parkena console: push your car parks and your prices in, pull your Parkena bookings back out. Both routes write the same tables through the same security, and neither can reach another operator’s data. You can use both — the console for the things a person decides, the API for the nightly sync — and they will not fight each other.
What it is not is a product you integrate with unattended. A signed-in owner or manager mints the key in the console; there is no sandbox to practise against, and the first integration is built with a person on our side. That is a statement about the stage Parkena is at rather than a queue you can skip.
Five things this API does not do, up front
Bookings are pulled, on a cursor, and that cursor is the source of truth. A registered endpoint can receive a signed booking.changed hint that says “pull now” — §13 — but no booking data ever rides in a webhook, deliberately, and §12 says what still does not exist.
There is no availability calendar. Blocked periods — §7 — withdraw whole date ranges from sale, but you cannot push “spaces free tonight” as a number, and the field that looks like it — capacity — is TOTAL spaces. Putting availability there misreports your car park and voids its listing review every night.
No money moves through this API. No charges, no refunds, no payout data, no commission figures.
Nothing here creates, amends, cancels, checks in or refunds a booking. There is no scope for it and no grant behind it.
The base URL is https://api.parkena.com/v1 — see §3. Nothing else should be pointed at.
2. Two rules to get right before you write a line
These are the two things a competent integration still gets wrong, because in both cases the wrong thing looks like it worked. They are lifted to the top of this page for that reason; everything after them is ordinary reference material.
Rule one: diff before you update
When a Parkena reviewer approves your listing they approve specific content. If that content changes, the approval no longer describes what is published, so it is voided and the car park goes back for review — and it stops selling on parkena.com until a person approves it again.
That is correct behaviour, and in front of a machine that re-pushes its whole estate every night it is also a way to be de-listed at 03:00 every night forever. So this API does not issue an update that changes nothing. Both write routes read the current row, compare it field by field, and if nothing differs they issue no statement at all — not a no-op UPDATE; no statement. You get "result": "unchanged" and "changed": [].
You do not have to do anything to get this. It is not a flag and there is no header to send. A nightly full re-push of identical data is a no-op that costs one read per car park.
# an identical re-push of an approved car park
{ "result": "unchanged", "changed": [], "listing_review_superseded": false }
# the same car park, with one word of the name changed
{ "result": "updated", "changed": ["name"], "listing_review_superseded": true }The second response is not a warning you can ignore: that car park left the storefront and stays off it until it is approved again. §8 lists every field that costs you a re-review, and every field that does not.
Rule two: GET returns a field that PUT refuses
GET /lots returns each car park WITH its rates envelope, because that is the useful thing to read. PUT /lots/{external_id} does NOT take one — prices are set at PUT /lots/{external_id}/rates. So the obvious loop, read a lot and change one field and put it back, fails until you drop rates. Drop external_id with it: that lives in the path.
curl "$BASE/lots" -H "Authorization: Bearer $KEY.$SECRET" \
| jq '.lots[0] | del(.rates, .external_id)' > lot.json
# edit lot.json
curl -X PUT "$BASE/lots/edge-main" -H "Authorization: Bearer $KEY.$SECRET" \
-H 'Content-Type: application/json' --data @lot.jsonSend it back with rates still attached and you get 422 {"error":"field_belongs_to_another_route","field":"rates"}. We refuse it rather than ignoring it, and the distinction is the whole point: if you put a new price in a lot body and we answered 200, you would reasonably believe the price had changed. It would not have.
3. The base URL
Every path on this page is relative to https://api.parkena.com/v1. Everything is JSON, in and out.
api.parkena.com is a hostname Parkena owns
It sits in front of the function that answers; the paths below will not change if what is behind it does. Keep the base in configuration rather than in source all the same.
Local development against the repository’s own stack uses the same paths under /functions/v1/operator-api/v1 on the local Supabase origin — the same paths, a different base.
There is no sandbox and no test base. The base above is the live one, and which operator a request acts on is resolved from the credential you present — never from anything in a URL or a body. See §4, and §12 on tenant selection.
4. Authentication
Every request carries one header:
Authorization: Bearer <key_id>.<secret>key_id and secret are the two halves of one credential, joined by a dot. The first dot separates them; any further dots belong to the secret.
curl "$BASE/ping" \
-H "Authorization: Bearer pk_api_3f9c….pk_sec_a71b…"key_id—pk_api_followed by 32 hex characters. This is the public half. It appears in our logs by design, and you can safely put it in a configuration file or quote it in a support request. Knowing it gets someone as far as knowing a username.secret—pk_sec_followed by 64 hex characters, which is 256 bits. We do not store it. We store a salted HMAC-SHA256 of it, in two columns that no role in our database can read. We cannot recover it for you, ever. If you lose it, issue a new credential and revoke the old one.
There is no ?key= query-string fallback and there will not be one: a secret in a URL is a secret in a proxy log, a browser history and a Referer header.
Every authentication failure returns the same 401
An unknown key_id, a wrong secret, a revoked credential, an expired credential, a suspended operator account, a credential lacking the scope a route requires, and an external_id that is not one of your car parks are all 401 {"error":"unauthorized"}. They are not distinguishable, deliberately — §9 says what that buys and what it costs you.
Use GET /ping to check a credential rather than inferring anything from a 401.
5. Issuing, rotating and revoking a credential
Credentials are minted by a signed-in owner or manager on your operator account — never through this API. An API key that can mint API keys is a key that can escalate itself, so no route here issues one. Staff logins can neither issue nor revoke.
The two calls behind it are api_credential_issue and api_credential_revoke, made over PostgREST with a signed-in user’s token rather than with an API credential. They are documented here because an operator with an engineering team will want to drive them directly; the console screen under Account → API keys is the ordinary way to issue one.
Issue
curl -X POST '<project rest url>/rpc/api_credential_issue' \
-H "apikey: <anon key>" \
-H "Authorization: Bearer <a signed-in owner or manager’s JWT>" \
-H 'Content-Type: application/json' \
-d '{
"p_label": "nightly sync",
"p_scopes": ["read_supply", "write_supply", "read_bookings"],
"p_expires_in_days": 365
}'| Parameter | Type | Notes |
|---|---|---|
p_label | string, 1–80 chars | What you will call this key in six months. Required. |
p_scopes | array of scope | Non-empty set of distinct scopes. Required. |
p_expires_in_days | integer 1–3650, or null | Null means it does not expire. Optional. |
[{
"credential_id": "01a01a73-a650-710e-9be1-08ffc77b4696",
"key_id": "pk_api_3f9c…",
"secret": "pk_sec_a71b…",
"label": "nightly sync",
"scopes": ["read_supply", "write_supply", "read_bookings"],
"created_at": "2026-08-19T14:22:07.113904Z",
"expires_at": "2027-08-19T14:22:07.113904Z"
}]There is no p_expires_at and there never will be. Expiry is a number of days that the server converts against its own clock; this API accepts no caller-supplied timestamp anywhere, on any route.
Rotate
There is no rotate call, because rotation with zero downtime is just two calls in the right order:
- Issue a second credential with the same scopes.
- Deploy it to your system and confirm traffic is flowing —
GET /pingwith the new key, then watchlast_used_aton it. - Revoke the old one.
Both credentials are live between the first step and the third. There is no limit that stops you holding two.
Revoke
curl -X POST '<project rest url>/rpc/api_credential_revoke' \
-H "apikey: <anon key>" \
-H "Authorization: Bearer <a signed-in owner or manager’s JWT>" \
-H 'Content-Type: application/json' \
-d '{"p_credential_id": "01a01a73-a650-710e-9be1-08ffc77b4696"}'Revocation is immediate and permanent. A revoked credential is not reinstated — it is replaced. Revoking twice returns the same row with the original revoked_at, because a key does not die twice. An id that is not one of yours returns [], exactly as an id that never existed does.
6. Scopes
A credential carries a set of scopes. Three exist:
| Scope | What it permits |
|---|---|
read_supply | List your car parks, their Parkena price envelopes, and each car park’s blocked periods — including the aggregate overlapping_bookings count on each one. A count, never a booking: no reference, name or plate rides along on it. |
write_supply | Create and amend car parks, their Parkena prices, and their blocked-period lists. |
read_bookings | Pull your Parkena bookings. |
Grant the least you need. A sync that only pushes inventory does not need read_bookings; a reporting job that only pulls bookings must not hold write_supply.
The scope check is not advisory. It is an argument to the same database call that establishes which operator’s rows the request may touch, so there is no path to your data that skips it. A credential without the scope a route requires gets the uniform 401 — the same answer a wrong secret gets.
There is deliberately no write_bookings. Nothing in Parkena lets a machine create, amend or cancel a booking, so a scope naming that capability would read to you as a boundary and be nothing of the kind.
7. Routes
| Method | Path | Scope required |
|---|---|---|
GET | /ping | any valid credential |
GET | /lots | read_supply |
PUT | /lots/{external_id} | write_supply |
PUT | /lots/{external_id}/rates | write_supply |
GET | /lots/{external_id}/blocked-periods | read_supply |
PUT | /lots/{external_id}/blocked-periods | write_supply |
GET | /bookings | read_bookings |
{external_id} is YOUR OWN identifier for the car park — whatever your system calls it. It is opaque to us: we never parse it, and it need not be a UUID. Percent-encode it if it contains a / or a space. Parkena’s internal ids are never sent to you and are never accepted from you.
Unknown query parameters are refused rather than ignored, on every route. A typo’d parameter that is silently dropped is a filter you think is applied and is not.
GET /ping
Checks a credential and tells you what it may do. This is the route to use when an integration is not working — every other refusal on this API is deliberately unable to tell you which of six things went wrong. It establishes no operator context and reads no rows.
curl "$BASE/ping" -H "Authorization: Bearer $KEY.$SECRET"
{
"ok": true,
"key_id": "pk_api_3f9c…",
"scopes": ["read_supply", "write_supply", "read_bookings"],
"server_time": "2026-08-19T15:12:09.870151Z"
}server_time is our clock, in UTC. It is informational — you never send a time back to us.
GET /lots
Your car parks, with each one’s Parkena price envelope.
| Query parameter | Type | Default | Notes |
|---|---|---|---|
after | string | — | Resume after this external_id. Use the next_after from the previous page. |
limit | integer 1–200 | 50 | Asking for more is refused, not silently reduced. |
Paging is on your own external_id, in ascending order — not an offset, so a page boundary does not shift under a concurrent write.
curl "$BASE/lots?limit=50" -H "Authorization: Bearer $KEY.$SECRET"
{
"lots": [
{
"external_id": "LOT-1",
"name": "Terminal Park",
"timezone": "Europe/Berlin",
"handover": "self",
"online_bookable": true,
"capacity": 250,
"latitude": "52.520000",
"longitude": "13.405000",
"city": "Berlin",
"country_code": "DE",
"features": ["indoor", "valet"],
"arrival": null,
"media": null,
"min_advance_days": 1,
"min_stay_days": 1,
"max_stay_days": 60,
"cancellation": {
"free_until_hours_before_check_in": 48,
"penalty_percent_after": "50.00",
"no_show_forfeits_full": true
},
"rates": {
"channel": "parkena",
"currency": "EUR",
"base_price": "12.50",
"floor_price": "9.00",
"ceiling_price": "18.00",
"min_first_day_price": "11.00",
"dynamic": false,
"valid_from": null,
"valid_to": null
}
}
],
"next_after": null
}next_after is present only when the page was full. Follow it until it is null and you have seen your whole estate exactly once. rates is null for a car park that has no Parkena price yet.
PUT /lots/{external_id}
Create or update ONE car park. One request, one car park — there is no bulk endpoint, and the route shape is what enforces that. An array body is refused with too_many_lots.
PUT is a full representation. An absent key means null, not “leave it as it was”. Send the whole car park every time. The alternative makes it impossible to clear a field and turns a typo’d key into a permanent silent no-op.
| Field | Type | Required | Notes |
|---|---|---|---|
external_id | string | no | If present it must equal the one in the path. Checked, not used. |
name | string, ≤200 | yes | |
timezone | string | yes | IANA name, e.g. Europe/Berlin. Every day-boundary calculation resolves through it. |
handover | string | yes | self or attended. |
online_bookable | boolean | yes | Your sales switch. Required precisely because defaulting it would take a live car park off sale on the first partial push. |
capacity | integer 0–1000000 | no | TOTAL spaces. Not spaces free tonight — see the warning below. |
latitude | number or string | no | Rounded to 6 decimal places. Must be sent with longitude. |
longitude | number or string | no | Rounded to 6 decimal places. Must be sent with latitude. |
city | string, ≤120 | no | |
country_code | string | no | ISO 3166-1 alpha-2, e.g. DE. |
features | array of string | no | See the list below. Order does not matter — we sort them. |
arrival | object | no | Free-form arrival guidance. |
media | object | no | Free-form media references. |
min_advance_days | integer 0–365 | no | Defaults to 0. |
min_stay_days | integer 1–365 | no | |
max_stay_days | integer 1–365 | no | All three stop at 365, which is how far ahead Parkena quotes a stay at all. A larger value would be accepted and never honoured. |
cancellation | object or null | no | All three keys or none — see below. |
features accepts: indoor, security, cameras, gate_automation, plate_recognition, ev_charging, disabled_access, oversize_vehicle, valet.
cancellation is one nested object, and it is all or nothing:
"cancellation": {
"free_until_hours_before_check_in": 48,
"penalty_percent_after": "50.00",
"no_show_forfeits_full": true
}Sending one or two of the three is 422 incomplete_cancellation_policy. A policy with a free window and no stated penalty is not a partially known policy, it is an unanswerable refund question. Send null or omit the key for “no policy”.
capacity is TOTAL SPACES, not availability
If your nightly feed pushes “spaces free tonight” into capacity, you will tell Parkena your car park has shrunk, AND you will void your listing review every single night, because capacity is reviewed content.
Live availability is a different mechanism and is not part of v1. This API cannot detect the mistake, because a number is a number.
curl -X PUT "$BASE/lots/LOT-1" \
-H "Authorization: Bearer $KEY.$SECRET" \
-H 'Content-Type: application/json' \
-d '{
"name": "Terminal Park",
"timezone": "Europe/Berlin",
"handover": "self",
"online_bookable": true,
"capacity": 250,
"latitude": 52.5200,
"longitude": 13.4050,
"city": "Berlin",
"country_code": "DE",
"features": ["valet", "indoor"],
"min_advance_days": 1,
"min_stay_days": 1,
"max_stay_days": 60,
"cancellation": {
"free_until_hours_before_check_in": 48,
"penalty_percent_after": "50.00",
"no_show_forfeits_full": true
}
}'{
"external_id": "LOT-1",
"result": "created",
"changed": ["name", "timezone", "handover", "…"],
"listing_review_superseded": false,
"lot": { "…": "the car park as it now stands" }
}201 when the car park was created, 200 otherwise.| Field | Meaning |
|---|---|
result | created, updated, or unchanged. |
changed | The field names that actually differ. Empty on unchanged. |
listing_review_superseded | true if this write voided your Parkena listing review — see §8. |
lot | The stored car park, read back. |
result: "unchanged" means no statement was issued at all — no row lock, no write, no trigger. That is the normal and expected outcome of a nightly re-push, and it is what stops this API from de-listing you every night.
PUT /lots/{external_id}/rates
Set the Parkena price envelope for one car park. A rate plan is not a price — it is the RANGE you are willing to sell in on the Parkena channel: a floor, a ceiling, and a base inside them.
This route writes the parkena channel and only that channel. Your own direct pricing is yours and this API cannot touch it.
Two body shapes are accepted. A range:
{
"floor_price": "9.00",
"base_price": "12.50",
"ceiling_price": "18.00",
"min_first_day_price": "11.00",
"dynamic": false
}Or one fixed price, which expands to floor = base = ceiling:
{ "fixed_price": "12.50" }| Field | Type | Required | Notes |
|---|---|---|---|
fixed_price | decimal | one shape or the other | Cannot be combined with the range fields. |
floor_price | decimal | with the range | Must be ≤ base_price. |
base_price | decimal | with the range | Must be between floor and ceiling. |
ceiling_price | decimal | with the range | Must be ≥ base_price. |
min_first_day_price | decimal or null | no | Must lie within the envelope. |
currency | string | no | ISO 4217. Defaults to your settlement currency, and cannot be anything else. |
dynamic | boolean | no | Defaults to false. |
valid_from | YYYY-MM-DD or null | no | A calendar date. A timestamp is refused, not truncated. |
valid_to | YYYY-MM-DD or null | no | Must not precede valid_from. |
Money is a string, and it is refused rather than rounded. Send "12.50", not 12.345. Amounts carry two decimal places; a third is 422 invalid_body, because a price you did not type is not a price you agreed to. Coordinates are the opposite — they are a measurement, so they round.
Sending both fixed_price and a range is 422. Guessing which you meant is how a car park ends up priced at the wrong end of its own range.
{
"external_id": "LOT-1",
"result": "updated",
"changed": ["floor_price"],
"envelope_widened": true,
"rates": {
"channel": "parkena",
"currency": "EUR",
"base_price": "12.50",
"floor_price": "7.00",
"ceiling_price": "18.00",
"min_first_day_price": "11.00",
"dynamic": false,
"valid_from": null,
"valid_to": null
}
}201 on create, 200 otherwise.envelope_widened is true when this write moved the envelope OUTWARD — floor down, ceiling up, currency changed, or dynamic flipped — or when there was no Parkena envelope before. Widening is what can cost you a listing review.
It is a conservative signal and we would rather say so than overstate it: we compare against the envelope that was live a moment ago, not against the one a reviewer approved, because this API deliberately cannot read your review state. So it can report true in a case that supersedes nothing. It will not report false when something was voided.
GET /lots/{external_id}/blocked-periods
The car park’s closure list: every date range it is withdrawn from sale on Parkena, whoever authored it — your syncs and the console write the same list. A blocked period stops NEW Parkena sales for stays that touch it, and does nothing else: it cancels nothing, and it does not touch your own direct sales. Both dates are inclusive — ends_on is the last blocked day, not the day after, and a period with starts_on equal to ends_on blocks exactly that one day.
curl "$BASE/lots/LOT-1/blocked-periods" -H "Authorization: Bearer $KEY.$SECRET"
#
{
"external_id": "LOT-1",
"blocked_periods": [
{ "starts_on": "2026-11-02",
"ends_on": "2026-11-08",
"reason": "resurfacing",
"overlapping_bookings": 2 },
{ "starts_on": "2026-12-24",
"ends_on": "2026-12-26",
"reason": null,
"overlapping_bookings": 0 }
]
}reason is your own label, null when none was given.overlapping_bookings is a transparency count: how many of this car park’s bookings — every channel, every status except cancelled — have a stay that touches the period. Both comparisons are inclusive, on the car park’s own calendar dates, so a booking that checks out on the closure’s first morning still counts, and so does one that checks in on its last evening. Note what “except cancelled” includes: a COMPLETED stay counts too. The count answers “what was sold into these dates”, history included — it is not the number of upcoming cars a closure would strand, so it can read higher than the console’s warning panel, which asks that narrower question. It is a count and nothing more: no reference, name, plate or date of any booking is in the response.
PUT /lots/{external_id}/blocked-periods
Replace the car park’s ENTIRE closure list with the one in the body. There is no “add one period” call and no way to address a single period — a sync that can only merge is a sync that can never delete, and a closure lifted in your system would stay on Parkena forever. At most 100 entries; every date a real calendar day inside 2020-01-01..2032-12-31; ends_on never before starts_on; and no two entries may overlap — inclusively, so a pair sharing one day collides.
The list you send is the list that exists
PUT here is a full representation, the same rule as PUT /lots/{external_id} — and on this route the rule has a consequence worth capital letters: CLOSURES MADE IN THE CONSOLE ARE PART OF THE SAME LIST. If a person types a closure into the console on Tuesday and your nightly sync pushes only its own periods on Tuesday night, the sync removes the person’s closure — silently, correctly, because you told us the list you sent is the whole list.
A machine that owns this route owns the whole calendar. Either read this route back and carry console-authored closures in your own system, or agree with your own staff on which system owns closures. The API will not referee.
curl -X PUT "$BASE/lots/LOT-1/blocked-periods" \
-H "Authorization: Bearer $KEY.$SECRET" \
-H 'Content-Type: application/json' \
-d '{
"blocked_periods": [
{ "starts_on": "2026-11-02", "ends_on": "2026-11-08", "reason": "resurfacing" },
{ "starts_on": "2026-12-24", "ends_on": "2026-12-26" }
]
}'reason, which is allowed.| Field | Type | Required | Notes |
|---|---|---|---|
starts_on | YYYY-MM-DD | yes | The first blocked day, inclusive. A calendar date — a timestamp is refused, not truncated. |
ends_on | YYYY-MM-DD | yes | The LAST blocked day, inclusive — not the day after. Must not precede starts_on. |
reason | string ≤200, or null | no | A label, shown in the console. Blank is refused; null and absent both mean “no reason”. Stored verbatim, never trimmed — a changed space is a changed period. |
Every content refusal on this route is the same code, 422 {"error":"invalid_blocked_periods"}, with field naming the offending entry in your own payload’s coordinates — blocked_periods[3].ends_on. The two envelope-level mistakes keep the codes the rest of the API uses for them: a stray top-level key is unknown_field, a missing blocked_periods key is missing_field.
| What was wrong | `field` |
|---|---|
More than 100 entries, or blocked_periods is not an array | blocked_periods |
Not a real calendar day (2026-02-30), a timestamp, or outside 2020..2032 | the entry’s starts_on / ends_on |
ends_on before starts_on | the entry’s ends_on |
| Two entries overlap (inclusive — sharing one day collides) | the later-starting entry’s starts_on |
reason blank, non-string, or over 200 characters | the entry’s reason |
Periods overlapping sold bookings are NOT refused. A machine sync must not wedge on a car that was sold last week; the bookings stand — a closure stops NEW sales only. Instead the response carries each period’s overlapping_bookings count (semantics above, completed stays included), so your system, and the human behind it, can see exactly which closures have cars already sold into them.
{
"external_id": "LOT-1",
"result": "updated",
"added": 1,
"removed": 1,
"blocked_periods": [
{ "starts_on": "2026-11-02",
"ends_on": "2026-11-08",
"reason": "resurfacing",
"overlapping_bookings": 2 },
{ "starts_on": "2026-12-24",
"ends_on": "2026-12-26",
"reason": null,
"overlapping_bookings": 0 }
]
}201 when the car park previously had no periods at all, 200 otherwise.| Field | Meaning |
|---|---|
result | created (had zero periods, now has some), updated (anything else that wrote — a PUT of [] that emptied the list included), or unchanged. |
added / removed | The row counts the write actually moved. Both 0 on unchanged. |
blocked_periods | The stored list, read back AFTER the write with fresh overlapping_bookings counts — what the database now holds, never an echo of your payload. |
The diff-before-update rule of §2 holds here in list form: result: "unchanged" means the stored list and your payload already said the same thing and no statement was issued at all. A nightly re-push of an unchanged closure list costs one read.
GET /bookings
Your Parkena bookings, as a PULL CURSOR. This cursor is the source of truth: a registered webhook endpoint can receive a signed booking.changed hint telling you to pull it sooner — §13 — but a hint carries no booking data, and nothing you build on this cursor is ever wasted.
| Query parameter | Type | Default | Notes |
|---|---|---|---|
since | string | — | The next token from your previous call. Omit for “from the beginning”. |
limit | integer 1–500 | 200 | Asking for more is refused, not silently reduced. |
curl "$BASE/bookings?since=$CURSOR" -H "Authorization: Bearer $KEY.$SECRET"
{
"bookings": [
{
"reference": "PK0000000040",
"external_reference": "OPS-9911",
"lot_external_id": "LOT-1",
"lot_name": "Terminal Park",
"timezone": "Europe/Berlin",
"check_in_local": "2026-09-01T08:00:00",
"check_out_local": "2026-09-05T19:30:00",
"parking_days": 5,
"status": "confirmed",
"payment_status": "paid",
"currency": "EUR",
"total": "56.00",
"channel": "direct",
"flight_number": "LH401",
"customer": {
"first_name": "Ada",
"last_name": "Lovelace",
"email": "[email protected]",
"phone": "+49301234567"
},
"vehicle_plates": ["BXY4242"],
"created_at": "2026-08-19T15:18:01.099213Z",
"cancelled_at": null
}
],
"next": "eyJ2IjoxLCJ0IjoiMjAyNi0wOC0xOVQxNToxMzowOC44MjQxMDhaIiwi…",
"has_more": false
}| Field | Notes |
|---|---|
reference | PARKENA’s booking reference. This is the de-duplication key. |
external_reference | Your own string, if one was set. Echoed back, never keyed on. |
check_in_local / check_out_local | The car park’s own wall clock, with NO zone offset. Read them in timezone. |
total | A decimal string, not a float. |
status | pending, confirmed, checked_in, completed, cancelled. |
payment_status | pending, paid, partial, refunded, expired, not_required. |
How to poll correctly
cursor = load_saved_cursor() # null on the first run
loop:
r = GET /bookings?since=<cursor>&limit=200
for b in r.bookings:
upsert_into_your_system(b, key = b.reference) # NOT external_reference
cursor = r.next
save_cursor(cursor)
if not r.has_more: sleep(60)Three rules, and each one is load-bearing:
- De-duplicate on
reference. Delivery is at-least-once, never exactly-once. You will see the same booking more than once and that is correct behaviour, not a fault. - Save
nextAFTER you have durably stored the batch, not before. If your process dies mid-batch, the unsaved cursor re-delivers — which rule 1 makes harmless. - Do not construct a cursor. It is a token we issued. There is no
?since=<an instant I chose>, and that absence is deliberate: a partner who named a future instant would silently stop receiving their own bookings, and the first symptom would be a car at a barrier their system has never heard of. A cursor beyond our watermark is clamped back to it, so the worst a tampered token can do is deliver rows twice.
Why you may see a booking again immediately: the cursor we hand back never points past now() − 5 minutes. Bookings newer than that are still returned — you want your booking now, not in five minutes — we simply do not commit the cursor to them. This is not caution for its own sake. A database transaction is stamped with its START time, so a slow transaction can commit a row stamped earlier than one you have already been given; without the lag, a cursor that jumped straight to the newest row would step over it permanently and silently.
8. What costs you a re-review
§2 has the rule; this is the field list behind it. Changing any of the following on PUT /lots/{external_id} voids a pending or approved listing, and the response says so with "listing_review_superseded": true:
name · timezone · handover · capacity · latitude · longitude · city · country_code · features · arrival · media · min_advance_days · min_stay_days · max_stay_days · cancellation_free_until_hours_before_check_in · cancellation_penalty_percent_after · cancellation_no_show_forfeits_full
What does not cost you one
online_bookable. It is your sales switch, not reviewed content. Turning it off takes the car park off sale immediately without voiding anything — which is exactly why it is a required field on everyPUT.- Anything on the rates route that does not widen the envelope. Raising your floor or lowering your ceiling is staying inside a promise you already made. Lowering the floor, raising the ceiling, changing currency, or flipping
dynamicis making a new one, and can supersede the review —envelope_widenedtells you when. valid_from/valid_to. Expiry and renewal are read live, so a window change takes the car park off the storefront and puts it back without a human.
Two traps worth knowing about
- Feature order does not matter to you, but it used to matter to us. We sort
featuresbefore storing them, so["valet","indoor"]and["indoor","valet"]are the same push. You do not need to sort. - Coordinate precision does not matter. We round to six decimal places before comparing, so sending
52.5200001every night does not reportlatitudeas changed forever.
There is no delete
This API cannot delete a car park or a price, and there is no grant that would let it. delete-then-insert is the most common upsert idiom in ingest code, and on this data it is catastrophic: the car park would get a new identity and take its listing history, its prices and its availability with it — from a job nobody was watching. Removing a car park is something a person does in the console, deliberately, where the confirmation names everything that goes with it. A car park that has ever taken a booking cannot be removed at all, by anyone: a booking is a financial record, it is kept, and so is the car park it was taken for. To stop selling one, set "online_bookable": false.
9. Errors
Every error is JSON with a stable machine-readable error code:
{ "error": "invalid_price_envelope", "field": "floor_price" }field, when present, is YOUR OWN field name, echoed back. We never return a database message, a constraint name, or a table name — those are ours to rename, and your integration should not break when we do. Branch on error.
Credential
| Status | Code | Meaning |
|---|---|---|
401 | unauthorized | See below — it covers six different situations and refuses to say which. |
Request shape
| Status | Code | Meaning |
|---|---|---|
404 | not_found | No such ROUTE. Never used for a resource. |
405 | method_not_allowed | Right path, wrong verb. The Allow header names the verb we wanted. |
413 | payload_too_large | Body over 64 KiB. |
415 | unsupported_media_type | Content-Type was not application/json. |
422 | invalid_json | The body was not parseable JSON. |
422 | invalid_body | Well-formed JSON, wrong shape or an unusable value. |
422 | missing_field | A required field was absent. |
422 | unknown_field | A field we do not recognise. Refused, not ignored. |
422 | field_belongs_to_another_route | A field that is real, but is set somewhere else. Today the only one is rates — see §2. |
422 | invalid_query | A bad or unknown query parameter. |
422 | invalid_cursor | The since token was not one of ours. |
422 | too_many_lots | An array body. One request, one car park. |
Content
| Status | Code | Meaning |
|---|---|---|
422 | invalid_timezone | Not an IANA time zone name. |
422 | invalid_coordinates | Out of range, or a latitude without a longitude. |
422 | invalid_country_code | Not ISO 3166-1 alpha-2. |
422 | invalid_stay_bounds | min_stay_days / max_stay_days disagree. |
422 | incomplete_cancellation_policy | One or two of the three cancellation keys. |
422 | invalid_price_envelope | Floor, base and ceiling are not in order. |
422 | invalid_first_day_price | min_first_day_price outside the envelope. |
422 | invalid_validity_window | valid_to precedes valid_from. |
422 | currency_is_not_settlement_currency | Not your settlement currency. |
422 | invalid_blocked_periods | A blocked-periods entry is unusable; field names it in your payload’s own coordinates (blocked_periods[3].ends_on). The refusal table is in §7. |
409 | conflict | A genuine race: two pushes created the same external_id at once. Retry. |
Ours
| Status | Code | Meaning |
|---|---|---|
429 | rate_limited | Over a limit. Honour Retry-After. |
503 | try_again | A transient database conflict. Retry; Retry-After is set. |
500 | internal_error | Our fault. It is in our logs in full. Retrying is reasonable. |
Why the 401 will not tell you more
unauthorized covers all of these and refuses to distinguish them:
- No
Authorizationheader, or one we cannot parse. - A
key_idthat names no credential. - A
key_idthat exists, with the wrong secret. - A credential that is revoked, expired, or whose operator account is suspended.
- A valid credential that lacks the scope this route requires.
- A valid credential naming an
external_idthat is not one of its car parks.
Cases 2–5 are not distinguishable even inside our own process — the database answers all of them identically, with the same work performed, so that nobody can enumerate the operator roster or map which capabilities of a stolen key still work.
Case 6 costs you some comfort and we will say why rather than just assert it: a credential holding only write_supply cannot list car parks. If an unknown external_id answered lot_not_found, that credential would have a working enumeration oracle over exactly the estate its scopes deny it — built out of a refusal. So it gets the same 401.
GET /ping is the answer to this. It tells you your key is live and what it may do, without you having to guess at a 401. If ping succeeds and a route 401s, you are looking at a scope you do not hold or an external_id you do not own — check both against GET /lots.
10. Rate limits, size limits and what we log
| Limit | Value |
|---|---|
| Request body | 64 KiB |
| Car parks per request | 1 |
GET /lots page | 1–200, default 50 |
GET /bookings page | 1–500, default 200 |
Blocked periods per PUT …/blocked-periods | 100 — one list, one car park |
Rate limits are token buckets — a burst capacity that refills continuously.
| Bucket | Counted against | Burst | Refill |
|---|---|---|---|
| All requests | source address | 240 | 4 / second |
| Failed authentications | source address | 20 | 1 per 3 seconds |
| Reads | credential | 120 | 2 / second |
| Writes (burst) | credential | 60 | 1 / second |
| Writes (hourly) | credential | 1000 | 1000 / hour |
A refusal is 429 with a Retry-After header in whole seconds. Being refused does not spend a token, so retrying does not push your own recovery further away.
Writes are bounded twice, and the hourly window is the one that matters. A leaked write key zeroing out an estate looks exactly like a legitimate nightly sync — same credential, same route, same shape, same hour of the night — so a burst limit alone cannot tell them apart, because a real sync is also a burst. The hourly ceiling bounds how much of an estate one stolen key can rewrite before a person could plausibly be looking. An operator with 1000 car parks pushing each once a night fits inside it; if your estate is larger, ask us and we will raise it rather than have you work around it.
The blocked-periods routes spend the same buckets as everything else: the GET costs one read token, and the PUT is a write, charged against both write buckets — a nightly closure sync counts against the same 1000-per-hour ceiling as your lot pushes, deliberately, because a leaked key withdrawing an estate from sale is exactly the shape that ceiling exists to bound.
Failed authentications are counted against THE SOURCE ADDRESS, never the key_id presented. Counting them against the key would be a denial-of-service aimed at you: key_id is the non-secret half and appears in logs and configuration files by design, so anyone who read one could lock you out of your own integration with a few dozen wrong secrets.
Note that an unknown external_id also spends failure budget, because it returns the same 401 as everything else. A limiter that treated the two differently would be an existence oracle built out of a 429. If you are carrying a stale mapping, reconcile it against GET /lots rather than probing.
What we log
Every request produces one log line on our side; every write that changed something produces a second, carrying the FIELD NAMES that changed and whether the change cost you a review. Field names, never values — the log answers “what happened to this estate last night”, and your prices are not in it. Your key_id is, which is how you can ask us which of your integrations did something. Your secret never is, in any form.
11. Before your car park can sell
Pushing a car park through this API creates it, but a new car park does not start selling on parkena.com the moment the API returns 201. Some of what is required is content this API can supply, and some of it is a decision a person has to confirm in the console.
A complete PUT /lots/{external_id} plus a PUT …/rates satisfies the capacity, price, geography, city/country and cancellation-policy requirements. Still outstanding, and only doable in the console:
- Confirming the time zone and the booking rules — they are derived and defaulted, and a plausible wrong answer misprices bookings silently, so a person confirms them once.
- Arrival guidance and a hero image, for the public listing.
- Accepting the Parkena listing agreement, once for the whole account.
- Submitting the car park for review.
That last one is deliberate: submitting a car park to a human reviewer is a statement you make about your business, and a machine holding a key should not be able to make it on your behalf.
The console shows every requirement, whether it is satisfied, and what it protects. This is not a limitation we intend to remove in v1.
12. What v1 does not do
Stated plainly, because an integration built on an assumption we never made is worse than one built on a documented gap.
- No live availability numbers. You can now withdraw whole date ranges from sale with
PUT /lots/{external_id}/blocked-periods— §7 — but there is still no way to push “spaces free tonight” as a number, andcapacityis total spaces — putting live availability in it will misreport your car park and re-review it nightly. A count-based availability calendar is not in v1. - No webhook payloads. A registered endpoint receives the signed
booking.changedHINT of §13 — a booking reference and nothing else — and the pull cursor remains the source of truth, exactly as this list promised before the hint existed. What there still is not: per-event payloads (no booking data ever rides in a webhook), ordering guarantees (hints coalesce and retry; sequence is the cursor’s job), or a replay API (nothing to replay — re-pull the cursor). An integration must work with hints switched off, because a hint that fails five deliveries dies quietly and on purpose. - No writing bookings. You cannot create, amend, cancel, check in or refund a booking through this API. There is no scope for it and no grant behind it.
- No payments. No charges, no refunds, no payout data, no commission figures. The booking feed carries the sale total and currency and nothing about how the money moved.
- No OTA or channel-manager distribution. This API writes the
parkenachannel only. It is not a channel manager and does not push to anyone else. - No deletes. See §8.
- No bulk endpoint. One request, one car park.
- No listing submission or review state. You cannot submit for review or read your review status through the API.
listing_review_supersededandenvelope_widenedare the only review-adjacent signals, and the second is deliberately conservative. - No tenant selection. There is no
tenant_idin any body or query string this API parses. Which operator a request acts on is resolved from the credential and from nothing else — a request-supplied operator id would be a cross-tenant write key. - No caller-supplied timestamps, anywhere. No
as_of, nowatermark, noupdated_since. The only dates you may send arevalid_fromandvalid_to, which are calendar days you are declaring about your own price. Everything else is our clock.
13. Webhooks: the `booking.changed` hint
You can register one HTTPS endpoint per operator account, and we will POST it a signed hint whenever a booking of yours is created or changes — any channel, any field. Read the warning below before you design anything around it.
A hint is not data. The cursor is the data.
The entire body of a hint is an event name, a booking reference and a timestamp. No status, no dates, no traveller, no amounts — nothing your system could act on directly, and nothing that goes stale in transit. The correct and only response to a hint is the thing your integration already does: pull GET /bookings with your saved cursor.
A partner whose endpoint is down for a day loses latency, never data — the cursor re-delivers everything on the next poll. If your integration cannot survive with the hints turned off, it is built wrong.
That division of labour is why §12 no longer says “no webhooks”: what we refused to ship was a webhook that CARRIED the booking, because a webhook that silently fails is a booking you never hear about while the car still arrives at your barrier. A hint can fail silently and cost you nothing.
The delivery
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Parkena-Webhooks/1
Parkena-Signature: t=1767139200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
#
{"event":"booking.changed","booking_reference":"PK0000000040","occurred_at":"2026-08-27T09:15:12.114532Z"}booking.changed, one name for create, amend and cancel alike, because the hint does not say what happened; the cursor does.booking_referenceis the samereferencethe booking feed carries — feed it to your de-duplication, exactly as you de-duplicate cursor rows.occurred_atis when the change was recorded on our side, not when this attempt was sent. Retries re-send it verbatim.
Successive changes to the same booking COALESCE while a hint for it is still undelivered: five edits in a minute produce one ping, and that is correct precisely because the hint carries no state — however many writes it stands for, your next cursor pull sees the final row. Delivery is at-least-once, like everything else on this API: you may receive two hints for one change, and de-duplicating on booking_reference makes that free.
Registering an endpoint, and the rules it must satisfy
Endpoints are registered in the console, on the same Account → API keys page where credentials are issued, by an owner or manager — not through this API, for the same reason keys are not. The signing secret (whsec_ followed by 64 hex characters) is generated in your browser and shown ONCE: we store it to sign with, but no console screen and no query can ever read it back. One endpoint may be active per account in v1. An endpoint is never edited — a new URL or a rotated secret is a new endpoint (disarm the old one first); disarming is the one switch the console offers after birth.
- HTTPS only, port 443 only.
http://, and any explicit port other than 443, is never attempted. - A hostname, not an address. IP literals (v4 or v6),
localhostand anything on our own platform’s domains are refused. - Redirects are never followed. A redirect is a second URL nobody vetted; the attempt fails instead.
- Five-second timeout, and beyond the status code your response is never read. Answer fast and do the work after — the right shape is “enqueue and return 204”.
The 2xx contract, retries, and death
Any 2xx inside the timeout means delivered. Anything else — a 4xx, a 5xx, a timeout, a refused connection — is retried on a fixed backoff, and after the fifth failed attempt the hint is dead: the last HTTP status and failure reason are recorded and visible in the console, and no further attempt is made. The booking itself is, as ever, waiting on the cursor. Disarming an endpoint kills its pending hints at the next sweep rather than delivering them later to an endpoint you switched off.
| Failed attempts so far | Next attempt |
|---|---|
| 1 | after 1 minute |
| 2 | after 5 minutes |
| 3 | after 30 minutes |
| 4 | after 2 hours |
| 5 | none — the hint is dead, with its last status and reason recorded |
Verifying the signature
Every delivery carries one Parkena-Signature header: t=<unix-seconds>,v1=<hex HMAC-SHA-256>. The signed payload is the literal string t + . + the RAW request body — sign the bytes you received, never a re-serialisation of the parsed JSON. This is deliberately the exact scheme Stripe uses for its webhooks, whsec_ secret prefix included, so any Stripe-webhook verifier you already run — or the one published in Parkena’s own repository — verifies these deliveries unchanged.
# header: "t=1767139200,v1=5257a869…" secret: "whsec_…" exactly as shown once
const pairs = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = hexHmacSha256(secret, `${pairs.t}.${rawBody}`);
const fresh = Math.abs(nowSeconds() - Number(pairs.t)) <= 300;
const ok = fresh && timingSafeEqual(pairs.v1, expected);Three details a quick implementation gets wrong: compare with a CONSTANT-TIME equality, not ===, so response timing does not leak how much of a forged signature was right; reject a t older than a few minutes — we recommend 300 seconds — which is what makes a captured delivery worthless to replay later; and if you parse the header properly rather than splitting naively, verify against EVERY v1= pair present and accept if any matches — that is what keeps a future signing-secret rotation from breaking you mid-window.
A hint that fails your verification is not a Parkena delivery. Answer it 401 and do nothing else — in particular, do not pull the cursor on its say-so schedule. Pulling the cursor is always SAFE; refusing is about not letting an unauthenticated caller drive your system’s timing.
14. Getting access, and getting help
Keys are issued in the console by an owner or manager under Account → API keys. There is no sandbox, and access to the pilot is arranged with us one operator at a time — if what you have read here fits the system you already run, that is the thing to say when you write.
Write to [email protected]. For support on an integration that is already running: quote your key_id — never your secret — and the external_id and timestamp of the request you are asking about. Both appear in our logs, and together they identify a single request.
Ask about the pilot.
Tell us what your system is and what you want it to push. We will tell you honestly whether v1 covers it — §12 is the complete list of what it cannot do, written out in full so that you can decide against it before you build anything.
