Sell CityLockers locker capacity from your own product: discover locations, price a window, create and manage bookings, and hand your customer an access code.
This file is GENERATED from
docs/api/partner-api.openapi.yamland must not be edited by hand. It reproduces that file'sinfo.descriptionin full, so this bundle, the rendered reference page and the Postman collection cannot describe different APIs. A hand edit is reverted by the next build and failsnpm run spec:postman.
| file | what it is |
|---|---|
README.md |
This file. |
partner-api.html |
The full API reference, rendered. Open in a browser. |
partner-api.openapi.yaml |
The OpenAPI 3.1 source every other file here is generated from. Point a client generator at this. |
partner-api.postman_collection.json |
Postman collection — all 11 operations, auth wired once at collection level. |
partner-api.postman_environment.json |
Postman environment — production host; bearerToken is empty, paste your key in. |
Start with partner-api.html — open it in a browser for the full endpoint-by-endpoint
reference. It renders offline; only its interactive chrome needs the network.
Then import partner-api.postman_collection.json and
partner-api.postman_environment.json into Postman. Set bearerToken in the environment to
the key you were issued. It ships empty, and no file in this bundle contains a credential.
Idempotency-Key is wired to Postman's {{$guid}} on create and extend, so every send
generates a fresh key. Leave it that way. A key is spent by its first outcome, including a
rejection, and a replay returns the stored result without re-reading your request body — so
pinning it to a literal means your first call burns it and every later call replays that outcome.
The Direction A partner API lets a partner sell CityLockers locker capacity inside its own product. Every call is authenticated with a partner API key, every result is scoped to the partner that key belongs to, and every response — success or failure — uses the same envelope.
Every response body is a JSON object carrying ok.
ok: true— the operation succeeded. The rest of the object is the operation's payload.ok: false— the operation was refused.reasonis always present and is a machine-readable string from the vocabulary under Reason codes.erroris sometimes present and is a human-readable sentence; branch onreason, never onerror, and never on the HTTP status alone.
There is no separate error object and no errors array. A refusal is never
delivered as a bare HTTP status with an empty body.
- Ask your CityLockers contact for a partner API key. Keys are issued by us; there is no self-service signup. The key is what defines which locations you may sell, which operations you may call, and which booking windows you are allowed to offer.
- Call
GET /bootstraponce, at startup, and configure your UI from what it returns — your locations, the locker sizes offered at each, your period policy, your granted operations, and the mode of the key you hold. Do not hard-code any of it. Every one of those is a property of the key and can change without a release on your side. - Price a window with
GET /quoteand check stock withGET /availabilitybefore you show a price or a "book" button. Neither creates anything. - Create the booking with
POST /bookings, carrying anIdempotency-Key. You get back a bookingreferenceand, where one exists, the customer'saccess_code. - Manage it afterwards with
GET /booking,POST /extend,POST /cancel,POST /end,POST /resend-codeandPOST /reissue-code.
There is one host and it is production. A test key mode exists in the
data model, but the partner sandbox it refers to has not been built, so
no test key can currently be issued and every key you are given is live
against real lockers, real money and real customer notifications. Ask for a
throwaway location rather than assuming a safe environment exists.
Keep the key on your server. Never put it in a browser, a mobile app, a URL or a repository. It is a bearer credential: whoever holds it is you.
bootstrap ──▶ availability ──▶ quote ──▶ create ──▶ (extend | cancel | end)
once per search per per per booking
at startup window basket
bootstrap is a startup call, not a per-request one. availability and
quote answer different questions and neither is authoritative: create is
the only gate. A window availability happily counts and quote happily
prices can still be refused by create — period_not_allowed if it falls
outside your key's period policy, unavailable if a concurrent booking took
the compartment first, invalid if it is under the engine's real floor.
Treat a create refusal as normal control flow, not as an exception.
The API is one Edge Function invoked at /partner-bridge. The sub-route is
the trailing path segment of the request URL, and the method then selects
the operation (GET /bookings lists, POST /bookings creates). This is
described here faithfully rather than reshaped.
Two consequences a generated client must respect:
- A bare
POSTwith no path segment resolves tobookings(create).POST /partner-bridgeandPOST /partner-bridge/bookingsare the same operation. This is documented below as the path/and was proven live — a real second booking was created through a request with no trailing segment at all. It is an alias, not a twelfth endpoint. - An unrecognised segment, or a recognised segment with the wrong method,
returns
404 not_found— the same reason string a missing booking reference produces. A 404 therefore does not by itself mean "no such booking".
Authorization: Bearer <key> on every call, including the ones that can fail
before the key is checked (see the create endpoint's security note).
A raw key is kpk_<mode>_<8hex>_<64hex>, e.g.
kpk_live_1a2b3c4d_<64 hex characters>. Only the fourth segment is secret.
The first three segments together are the key's non-secret prefix
(kpk_live_1a2b3c4d); it is what the server stores alongside the SHA-256
hash of the full key, what the rate limiter buckets on, and the only part
safe to quote in a support ticket or a log line.
mode is a property of the key itself, live or test, and is the source
of truth for which environment the credential belongs to. It is surfaced to
you on GET /bootstrap as the top-level mode field. Only live keys are
minted today; the test sandbox is a separate, not-yet-built partner
environment, so a test key cannot currently be issued.
Legacy two-segment keys (kpk_<8hex>_<64hex>) no longer authenticate.
Verification derives the prefix from the first three segments, so a
two-segment key yields a prefix that matches no stored row and is rejected
as unauthorized.
Verification hashes the full raw key with SHA-256 and constant-time compares
it against the stored hash, scoped to an active key belonging to an active
partner. Anything else — no key, malformed key, revoked key, suspended
partner — is 401 unauthorized.
60 requests per minute per key prefix, over-limit → 429 rate_limited. The
limiter is an in-process token bucket: it resets when the function instance
cold-starts and is not shared across instances, so the effective limit is a
floor, not a guarantee. Treat 429 as retryable with backoff. This behaviour
is expected to be replaced by a durable shared counter; do not build a client
that depends on the current leniency.
Idempotency-Key is required on the two operations that move money:
POST /bookings (create) and POST /extend. It is not read on any other
endpoint. A missing or blank header is 400 invalid before any work is done.
Replaying a key that has already been used returns the earlier request's outcome instead of acting twice.
A replay returns LIVE CURRENT STATE, not a frozen echo of the original
response. This is the single most important thing to understand about
replay on this API, and it was proven live: a create replayed after the
booking had been extended and then cancelled came back with
status: "cancelled" and total: 18, where the original create had returned
status: "active" and total: 9. The create replay branch re-reads the
booking and customer rows at replay time. So on a create replay,
status, total and access_code describe the booking now;
only reference and replayed: true are stable facts about the original
request. (POST /extend differs: its replay returns the stored result
object of the original extension, stamped replayed: true.)
A replay of a request that was rejected returns the original rejection
reason with the original status. A replay that arrives while the first
request is still in flight returns 409 in_progress.
Send either duration_minutes or end_time, not both. They are
alternative spellings of the same window and create accepts both:
duration_minutes— an integer count of minutes fromstart_time. If you send both fields,duration_minuteswins andend_timeis ignored.end_time— an ISO-8601 instant. The window is then derived fromstart_time(or from now, if you omitstart_time).
Omit start_time to mean "now". GET /availability and GET /quote take
only duration_minutes, so if your UI works in end times you convert before
you price.
The floor is 180 minutes, not the 60 the edge function accepts. This is
the single most common way a first integration fails, and it fails late:
the edge function rejects anything under 60 minutes outright, but the booking
engine's smallest chip is 180 minutes, so a 60–179 minute request passes
edge validation, reaches the engine, and comes back 400 invalid with no
hint that duration was the problem. If you offer sub-3-hour windows in your
UI, they will all fail. Above a day, the window must be a whole number of
1440-minute blocks or it is invalid_duration.
The full reason vocabulary the code can emit is:
| reason | usual status | meaning |
|---|---|---|
unauthorized |
401, sometimes 403 | no usable key, or the partner is not active |
capability |
403 | the partner lacks the inbound direction capability |
out_of_scope |
403 | the location is not in this key's scope |
not_permitted |
403 | the partner is not granted this operation |
not_found |
404 | no such booking for this partner, or no such route |
unavailable |
409 | nothing free / nothing priceable for the request |
in_progress |
409 | a concurrent replay of the same Idempotency-Key |
invalid_state |
409 | the booking is not in a state this operation accepts |
window_not_expired |
409 | see the note below — unreachable today |
invalid |
400 | malformed or missing input |
invalid_duration |
400 | the duration is structurally wrong |
period_not_allowed |
400 | the partner's period policy forbids this window |
rate_limited |
429 | over the per-key request budget |
server_error |
500 | an internal failure; retry is reasonable |
reason alone does not determine the HTTP status, which is why this spec
models reason→status per endpoint per branch instead of publishing one global
table. The concrete case: unauthorized is 401 when it comes from a
route's own key-verification branch (the common case — no key, bad key), and
403 when it is returned by a wrapper RPC's partner-not-active branch
and mapped by the generic reason→status mapper. The two have never been
observed colliding, because key verification already scopes to an active
partner, so the 403 flavour is only reachable if a partner is deactivated in
the window between verification and the operation's own read. It is
nonetheless a real branch in shipped code, and a client keyed on a single
global reason→status table will mis-handle it. Each operation below lists
only the reasons that operation can actually produce, and each response says
which branch produces it.
Two further notes on the enum:
window_not_expiredis unreachable. It is documented here for completeness of the vocabulary only. The window-expiry guard on ending a booking was deleted in migration20260728140000_end_booking_early.sql, whose own header records that the reason string was left in the status maps deliberately, to avoid needless API churn.POST /endcompletes a live booking immediately, before its window closes — proven live. It is not listed as a response ofPOST /endanywhere in this document, and it will only become reachable if that deleted guard is reinstated.period_not_allowedis real and was missing from the earlier written accounts of this API.POST /bookingsreturns it (as a400) when the requested window falls outside the partner's own intraday/multiday policy.
A key's partner holds a set of granted operations. GET /bootstrap returns
them as granted_operations, an array of objects — { key, kind, label },
not an array of bare strings. The key values are the grant keys, and
each callable endpoint below names the one it requires in x-grant-key.
| grant key | kind | endpoint |
|---|---|---|
locations |
read | GET /bootstrap |
availability |
read | GET /availability |
quote |
read | GET /quote |
bookings.list |
read | GET /bookings |
booking.read |
read | GET /booking |
booking.create |
write | POST /bookings (and bare POST /) |
booking.cancel |
write | POST /cancel |
booking.end |
write | POST /end |
booking.extend |
write | POST /extend |
booking.resend_code |
write | POST /resend-code |
booking.reissue_code |
write | POST /reissue-code |
Calling an endpoint whose grant the partner does not hold is
403 not_permitted. A grant is checked inside the operation, so a missing
grant is reported after authentication, never instead of it.
Three further operation keys exist in the server-side registry but are not
built and have no endpoint: booking.open, booking.add_lockers,
booking.overstay. They are listed under x-planned-operations at the root
of this document so a client can recognise the strings, and they deliberately
appear nowhere as a path. If one of them shows up in your
granted_operations, there is still nothing to call.
- Customer identity is first-write-wins by email. Creating a booking
resolves the customer by email (or phone) and reuses the existing row if
one matches. A later create under the same email with a different
namedoes not update the stored name, and the response echoes the stored one. Proven live: a create sending"E2E Rerun B"came back with the earlier"E2E Rerun A". Do not use the returnedcustomer.nameto confirm what you sent. access_codeis not always present on a create. See the create response schema — it isstringornull, and the two conditions that producenullare named there.resend-codeandreissue-codedo not email the customer. They return the code in the response body and nothing else leaves the platform. Delivering it to the end customer is the partner's job. (reissue-codeadditionally enqueues a device event so the physical keypad learns the new code; until that is consumed, the OLD code still opens the door.)GET /bookingsis capped at 100 rows, newest first, with no pagination. There is no cursor, nooffsetand no total count.- Times are UTC ISO-8601 instants. Multi-day quoting anchors on the Asia/Dubai business date, which can make a multi-day quote indicative rather than exact; a create charges from the real booking start.
Creating a booking sends us your customer's contact details — an email address, or a phone number, and optionally a name — because a booking resolves to a customer record on our side and a confirmation notification is sent to that address. That makes both parties handlers of personal data under the UAE Personal Data Protection Law (Federal Decree-Law No. 45 of 2021), and a short data-handling understanding forms part of the partner agreement. Agree it before you send live traffic, not after.
Practically, three rules:
- Customer data goes in the request body, over HTTPS, never in a URL. No endpoint here takes a customer identifier as a query parameter, and a URL is logged in places a body is not.
- Send the minimum that identifies the customer. One of
emailorphoneis required;nameis optional and is only used for the booking record. - Customer identity is first-write-wins by email — see Behaviours that
surprise integrators. The record we keep may not carry the name you sent,
so do not treat the echoed
customerobject as confirmation of what you submitted.
Every example in this document is illustrative. The response examples are reproduced from a real end-to-end run against the dev branch project on 2026-08-15, with every identifier, address, code and reference replaced by an obviously synthetic placeholder. No example contains a real customer email, a real access code, a real partner key or a real booking reference. Do not treat any identifier here as callable, and do not copy an example UUID into a request.