Authentication, scopes and limits
Concrete uptime, response-time, rate limits, and support-response times live in the SLA. This page covers the mechanics of authenticating, scoping and using rate-limit budgets.
Three independent axes, not one checkbox. A QC token carries scopes (categories of action), properties (which properties it may act on) and endpoint scopes (which specific routes it may hit, with per-resource wildcards). Every request must clear all three, so an integrator role can hold
inventory.writeon 40 properties but only for the availability endpoint. Most vendors give you "read all or write all" and call it security β see the differentiator breakdown on Why Quendoo Connect.
Two ways to obtain a Bearer
Every call to /v1/qc/* carries Authorization: Bearer <token>. The
Bearer itself can come from either of two flows β pick the one that
matches how you are calling the API today:
| Flow | Who it is for | How you get a Bearer |
|---|---|---|
Dashboard-minted personal API token (qc_β¦) |
A hotelier / agency / small partner running scripts, dashboards, quick integration work | Open the Quendoo dashboard β Pem systems β Quendoo Connect β API tokens β Add, copy the shown token once (one-time reveal). Sits in your dashboard until you revoke it. |
| OAuth 2.0 client credentials (JWT) | A machine-to-machine partner PMS/CM with a client_id + client_secret provisioned by Quendoo. Credentials are issued by Quendoo on request β there is no self-service form; contact [email protected]. |
POST /oauth/token with your credentials, the response carries the Bearer. Tokens live 1 hour β refresh proactively. |
Both Bearers work on every other endpoint. Pick one and send it as
Authorization: Bearer <token>.
Testing on the interactive reference. The Bearer field at the top of the reference is applied to every endpoint that opts in.
POST /oauth/tokenis deliberately NOT one of them β that endpoint MINTS a Bearer, it does not consume one, so pasting your dashboard token there will not authenticate the request; the endpoint asks forclient_id+client_secretin the body instead. To try the API with your dashboard-mintedqc_β¦token, skip/oauth/tokenand open, say,GET /propertiesβ the Bearer will substitute correctly.
OAuth 2.0 client credentials
Exchange your client_id / client_secret for a JWT:
curl -X POST https://staging-api.quendoo.com/v1/qc/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=3f2504e0-4f89-41d3-9a0c-0305e82c3301" \
-d "client_secret=YOUR_SECRET"
{ "access_token": "eyJhbGciOiβ¦", "token_type": "Bearer", "expires_in": 3600 }
Send it on every call as Authorization: Bearer <token>. Tokens live one
hour β refresh proactively, and treat a 401 as "get a new token", not as a
retry signal. Public JWKS for verification: /.well-known/jwks.json.
Tokens are scoped to specific properties. GET /properties returns exactly
the set you can touch β empty list means your token has no properties yet, not
that the API is broken.
Account-scoped tokens
Two ways to answer "which properties can this token touch":
| Mode | How the allow-list is chosen | When to reach for it |
|---|---|---|
| Fixed (default) | The property ids you passed at token / OAuth-client creation are frozen onto the credential. A property enrolled later needs a rotate to become reachable. | You are integrating a single hotel, or you deliberately want to hand a partner access to a specific subset. |
| Account-scoped | The token/client carries no fixed list; every request resolves your account's currently enrolled, active properties in real time. New properties become reachable within the next request. | You are a chain / brand mint one credential now, keep enrolling hotels later. This is also the shape most partner PMSes want: one login per operator, hotels come and go. |
Mint an account-scoped bearer:
curl -X POST https://staging.quendoo.com/api/v1/pem-systems/qc/api-tokens \
-H "Authorization: Bearer $DASHBOARD_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "PMS ACME production",
"scopes": ["inventory.write","bookings.read"],
"account_scoped": true
}'
property_ids in the request body is ignored whenever account_scoped: true β
the response echoes account_scoped: true and the persisted allow-list is []
(the middleware fills in the live set on every request). For an OAuth
client-credentials flow, an account-scoped client returns property_ids: null
and account_scoped: true in the POST /oauth/token response so integrators
can tell "the list is dynamic" from "the list is empty (deny-all)".
Security: the resolution is always bounded to the customer's own QC groups β
a token can never see across accounts. A disabled property or a paused group
drops out of scope at the very next request. The scope grants (inventory.write
etc.) are still checked per route: account_scoped widens the property
allow-list, never the vocabulary.
Rotate the token if you want to move between modes β the flag is set at creation and does not flip on an existing credential.
Scopes
| Scope | Grants |
|---|---|
inventory.read |
reading room types, rate plans, availability, rates |
inventory.write |
the three POST /inventory/* doors |
properties.manage |
property metadata, creating/mapping room types & rate plans, the catalogue (implies the inventory reads) |
bookings.read |
bookings, the revision feed, acks |
bookings.write |
cancel / no-show |
subscriptions.manage |
webhook subscriptions CRUD |
groups.manage |
property groups |
analytics.read |
the six read-only GET /analytics/* reports β overview, traffic, funnel, campaigns, bookings, pace (see Analytics via QC) |
GET /properties and GET /properties/{id} need no scope β they are the
discovery calls. Calling an endpoint without its scope returns 403 with
errors.qc.insufficient_scope.
A typical PMS integration asks for: inventory.read inventory.write properties.manage bookings.read subscriptions.manage. Add analytics.read
only if you surface reporting.
Endpoint-level scoping
Scopes are coarse β inventory.write opens all three write doors,
properties.manage opens every configuration surface. A token can be narrowed
below its scopes to an explicit set of endpoints with endpoint_scopes.
The two layers are ANDed: a request must clear the route's scope and
appear on the token's endpoint allow-list.
endpoint_scopes is a list of route names. Each entry is either:
- an exact name β
qc.promotions.storeadmits that one route only; or - a per-resource wildcard β
qc.promotions.*admits every promotions route (index, show, store, update, delete) that exists now or ships later.
An empty endpoint_scopes (the default) means "no endpoint restriction" β
the token reaches every route its scopes allow. That is what a normal
integration wants; reach for endpoint_scopes only to mint a deliberately
narrow credential (a partner who may manage promotions and nothing else, a
read-only reporting token, β¦).
It only ever narrows. endpoint_scopes cannot grant a route the token's
scopes don't already cover: a qc.promotions.* entry on a token without
properties.manage still gets 403 errors.qc.insufficient_scope. The scope
check runs first; the endpoint check runs only on a request that already
passed it. A mis-populated list can lock a token out β never let it in.
The wildcard boundary is the dot
qc.promotions.* matches by the prefix qc.promotions. β the trailing dot is
the resource boundary, so a wildcard can never leak into qc.promotions_v2.*
or any neighbour. That is what makes wildcards safe to hand out: qc.promotions.*
means exactly "promotions, plus whatever we add to it", so a future
qc.promotions.duplicate route is already covered β no re-issue, no gap.
Set it at mint time
endpoint_scopes is chosen when the token (or OAuth client) is created β from
the dashboard token UI or its API β and is frozen onto the credential; rotate
to change it. Every entry is validated against the live catalogue (below) at
mint time, so a typo'd or unknown route name is rejected outright and a token
can never carry a rule that silently matches nothing:
curl -X POST https://staging.quendoo.com/api/v1/pem-systems/qc/api-tokens \
-H "Authorization: Bearer $DASHBOARD_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "Promo bot (staging)",
"scopes": ["properties.manage"],
"endpoint_scopes": ["qc.promotions.*"]
}'
Discover the routes β GET /v1/qc/permissions
The catalogue that endpoint_scopes entries name is introspected straight from
the route table β never a hand-maintained list, so it cannot drift. A new
endpoint appears the moment its route gets a qc.<resource>.<action> name. Any
authenticated QC token may read it; it needs no scope and exposes no property
data (it lists shapes, not data):
curl https://staging-api.quendoo.com/v1/qc/permissions \
-H "Authorization: Bearer $TOKEN"
{
"status": "ok",
"message": "ok",
"data": {
"endpoints": [
{ "name": "qc.analytics.overview", "methods": ["GET"], "uri": "/properties/{external_property_id}/analytics/overview", "scope": ["analytics.read"] },
{ "name": "qc.promotions.index", "methods": ["GET"], "uri": "/properties/{external_property_id}/promotions", "scope": ["properties.manage"] },
{ "name": "qc.promotions.store", "methods": ["POST"], "uri": "/properties/{external_property_id}/promotions", "scope": ["properties.manage"] }
],
"resources": [
{ "resource": "analytics", "wildcard": "qc.analytics.*", "endpoint_count": 6 },
{ "resource": "promotions", "wildcard": "qc.promotions.*", "endpoint_count": 5 }
]
}
}
endpoints[]β every authenticated route, sorted byname.nameis the exact string anendpoint_scopesentry matches;scopeis the QC scope the route requires (empty for the handful of scope-free discovery reads).resources[]β one row per resource with itswildcardand endpoint count β the natural granularity for a "tick which resources this token may touch" UI.
A request blocked by the endpoint layer alone (scope OK, route not on the list)
returns 403 with errors.qc.endpoint_not_allowed β distinct from
errors.qc.insufficient_scope, so a client can tell "my token lacks the scope"
apart from "my token is narrowed away from this route".
Rate limits
Per client, sliding window. Every response carries X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Reset; exceeding a budget returns
429 with errors.qc.rate_limit_exceeded β back off until the reset.
| Bucket | Per minute | Per day |
|---|---|---|
All GET endpoints |
300 | 100 000 |
POST /inventory/* (each door) |
20 | 5 000 |
| Booking actions | 60 | 20 000 |
POST /oauth/token |
10 | 500 |
The inventory budgets are comfortable if you batch: one range row covers a whole period, one request carries up to 1000 rows. If you are hitting 20/min, you are almost certainly looping per date β don't.
Idempotency
Every write door honours the Idempotency-Key header. Send a unique key
(a UUID) per logical operation; if the connection drops, replay with the
same key and you get the cached response instead of a double write. Keys are
remembered for 24 hours. Reusing a key with a different body returns
errors.qc.idempotency_conflict.
curl -X POST β¦/inventory/availability \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 018f3c1e-7a2b-4c5d-9e8f-6a1b2c3d4e5f" \
-H "Content-Type: application/json" \
-d '{ "values": [ β¦ ] }'
Webhook signing
Outbound webhooks are signed: X-Qc-Signature-256: sha256=<hex> is the
HMAC-SHA256 of "<timestamp>.<body>" under your subscription's signing
secret, with X-Qc-Timestamp in Unix seconds. Reject deliveries older than
5 minutes. The secret is shown once, on subscription creation β rotate it
via POST /subscriptions/{id}/rotate-secret if you lose it.