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.
Secret keys (sk_live_… / sk_test_…)
Section titled “Secret keys (sk_live_… / sk_test_…)”- 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.
Secret key scopes
Section titled “Secret key scopes”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.
| Scope | What it unlocks |
|---|---|
availability:read | GET /v1/availability, GET /v1/resources, GET /v1/resources/{id}, GET /v1/resources/{id}/calendar, GET /v1/resources/{id}/slots |
quote:read | POST /v1/quotes |
hold:create | POST /v1/holds, POST /v1/holds/{id}/confirm, POST /v1/holds/{id}/otp, POST /v1/holds/{id}/otp/verify |
checkout:create | POST /v1/holds/{id}/checkout-session |
payment:intent:create | POST /v1/holds/{id}/payment-intent |
bookings:read | GET /v1/bookings, GET /v1/bookings/{id}, GET /v1/metrics/summary, and the calendar’s occupants (guest details) |
bookings:write | POST /v1/bookings, PATCH /v1/bookings/{id}, DELETE /v1/bookings/{id}, POST /v1/bookings/{id}/confirm |
payment:refund:create | POST /v1/bookings/{id}/refunds |
payments:read | GET /v1/payments |
rates:write | PUT /v1/resources/{id}/rates, POST /v1/rates/bulk |
restrictions:write | PUT /v1/resources/{id}/restrictions |
resources:write | POST /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:manage | channel connections, channel conflicts, and iCal feed tokens. Not available yet: these routes answer 503 channel_unavailable. See Errors. |
webhooks:manage | GET/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), andcheckout:create(a Stripe-hosted checkout for that hold). On the calendar endpoint specifically, apk_key never receives a night’soccupants(guest id, label, state) — that field issk_/operator-session only. - Must be sent from an allowlisted origin. We check the browser’s
Originheader 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.
Allowed origins for a publishable key
Section titled “Allowed origins for a publishable key”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.
| Entry | What it allows |
|---|---|
https://shop.example.com | Exactly that site. |
https://*.example.com | Any subdomain, such as https://app.example.com or https://a.b.example.com. Not https://example.com itself. The scheme must match. |
http://localhost:3000 | Local 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 (
443forhttps,80forhttp) are ignored.https://Shop.Example.com:443andhttps://shop.example.comare the same origin. - Any other port must match exactly.
- A wildcard matches whole subdomains only.
https://evil-example.comandhttps://example.com.evil.comdo not matchhttps://*.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.
Lock a secret key to your server IPs
Section titled “Lock a secret key to your server IPs”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/24is refused; write203.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
404with the codeip_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-IPheader only.X-Forwarded-Foris 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 throughcheckout:create(apk_-reachable hosted-checkout handoff — what the widget uses). - The operator confirming it as paid offline, with
POST /v1/bookings/{id}/confirm. That call needs ansk_key withbookings:write, or a dashboard admin. Apk_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.
Test mode
Section titled “Test mode”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.
Getting a key
Section titled “Getting a key”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.
Next steps
Section titled “Next steps”- Quickstart — your first request with a real key.
- Errors — how a wrong-key-kind request comes back (spoiler:
404, not403). - Rate limits — the limits above, in full.