Configuration surfaces

The whole hotel, not just inventory. Off-the-shelf channel APIs stop at ARI (availability, rates, inventory). QC keeps going — payment policies, cancellation policies, promotions, extras, gallery, website content, auto-emails, review replies, age groups, taxes, room-type structure, rate-plan derivation, subscriptions. A partner PMS can onboard a hotel end-to-end without asking anyone to open the Quendoo dashboard. See Why Quendoo Connect for how this reshapes what integrators can promise their customers.

The pricing, availability and bookings loop is the daily-heartbeat side of the integration. Around it sits the configuration — policies, promos, ancillaries, child-age bands, website content, review replies — that a hotelier normally builds once and edits rarely. QC exposes all of it as first-class CRUD so a partner PMS can migrate a hotel without asking anyone to open the Quendoo dashboard.

Two invariants hold across every surface below:

Writes ride the shared Idempotency-Key contract (authentication & limits); reads ride the plain auth chain.

Property-scoped vs owner-scoped

Two URL shapes across the configuration surfaces:

Shape Surfaces Why
/properties/{external_property_id}/… extra services, promotions, payment policies, cancellation policies, age groups, content pages, guest feedback, booking-buttons discovery Every row lives against one property.
Top-level (/gallery, /auto_email_templates) gallery, auto-email templates A customer's image library and their auto-email set are shared across all their properties; the customer id is the scope.
Nested by booking-button (/booking_buttons/{pbb_id}/special_offers) special offers An offer belongs to one booking-button (a property can carry several buttons).

The auth chain still checks that every resource resolved from an id belongs to the OAuth client's granted property set — even when the URL doesn't carry a property id.

Extra services

Per-property ancillaries (airport transfer, breakfast add-on, ...). Standard CRUD:

GET    /v1/qc/properties/{ext}/extra_services
POST   /v1/qc/properties/{ext}/extra_services
GET    /v1/qc/properties/{ext}/extra_services/{id}
PATCH  /v1/qc/properties/{ext}/extra_services/{id}
DELETE /v1/qc/properties/{ext}/extra_services/{id}

Two things to know:

Promotions

Discount rules with elaborate targeting. The domain has three promotion types encoded on the type field:

Cross-field rules live in a single validator shared with the dashboard's own request: resv_min_abo_days ≤ resv_max_abo_days, same for hours, stay_conditions[i].stay_min_days ≤ stay_max_days, and — QC-only — resv_from_time < resv_to_time with "00:00" and "24:00" as midnight sentinels.

Payment & cancellation policies

Two sibling resources, similar shape.

Payment policies carry a payments[] array of instalment steps. Each step declares a pay_type (on_res, af_res, be_arr, on_arr, on_dep, be_dat) that says WHEN and an amount_type (tp, pf_tp, pf_fn, fn, rest, fixed, free) that says HOW MUCH. Conditional requireds:

DELETE returns 409 errors.qc.payment_policy_in_use when at least one rate plan still references the policy.

Cancellation policies carry a name_tr bag, four numeric knobs (par_type, par_value, deadline_days, pad_type, pad_value) and a free-form data[] array of rules (cap 50). Each rule needs c_type (which cancellation window triggers it) and p_type (what penalty kind); the visibility-driven fields (p_days_from, p_days, p_date, p_value) are optional at the API boundary — validate them on your side against the reference-store map. Same 409 in-use behavior as payment policies.

Age groups

The property's child-age bands — the brackets the pricing engine uses to route a guest of age N into infant / child / teen pricing. Three fixed codes, each with its own age range and on/off flag:

Code Meaning
I infant
C child
T teen
GET  /v1/qc/properties/{external_property_id}/age-groups
PUT  /v1/qc/properties/{external_property_id}/age-groups

Scope: properties.manage. The wire shape uses booleans and numeric ages (the internal '0'/'1' string form is cleaned up at the boundary):

{
  "is_active": true,
  "groups": [
    { "code": "I", "is_active": true,  "from_age": 0,  "to_age": 1.99 },
    { "code": "C", "is_active": true,  "from_age": 2,  "to_age": 11.99 },
    { "code": "T", "is_active": false, "from_age": 12, "to_age": 17 }
  ]
}

The top-level is_active toggles child pricing as a whole; each group's own is_active toggles that one bracket. Ages run 0..99 and may be fractional (1.99, so an infant ends the instant a 2nd birthday begins). A group whose from_age exceeds its to_age is rejected with 422 errors.qc.age_group_range_inverted.

PUT is a partial, idempotent merge. Send only the keys you want to change — omitted groups and omitted fields keep their stored value — and the same body twice leaves the same state. The response echoes the post-write state plus a preview block, so a UI can show what moved without a second GET:

