Skip to content

Authentication: secret keys vs publishable keys

Every request to /v1 carries one API key as a bearer token:

Authorization: Bearer sk_live_...

or

Authorization: Bearer pk_live_...

There are two kinds. Using the wrong one for the job is the single most common integration mistake — read this page before you write any code.

  • Live only on your server. Never send one to a browser, a mobile app, or a log you don’t control.
  • Can do anything your operator granted: read guest details, create and cancel bookings, manage rates.
  • Default rate limit: 300 requests/minute, burst 50.
  • Can be locked to the IP addresses of your servers. See Lock a secret key to your server IPs.

Your operator picks one or more scopes when they issue a secret key. A route that needs a scope your key does not hold returns 404 with the code insufficient_scope. Ask for the smallest set your integration needs.

ScopeWhat it unlocks
availability:readGET /v1/availability, GET /v1/resources, GET /v1/resources/{id}, GET /v1/resources/{id}/calendar, GET /v1/resources/{id}/slots
quote:readPOST /v1/quotes
hold:createPOST /v1/holds, POST /v1/holds/{id}/confirm, POST /v1/holds/{id}/otp, POST /v1/holds/{id}/otp/verify
checkout:createPOST /v1/holds/{id}/checkout-session
payment:intent:createPOST /v1/holds/{id}/payment-intent
bookings:readGET /v1/bookings, GET /v1/bookings/{id}, GET /v1/metrics/summary, and the calendar’s occupants (guest details)
bookings:writePOST /v1/bookings, PATCH /v1/bookings/{id}, DELETE /v1/bookings/{id}, POST /v1/bookings/{id}/confirm
payment:refund:createPOST /v1/bookings/{id}/refunds
payments:readGET /v1/payments
rates:writePUT /v1/resources/{id}/rates, POST /v1/rates/bulk
restrictions:writePUT /v1/resources/{id}/restrictions
resources:writePOST /v1/resources, PATCH /v1/resources/{id}, POST /v1/room-types, PUT /v1/resources/{id}/slot-rules, PUT /v1/resources/{id}/hourly-pricing, PUT /v1/resources/{id}/opening-hours
channel:managechannel connections, channel conflicts, and iCal feed tokens. Not available yet: these routes answer 503 channel_unavailable. See Errors.
webhooks:manageGET/POST /v1/webhook-endpoints, DELETE /v1/webhook-endpoints/{id}

Any secret key can read room types, rates, restrictions, slot rules, hourly pricing, and opening hours (GET /v1/room-types, GET /v1/resources/{id}/rates, GET /v1/resources/{id}/restrictions, GET /v1/resources/{id}/slot-rules, GET /v1/resources/{id}/hourly-pricing, GET /v1/resources/{id}/opening-hours) without a scope. These responses hold no guest details and no payment data.

Keys cannot call the dashboard-only routes: /v1/api-keys, /v1/users, /v1/tenant, /v1/tenant/opening-hours, and /v1/audit. They need a dashboard sign-in. A key that calls them gets 404 key_auth_not_configured.

Publishable keys (pk_live_… / pk_test_…)

Section titled “Publishable keys (pk_live_… / pk_test_…)”
  • Safe to embed in a public page — that’s the point. It’s what the widget uses.
  • Hard-capped, on our server, to four scopes: availability:read (availability, resources, the per-night calendar, and the hourly slots), quote:read (a quote), hold:create (create a hold, and its guest-verification and confirm calls), and checkout:create (a Stripe-hosted checkout for that hold). On the calendar endpoint specifically, a pk_ key never receives a night’s occupants (guest id, label, state) — that field is sk_/operator-session only.
  • Must be sent from an allowlisted origin. We check the browser’s Origin header against the origins registered for that key; a request from any other origin is refused. See Allowed origins for a publishable key.
  • Tighter rate limit: 60 requests/minute per key+IP, plus a separate 10/minute cap per key+IP on hold creation, plus a cap on how many holds can be active at once (2 per key+IP, 50 per tenant). These stop one visitor from squatting all your inventory with holds. See Rate limits.

Each pk_ key has a list of allowed origins. An origin is scheme://host with an optional port. It has no path and no trailing slash.

