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.write on 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/token is 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 for client_id + client_secret in the body instead. To try the API with your dashboard-minted qc_… token, skip /oauth/token and 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 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 }
    ]
  }
}

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.