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:
- Same Application-layer use case as the dashboard. A partner write triggers the identical validations, defaults, gallery-usage sync, in-use guards, and emitted events the hotelier would trigger from the dashboard. There is no "partner vs. hotelier" divergence.
- Ownership check on
{id}returns 404, never 403. The API never confirms the existence of a resource on another customer's property.
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:
- Either
name(raw internal label) or at least one non-emptyname_trlocale is required on POST. A nameless service renders blank across the whole product. price_periods[]overrides the basepricefor a date range; ranges may not overlap or reverse.
Promotions
Discount rules with elaborate targeting. The domain has three
promotion types encoded on the type field:
""(empty) — Basic. The default flat-discount promotion.md— Mobile. Applies only when the booking engine detects a mobile device;stay_conditionsare not required for this type.lm— Last Minute.eb— Early Booking.
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:
af_res(X days after reservation) andbe_arr(X days before arrival) requirenum_days(1..1000).be_dat(before a specific date) requiresdate(YYYY-MM-DD).tp/pf_tp/pf_fn/fixedrequire anamount(>= 1).
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:
POST /v1/qc/gallery— multipart upload of a fresh file.POST /v1/qc/gallery/register— register alegacy_file_idthat's already been stored elsewhere in the system (e.g. via the dashboard's own uploader).PATCH /v1/qc/gallery/{id}— rename / retag.
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:
GET /property-types— reference dictionary; cache indefinitely.GET /properties— the property list your token can see.- Per property:
GET /amenities?property_type_id=…— catalogue for any future amenity assignment. - Per property:
GET /booking_buttons— cache the pbb_id map if you'll ever create special offers. - Per property: read policies (
payment_policies,cancellation_policies), then rate plans, then rooms. A brand-new property with no policies is fine — the very firstPOST /rate_plansauto-seeds anAPI default —-named placeholder of each kind at request-time (partner sees the rate plan succeed; hotelier renames the row when they log in). Explicitpayment_policy_id/cancellation_policy_idon 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.