EntryWhat it allows
https://shop.example.comExactly that site.
https://*.example.comAny subdomain, such as https://app.example.com or https://a.b.example.com. Not https://example.com itself. The scheme must match.
http://localhost:3000Local development. http is allowed only for localhost and 127.0.0.1.

How we compare:

  • Scheme and host are not case-sensitive. The default ports (443 for https, 80 for http) are ignored. https://Shop.Example.com:443 and https://shop.example.com are the same origin.
  • Any other port must match exactly.
  • A wildcard matches whole subdomains only. https://evil-example.com and https://example.com.evil.com do not match https://*.example.com.
  • A bare *, a wildcard on a single label (https://*.com), and entries with a path are refused when you save them.
  • A list can hold at most 50 entries.

A wildcard is opt-in. It trusts every subdomain, so use it only for a domain where you control all the subdomains.

For a stolen sk_ key, the IP allowlist is the real protection. A secret key can do much more than a publishable one, and it runs on your own servers, which have stable IP addresses. If you lock the key to those addresses, a thief who copies the key cannot use it from anywhere else.

  • An entry is an IPv4 or IPv6 address, or a CIDR range (a block of addresses): 203.0.113.7, 203.0.113.0/24, 2001:db8::/32.
  • In a range, the part after the prefix must be zero. 203.0.113.7/24 is refused; write 203.0.113.0/24.
  • IPv4-mapped IPv6 addresses (::ffff:203.0.113.7) count as the IPv4 address.
  • A list can hold at most 50 entries.
  • No list means any IP. Keys without a list keep working as before. Add a list to turn the protection on.
  • A request from an address that is not on the list gets 404 with the code ip_not_allowed. The check runs before the rate limit, so a thief cannot use up your key’s rate limit.
  • We read the caller’s address from Cloudflare’s CF-Connecting-IP header only. X-Forwarded-For is ignored, because a caller can set it to anything.
  • Publishable keys have no IP list. They run in your guests’ browsers, and guests have no fixed address.

Your operator sets the list when the key is issued. They can change it later without issuing a new key. They use PATCH /v1/api-keys/{id} (a dashboard sign-in, not an API key) or operator-cli update-key-allowlist (--allowed-ips for a secret key, --allowed-origins for a publishable key, --clear-ips to allow any IP). The key itself does not change, so your running integrations keep working. The new rule applies on the next request. A request to PATCH must send allowedIps for a secret key and allowedOrigins for a publishable key. The wrong one gets 400 allowlist_kind_mismatch.

Why a hold from a pk_ key isn’t a booking

Section titled “Why a hold from a pk_ key isn’t a booking”

A hold made with a publishable key is unconfirmed inventory only. Turning it into a confirmed booking needs one of two things:

  • A succeeded card payment — either through the sk_-only PaymentIntent route (your own server-side checkout) or through checkout:create (a pk_-reachable hosted-checkout handoff — what the widget uses).
  • The operator confirming it as paid offline, with POST /v1/bookings/{id}/confirm. That call needs an sk_ key with bookings:write, or a dashboard admin. A pk_ key can never make it.

Either way, the check happens on our server and Stripe’s, never in the browser. That server-side gate is what makes it safe to put a pk_ key in a page anyone can view-source.

Card payment (Stripe) is live: confirming a hold requires a succeeded payment, checked server-side. See Holds and confirmation for the full mechanics.

sk_test_… and pk_test_… hit the exact same API, against the operator’s sandbox. Reservations made with a test key are flagged test, and payments run in Stripe’s test mode. Build and test against _test_ keys; switch to _live_ keys only when you go to production — the request shape doesn’t change, only the prefix.

There’s no sign-up form for you as an integrator. Your operator contact — an admin on the tenant’s dashboard — issues a live key and hands you the plaintext once, at creation. For a test key, they ask us (see above).

If a key is compromised, tell your operator contact immediately — they can revoke it from their dashboard, and revocation takes effect on the very next request.

  • Quickstart — your first request with a real key.
  • Errors — how a wrong-key-kind request comes back (spoiler: 404, not 403).
  • Rate limits — the limits above, in full.