Your booking
English
Find parking

Operator API · v1

Push your car parks. Pull your bookings.

The reference for an operator connecting their own system to Parkena. Everything below describes software that is deployed and answering — including the parts that say no.

Read this before you plan a project around it.

This is a complete and accurate reference. An owner or manager on your operator account issues and revokes keys in the console, under Account → API keys — the secret is shown once. What is not open is the rest: there is no sandbox, and we connect one operator at a time, so write to us before you plan a build around it. What v1 refuses to do is written out in full in §12 rather than left for you to discover in week three.

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 }
Abbreviated responses from the deployed function. The full shape is in §7.

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.json

Send 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_idpk_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.
  • secretpk_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
      }'
ParameterTypeNotes
p_labelstring, 1–80 charsWhat you will call this key in six months. Required.
p_scopesarray of scopeNon-empty set of distinct scopes. Required.
p_expires_in_daysinteger 1–3650, or nullNull 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"
}]
The response — and the only time the secret is ever returned.

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:

  1. Issue a second credential with the same scopes.
  2. Deploy it to your system and confirm traffic is flowing — GET /ping with the new key, then watch last_used_at on it.
  3. 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:

ScopeWhat it permits
read_supplyList 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_supplyCreate and amend car parks, their Parkena prices, and their blocked-period lists.
read_bookingsPull 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

MethodPathScope required
GET/pingany valid credential
GET/lotsread_supply
PUT/lots/{external_id}write_supply
PUT/lots/{external_id}/rateswrite_supply
GET/lots/{external_id}/blocked-periodsread_supply
PUT/lots/{external_id}/blocked-periodswrite_supply
GET/bookingsread_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 parameterTypeDefaultNotes
afterstringResume after this external_id. Use the next_after from the previous page.
limitinteger 1–20050Asking 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.

FieldTypeRequiredNotes
external_idstringnoIf present it must equal the one in the path. Checked, not used.
namestring, ≤200yes
timezonestringyesIANA name, e.g. Europe/Berlin. Every day-boundary calculation resolves through it.
handoverstringyesself or attended.
online_bookablebooleanyesYour sales switch. Required precisely because defaulting it would take a live car park off sale on the first partial push.
capacityinteger 0–1000000noTOTAL spaces. Not spaces free tonight — see the warning below.
latitudenumber or stringnoRounded to 6 decimal places. Must be sent with longitude.
longitudenumber or stringnoRounded to 6 decimal places. Must be sent with latitude.
citystring, ≤120no
country_codestringnoISO 3166-1 alpha-2, e.g. DE.
featuresarray of stringnoSee the list below. Order does not matter — we sort them.
arrivalobjectnoFree-form arrival guidance.
mediaobjectnoFree-form media references.
min_advance_daysinteger 0–365noDefaults to 0.
min_stay_daysinteger 1–365no
max_stay_daysinteger 1–365noAll three stop at 365, which is how far ahead Parkena quotes a stay at all. A larger value would be accepted and never honoured.
cancellationobject or nullnoAll 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
        }
      }'
A complete push.
{
  "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.
FieldMeaning
resultcreated, updated, or unchanged.
changedThe field names that actually differ. Empty on unchanged.
listing_review_supersededtrue if this write voided your Parkena listing review — see §8.
lotThe 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" }
FieldTypeRequiredNotes
fixed_pricedecimalone shape or the otherCannot be combined with the range fields.
floor_pricedecimalwith the rangeMust be ≤ base_price.
base_pricedecimalwith the rangeMust be between floor and ceiling.
ceiling_pricedecimalwith the rangeMust be ≥ base_price.
min_first_day_pricedecimal or nullnoMust lie within the envelope.
currencystringnoISO 4217. Defaults to your settlement currency, and cannot be anything else.
dynamicbooleannoDefaults to false.
valid_fromYYYY-MM-DD or nullnoA calendar date. A timestamp is refused, not truncated.
valid_toYYYY-MM-DD or nullnoMust 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 }
  ]
}
Periods come back earliest first. 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" }
        ]
      }'