{
  "is_active": true,
  "groups": [ … ],
  "preview": {
    "is_active_changed": false,
    "changed_groups": ["T"],
    "reprice_note": "Quote endpoints read children_settings on every request; the new brackets take effect on the very next quote. Bookings already saved with a resolved price are unaffected."
  }
}

Repricing: quote and availability endpoints read the bands on every request, so a change takes effect on the very next quote. Bookings already saved with a resolved price are not recalculated — there is no back-dated sweep.

Amenity catalogue

Read-only reference. Amenities themselves are Quendoo-curated — no partner CRUD. Consumers use this to resolve amenity ids they see elsewhere (or to build a picker on their PMS side):

GET /v1/qc/amenities?level={property|unit|room}&property_type_id={n}

Returns grouped amenities with locale-resolved names and, for parametric amenities, a value list under options.

Auto-email templates

Owner-scoped. A template's event field names the booking-lifecycle moment that fires it (booking_create, payment_missed, checkin, etc.); days_offset / days_offset_to (signed integers, positive = after) narrow the window. property_ids[] narrows the property set — and every id in it must be within the OAuth client's granted property set, or the API refuses with errors.qc.property_id_not_granted_to_client.

subject / body_html are translated bags keyed by full locale code (en-GB, bg-BG, ...). Templates are per-owner, not per-property — one customer maintains one mailing set that fires against any property they own.

Gallery

Owner-scoped image library. Three write flavors:

DELETE returns 409 with a usages[] list of every entity that still references the image (rooms, properties, content pages, extras, ...). Pass ?force=1 to sever every usage and delete anyway.

AI generation is deliberately dashboard-only — not exposed on the partner surface.

Reviews

Reviews are ingested from the connected OTA channels; QC exposes read + reply only, never create/delete:

GET  /v1/qc/reviews
GET  /v1/qc/reviews/{id}
POST /v1/qc/reviews/{id}/reply     { "reply": "..." }

A reply against a review that's already been replied to, or one from an OTA that doesn't accept replies at all, returns 422 with the domain reason — the client should not retry.

Content pages, guest feedback

Both back the property's public website / guest guide (legacy content_pages and guest_feedback tables). Standard CRUD scoped to /v1/qc/properties/{ext}/.

Content pages carry a translated title_tr / description_tr / text_tr, an images[] CSV of legacy-file-id strings, and an optional subpages[]. DELETE also strips the deleted page from the property's guest_guide.data.content_items[] — without this, the guest-guide editor errors on zombie references (see DEV-234).

Guest feedback rows are website testimonials — name, comment, score (1..10), optional visit_date. Nothing special about the API shape; partners typically sync these from their own review-collection tool.

Booking buttons + special offers

Booking buttons are per-property widget configurations that drive the public booking engine. QC exposes them read-only:

GET /v1/qc/properties/{ext}/booking_buttons

lightweight — id, name, url_key, url_link, is_active, currency, locale. Cache this once at onboarding.

Special offers are sub-config of a booking button (one button can run several concurrent offers). Nested for list/create, flat by id for read/update/delete:

GET    /v1/qc/booking_buttons/{pbb_id}/special_offers
POST   /v1/qc/booking_buttons/{pbb_id}/special_offers
GET    /v1/qc/special_offers/{id}
PATCH  /v1/qc/special_offers/{id}
DELETE /v1/qc/special_offers/{id}

Business invariants on POST: active_from_date and active_to_date are both required (an open-ended offer would keep firing after the campaign ended), rate_plans_ids[] must be non-empty (an offer with no rate plans has nothing to fire on), and sfp.min_nights ≤ sfp.max_nights.

Discovery order for a fresh partner integration

Rough onboarding order that yields the smallest number of "unknown id" errors during the first sync:

  1. GET /property-types — reference dictionary; cache indefinitely.
  2. GET /properties — the property list your token can see.
  3. Per property: GET /amenities?property_type_id=… — catalogue for any future amenity assignment.
  4. Per property: GET /booking_buttons — cache the pbb_id map if you'll ever create special offers.
  5. Per property: read policies (payment_policies, cancellation_policies), then rate plans, then rooms. A brand-new property with no policies is fine — the very first POST /rate_plans auto-seeds an API default —-named placeholder of each kind at request-time (partner sees the rate plan succeed; hotelier renames the row when they log in). Explicit payment_policy_id / cancellation_policy_id on the request still win, so a partner who does maintain policies client-side never sees the placeholder.

After that the everyday loop is inventory pushes + booking-revision consumption; see the integration guide for the end-to-end walk-through.