Skip to content

Authentication

There is no single “API key”. Each credential class grants a bounded set of capabilities and nothing else, and the server derives the tenant and the acting user from the credential — never from the request body.

Credential How it is sent Who holds it What it can do
Publishable key pk_… x-api-key + x-tenant-id Storefronts, browser bundles Browse offerings, read availability, create a booking intent, make a customer-present purchase
Operator API key sk_op_… x-api-key + x-tenant-id Back-office integrations, scripts, agents Whatever scopes it was minted with, intersected with the live membership of the staff member who minted it
Staff session (JWT) Authorization: Bearer … The operator app, the console, a person at a keyboard Everything that member’s role allows, including minting operator keys (Admin/Owner)
Customer session (JWT) Authorization: Bearer … The customer portal /account/* only — a customer’s own bookings, quotes and readiness. Carries zero operator authority
Content keys pk_… / sk_… x-api-key A tenant’s website build / CMS tooling pk_ reads published content and media metadata; sk_ writes content types, entries, publications and media
Capability token In the URL (/g/{token}, /b/{token}, /p/{token}, /w/{token}) A guest who received a link Exactly the one action the link was issued for

A publishable key is safe to embed in a browser. Pair it with the tenant id on every request:

x-api-key: pk_live_…
x-tenant-id: 00000000-0000-0000-0000-000000000001

It is verified against that tenant; a key presented with another tenant’s id is rejected 401 invalid_key. Missing both headers is 401 unauthenticated. Operator-scoped routes do not accept a publishable key at all (403), so a leaked storefront key cannot read bookings or customers.

An operator key is minted by an Admin or Owner from a staff session and shown once:

Terminal window
curl -s "$QS_BASE/operator-keys" \
-H "Authorization: Bearer $STAFF_SESSION" \
-H "content-type: application/json" \
-d '{ "label": "nightly-reconciliation", "scopes": ["bookings.read", "manifest.read"] }'

The response carries the raw key (sk_op_…) and its operator_key_id; only a digest is stored. Use it like a publishable key, with the tenant id:

x-api-key: sk_op_…
x-tenant-id: 00000000-0000-0000-0000-000000000001

Two rules shape what a key can do:

  • Scopes are an upper bound. The key’s effective authority is its scopes ∩ the current membership of the member who minted it. If that member is removed, the key stops working.
  • Some things need a human. Minting keys, administering the tenant, and writes that carry permission gates (for example commerce.price_override) require a staff session, not a key.

Revoke with POST /operator-keys/{key_id}/revocation. Revocation is immediate and idempotent.

Scopes a key can be minted with: bookings.read, manifest.read, participants.read, capacity.read, conditions.read (reads) and bookings.write, participants.write, proposals.write, conditions.write, policies.write (writes; underwritten only by an Admin/Owner membership). Unknown scopes are refused 422.

Quikstik has no passwords. A staff member signs in by email:

  1. POST /login/requests with x-tenant-id and {"email": "…"}. Always 202, whether or not the email belongs to an account — existence is never disclosed.
  2. The member receives a single-use link valid for a few minutes.
  3. POST /login/sessions with {"token": "…"} → 201 {"session_token": …, "scopes": […], "role": …}.

Sessions are short-lived JWTs. Send one as Authorization: Bearer …. An expired session is the one failure the server names (401 session_expired) so a client knows to re-authenticate; every other session failure is 401 invalid_session.

A client that must stay signed in (the operator app) passes a device object on POST /login/sessions. The response then also includes a 90-day refresh_token, shown once. POST /login/refresh trades it for a fresh session JWT; the refresh token is the credential, so that call is unauthenticated by design and rate-limited. GET /login/devices lists the caller’s devices and POST /login/devices/{device_id}/revoke stops a device from refreshing — a JWT already minted lives out its short TTL.

GET /me returns who the session is and the scopes and role the membership authoriser grants it now.

Customers use the same magic-link flow at /customer-login/requests and /customer-login/sessions. The resulting session’s subject is a person, not a staff member, and it is accepted only on /account/*. A customer session at a staff route, or a staff session at a customer route, fails as if the route did not exist.

Content endpoints under /merchants/{merchant_slug}/… take a tenant-scoped key in x-api-key and resolve the tenant from the merchant slug in the path — no x-tenant-id header. pk_ keys read; sk_ keys write. The two are distinct credential classes behind distinct verifiers: an sk_ key is refused on a read (401), and vice versa. See Content.

Guest links put the authorisation in the URL. GET /w/{token} (readiness / waiver), /g/{token} (group share), /b/{token} (balance), /p/{token} (party / riders) each resolve to exactly one booking and one action; the token is stored as a digest, and an unknown, expired or consumed token is a 404 indistinguishable from a wrong one. No header is needed. See Booking lifecycle → guest links.