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. To rehearse a write against your real data without keeping it, add ?dry_run=true — §7.

A machine-readable description of v1 — every route, scope, parameter, request and response schema, error code and rate limit — is published as an OpenAPI 3.1 document at https://api.parkena.com/openapi.json. It needs no credential, and it is generated from the code that serves the API, so a client generator or a contract test can read it instead of this page.

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 — on the routes under an existing car park, its rates and its blocked periods — 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. PUT /lots/{external_id} is not one of those routes: an id none of your car parks has is not refused there, it creates the car park.

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 — and both need the X-Tenant-Id header naming the operator account you are acting for, exactly as the console sends it. A person can belong to more than one operator, so neither call guesses: without the header (or with an account you are not an accepted owner or manager of, or one that is not active) both refuse with api_credential_requires_manager, the same answer a staff login gets. So does a sign-in that has not finished its second step: once a user has turned on two-step sign-in, only a token whose aal claim is aal2 acts for any operator, and a password-only token acts for none. Your operator id is on the console’s Settings page, under Set by PARKENA → Your operator id. The calls 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 "X-Tenant-Id: <your operator id>" \
  -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 "X-Tenant-Id: <your operator id>" \
  -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.

Rehearsing a write: ?dry_run=true

?dry_run=true is accepted on the three PUT routes and nowhere else. The write runs in full — parsing, validation, the diff, the same statements and triggers, inside the same transaction a real write uses — and is then rolled back. The answer is the real write’s report, word for word, plus "dry_run": true. Nothing in your estate changes, and a listing review the report says would be voided is not voided.

curl -X PUT "$BASE/lots/LOT-1?dry_run=true" \
  -H "Authorization: Bearer $KEY.$SECRET" \
  -H 'Content-Type: application/json' \
  --data @lot.json

{ "external_id": "LOT-1", "result": "updated", "changed": ["name"],
  "listing_review_superseded": true, "lot": { "…": "…" }, "dry_run": true }
A rename, rehearsed: the review would be voided, and nothing was written.
  • One spelling: the literal true. Absent means a real write; anything else — false, 1, True, an empty value, the parameter twice — is 422 invalid_query naming dry_run, so a request you meant as a test never falls through to a real write.
  • Always 200, even where the real write would answer 201 — nothing was created. result still says what the write would have done.
  • Refused exactly as the real write would be, with the same code, because it is the real write, stopped before it commits.
  • It spends the burst write bucket and not the hourly ceiling (§10), so you can rehearse a push of your whole estate and then run it in the same hour. It is a snapshot of your data at that moment; the key is still recorded as used, and both of our log lines are marked dry_run.

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
afterstring—Resume 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. An external_id none of your car parks has is not refused — it creates one: 201 (200 on a dry run, which keeps nothing), never the uniform 401. Ids are per operator, so another operator’s id is simply new to you, and a mistyped id is a second car park rather than an error.

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.
arrivalobjectnoArrival guidance. An entrance, access or gateWindow key satisfies the listing requirement — §11.
mediaobjectnoMedia references. A heroUrl at an https:// address satisfies the hero-image requirement, but parkena.com displays only photos stored with Parkena. A string that names lot-media outside your own folder is refused, whatever the host: 422 invalid_body, field media — §11.
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”. These keys do not set the terms of a Parkena sale — every booking Parkena sells carries Parkena’s own cancellation terms, the same for every car park — but they are part of what your reviewer approved, so changing them still voids the review (§8).

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
sincestring—The 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": "ada.lovelace@example.test",
        "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, on a route under an existing car park — its rates or its blocked periods. PUT /lots/{external_id} never gives this answer: an id you do not hold creates the car park there.

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 — from any bucket: a write that one write bucket refuses is not charged to the other — so retrying does not push your own recovery further away.

Every bucket above is counted in the memory of the edge worker that serves your request — held_in: "edge-worker-memory" in the OpenAPI document: per worker, not shared between workers, and reset when a worker is replaced, which on our hosting happens at least every 400 seconds. So the buckets are a brake on bursts and on guessing, not a global quota — and for the hourly write window that matters, as the paragraph after next explains.

“Source address” is the address you connect to us from. Called directly, it is the address Cloudflare sees connecting. Called through api.parkena.com, it is your address as our gateway received it, which the gateway forwards to the function with a signature the function checks before believing it — the function never takes an address from a header on trust. (Until that signature is configured on both sides, every partner on api.parkena.com presents the gateway’s single address and the two source-address buckets are shared there; the failure bucket cannot cost you a request you authenticated either way.) An IPv6 address is counted as its /64 — the block one line is assigned — so an IPv6 client is one source, not 2^64 of them; an IPv4 address is itself.

Writes are bounded twice — and, today, the burst is the bound that holds. 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 was designed to bound how much of an estate one stolen key can rewrite before a person could plausibly be looking. Be clear about what it does: because it is counted per worker and no worker of ours lives for an hour, it does NOT cap a key at 1000 writes per clock hour. What reliably holds is the burst window: 60 writes at once and then one a second, per worker. Plan for that pace — an operator with 1000 car parks pushing each once a night is inside it in about sixteen minutes — and treat a leaked write key as a key to revoke at once (§5), not as one the ceiling will contain. If your estate is larger than 1000 car parks, ask us before your first push.

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 shares one write budget with your lot pushes, deliberately, because a leaked key withdrawing an estate from sale is exactly the shape the write budget exists to slow. A ?dry_run=true write spends the burst bucket only (§7).

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 on the rates and blocked-periods routes 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 produces a second, carrying the FIELD NAMES that changed (none, on unchanged) and whether the change cost you a review; a dry run’s two lines are marked dry_run. 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 every content requirement: a capacity above zero, a price in force, a map pin, a city and a country, arrival guidance (arrival with an entrance, access or gateWindow) and a hero image (media.heroUrl at an https:// address). parkena.com displays only photos stored with Parkena, which are uploaded in the console’s Photos step: a heroUrl on any other website satisfies the requirement, but travellers see a placeholder instead of the picture. lot-media is the path of Parkena’s photo store, and a photo stored there sits in your own folder, …/lot-media/<your operator>/…, which the Photos step uploads into. So every string in media that contains lot-media — read percent-decoded — must have /<your operator>/ directly after each lot-media in it, with no . or .. segment after it, whatever the host; anything else is refused with 422 invalid_body, field media, and nothing is stored. That includes another operator’s photo, and a path on your own website that happens to contain lot-media. There is no cancellation-policy requirement: every Parkena sale carries Parkena’s own terms. 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. Changing timezone later — through this API or in the console — withdraws that confirmation, and the car park takes no new booking until someone confirms the new zone in the console.
  • Connecting the account’s payouts with Stripe, under Settings — no car park of the account lists until Stripe reports that it can receive and pay out your share of each sale.
  • Accepting the Parkena listing agreement, once for the whole account.
  • Submitting the car park for review, after which a Parkena reviewer approves it.

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 sandbox. There is no test base and no test operator account; ?dry_run=true on the three PUT routes rehearses a write against your real data and rolls it back — §7.
  • 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.
  • A hostname that resolves to the public internet. Immediately before each attempt we look the name up (A and AAAA) and send nothing if any answer is a private, loopback, link-local, shared (CGNAT), multicast, documentation or otherwise non-public address — the attempt records private_address. A name with no address records is unresolvable; a lookup that fails is dns_failed and is retried like any other failure. This narrows, and does not close, DNS rebinding: the connection resolves the name again, so treat it as a guard against misconfiguration and casual abuse, not as a network boundary.
  • 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 info@parkena.com. 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.