A full replace. The second period carries no reason, which is allowed.
FieldTypeRequiredNotes
starts_onYYYY-MM-DDyesThe first blocked day, inclusive. A calendar date — a timestamp is refused, not truncated.
ends_onYYYY-MM-DDyesThe LAST blocked day, inclusive — not the day after. Must not precede starts_on.
reasonstring ≤200, or nullnoA 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 arrayblocked_periods
Not a real calendar day (2026-02-30), a timestamp, or outside 2020..2032the entry’s starts_on / ends_on
ends_on before starts_onthe 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 charactersthe 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.
FieldMeaning
resultcreated (had zero periods, now has some), updated (anything else that wrote — a PUT of [] that emptied the list included), or unchanged.
added / removedThe row counts the write actually moved. Both 0 on unchanged.
blocked_periodsThe 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 parameterTypeDefaultNotes
sincestringThe next token from your previous call. Omit for “from the beginning”.
limitinteger 1–500200Asking 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
}
FieldNotes
referencePARKENA’s booking reference. This is the de-duplication key.
external_referenceYour own string, if one was set. Echoed back, never keyed on.
check_in_local / check_out_localThe car park’s own wall clock, with NO zone offset. Read them in timezone.
totalA decimal string, not a float.
statuspending, confirmed, checked_in, completed, cancelled.
payment_statuspending, 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:

  1. 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.
  2. Save next AFTER you have durably stored the batch, not before. If your process dies mid-batch, the unsaved cursor re-delivers — which rule 1 makes harmless.
  3. 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 every PUT.
  • 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 dynamic is making a new one, and can supersede the review — envelope_widened tells 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 features before 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.5200001 every night does not report latitude as 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

StatusCodeMeaning
401unauthorizedSee below — it covers six different situations and refuses to say which.

Request shape

StatusCodeMeaning
404not_foundNo such ROUTE. Never used for a resource.
405method_not_allowedRight path, wrong verb. The Allow header names the verb we wanted.
413payload_too_largeBody over 64 KiB.
415unsupported_media_typeContent-Type was not application/json.
422invalid_jsonThe body was not parseable JSON.
422invalid_bodyWell-formed JSON, wrong shape or an unusable value.
422missing_fieldA required field was absent.
422unknown_fieldA field we do not recognise. Refused, not ignored.
422field_belongs_to_another_routeA field that is real, but is set somewhere else. Today the only one is rates — see §2.
422invalid_queryA bad or unknown query parameter.
422invalid_cursorThe since token was not one of ours.
422too_many_lotsAn array body. One request, one car park.

Content

StatusCodeMeaning
422invalid_timezoneNot an IANA time zone name.
422invalid_coordinatesOut of range, or a latitude without a longitude.
422invalid_country_codeNot ISO 3166-1 alpha-2.
422invalid_stay_boundsmin_stay_days / max_stay_days disagree.
422incomplete_cancellation_policyOne or two of the three cancellation keys.
422invalid_price_envelopeFloor, base and ceiling are not in order.
422invalid_first_day_pricemin_first_day_price outside the envelope.
422invalid_validity_windowvalid_to precedes valid_from.
422currency_is_not_settlement_currencyNot your settlement currency.
422invalid_blocked_periodsA 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.
409conflictA genuine race: two pushes created the same external_id at once. Retry.

Ours

StatusCodeMeaning
429rate_limitedOver a limit. Honour Retry-After.
503try_againA transient database conflict. Retry; Retry-After is set.
500internal_errorOur 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:

  1. No Authorization header, or one we cannot parse.
  2. A key_id that names no credential.
  3. A key_id that exists, with the wrong secret.
  4. A credential that is revoked, expired, or whose operator account is suspended.
  5. A valid credential that lacks the scope this route requires.
  6. A valid credential naming an external_id that 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

LimitValue
Request body64 KiB
Car parks per request1
GET /lots page1–200, default 50
GET /bookings page1–500, default 200
Blocked periods per PUT …/blocked-periods100 — one list, one car park

Rate limits are token buckets — a burst capacity that refills continuously.

BucketCounted againstBurstRefill
All requestssource address2404 / second
Failed authenticationssource address201 per 3 seconds
Readscredential1202 / second
Writes (burst)credential601 / second
Writes (hourly)credential10001000 / 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, and capacity is 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.changed HINT 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 parkena channel 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_superseded and envelope_widened are the only review-adjacent signals, and the second is deliberately conservative.
  • No tenant selection. There is no tenant_id in 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, no watermark, no updated_since. The only dates you may send are valid_from and valid_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"}
One event in v1 — booking.changed, one name for create, amend and cancel alike, because the hint does not say what happened; the cursor does.
  • booking_reference is the same reference the booking feed carries — feed it to your de-duplication, exactly as you de-duplicate cursor rows.
  • occurred_at is 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), localhost and 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 farNext attempt
1after 1 minute
2after 5 minutes
3after 30 minutes
4after 2 hours
5none — 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);
The whole of it, as a sketch.

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.