The domain model

Five nouns carry the whole API. Everything else hangs off them.

Property  (external_property_id)
 ├─ Room type            "Double Room", "Family Apartment"
 │   └─ × Rate plan  →  a PAIR — where the pricing mode lives
 ├─ Rate plan            "Standard B&B" (parent) ← "Non-refundable" (child)
 └─ Calendars
     ├─ availability     per room type × date  (rate-independent)
     └─ prices + restrictions
                         per room type × rate plan × occupancy code × date

Property

The hotelier's property, addressed everywhere by its opaque external_property_id (a UUID QC assigns). You never see Quendoo's internal ids. GET /properties lists the properties your token is scoped to — it is the discovery call and needs no scope.

Properties can be created by the partner through POST /properties, or by the hotelier from the Quendoo dashboard. Either path lands the same underlying legacy row plus a QC mapping; the API answer is the same. Fetch the list of valid type_id values first via GET /property-types — it's the reference-data discovery endpoint the create call needs.

Each property has a QC mode — legacy, shadow, live or disabled — that gates whether your inventory pushes actually write. Partner-created properties default to shadow (safer than legacy for a fresh channel); ops flips to live when the mapping is validated. Either the hotelier or the integrator can PATCH the mode. See inventory writes.

Every property also carries an optional partner_code — a slot for the partner's own identifier so they can address the property by their code instead of our UUID (see the bidirectional-id contract in the guide). Group-scoped uniqueness — a real code cannot repeat within one owner's group. Null is the default; either side can PATCH it later.

Room type

A bookable unit category with a count (qty) and bed capacities (adults / children / infants). Addressed by external_room_type_id.

Rate plan — and the pair

A sellable tariff ("Standard Bed & Breakfast", "Non-refundable"). A rate plan is sold per room type: the combination of one room type and one rate plan is a pair, and the pair is where the pricing mode (sell type) is chosen. Because of that, the API returns rate plans flattened across rooms: a plan attached to three room types appears as three entries, each with its own room_type_id and its own external_rate_plan_id.

Parent and child rate plans

A rate plan can derive from a parent (parent_rate_plan_id, exactly one level deep — a child cannot have children):

Practical consequence: push prices to the parent, push restrictions to whichever plan they belong to. See pricing.

Calendars

Two, with different granularity — this asymmetry is load-bearing:

Calendar Granularity Holds
Availability room type × date open state, physical qty, offered qty, booked qty
Prices & restrictions room type × rate plan × occupancy code × date nightly price, min/max LOS, stop-sell, CTA/CTD, …

Availability is rate-independent: one number of sellable rooms per room type per date, shared by all its rate plans. Prices and restrictions are per pair, per occupancy code.

Your own catalogue and the mapping

QC also stores your side of the world: PUT /catalogue declares the rooms, rates, services, meals and beds your PMS has, by your own codes. Mapping your codes onto Quendoo entities is then a PATCH with partner_code on the room type or rate plan. The integration guide walks both steps.

Bidirectional-ids

Every URL segment that identifies a QC entity accepts EITHER the canonical external UUID QC minted OR the partner's own partner_code for that entity. This works today for the property, room-type and rate-plan levels — the three anchors your PMS touches most.

Practical shape:

GET /v1/qc/properties/{ext_property_id | partner_code}
GET /v1/qc/properties/.../room_types/{ext_room_type_id | partner_code}
GET /v1/qc/properties/.../rate_plans/{ext_rate_plan_id | partner_code}

Resolution order: exact match on the canonical UUID first (fast index hit), fallback to partner_code on miss (also unique per scope). Empty / null partner_code never matches — a room type without a partner code cannot be picked up by an empty URL segment.

Practical upside: once you push partner_code at onboarding your PMS can use its own catalogue ids everywhere in the URL — GET /properties/PMS-HOTEL-4211/room_types/PMS-STANDARD-QUEEN. The response still carries our UUIDs; store them if you care about a stable id across a hotelier renaming their side.

Mode lifecycle

Every property carries a mode field on its map that gates the inventory write path.

Mode Writes When to be here
legacy ignored Property is still owned by the old channel manager; QC pushes short-circuit into dry. Safe placeholder while onboarding a partner alongside the incumbent.
shadow validated + accounted, NOT applied Every write full-validates, response says "dry": true. The parity harness compares what we WOULD have written to what legacy wrote. Diff score must be zero before flip to live.
live applied Real writes hit the calendar; hotelier and guests see the effect immediately.
disabled rejected Emergency stop. Property temporarily out of scope for QC — the mapping row stays, writes error, hotelier can still see the property in the dashboard.

Transitions are one-way in practice: legacy → shadow → live. disabled can freeze from any state. live → shadow is possible but rare (post-incident audit); asks a PATCH with an explanation that lands in the property audit log.

Occupancy taxonomy — default vs Qc-Rich-Occupancy

GET /properties/{ext}/room_types/{ext} returns occupancy as a simple {adults, children, infants} triple by default — the Booking-style taxonomy every OTA understands.

Partners who need the Quendoo-native taxonomy (multiple child age bands, teen slot, extra-bed vs regular-bed distinction) send the Qc-Rich-Occupancy: true request header on the READ, and get back a richer occupancy object naming every group Quendoo tracks.

Writes always accept the simple triple; the rich form is a read convenience for PMSs that model bed layouts in detail.

See Pricing & occupancy codes for how those groups map to price rows.