Integration guide
The whole loop, end to end, against staging
(https://staging-api.quendoo.com/v1/qc). One fictional property β Hotel
Panorama, Sofia β runs through every example; the ids stay consistent from
here to the API reference, so you can follow one
hotel through the entire documentation.
Every request below is complete and copy-pasteable. $TOKEN is your bearer
token from step 1.
1. Get a token
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"
Tokens last one hour. Details and scopes: authentication & limits.
2. Discover your properties
curl https://staging-api.quendoo.com/v1/qc/properties \
-H "Authorization: Bearer $TOKEN"
{ "status": "ok", "message": "ok", "data": {
"properties": [ {
"external_property_id": "7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90",
"id": "7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90",
"name": "Hotel Panorama",
"name_tr": {"en-GB": "Hotel Panorama", "bg-BG": "Π₯ΠΎΡΠ΅Π» ΠΠ°Π½ΠΎΡΠ°ΠΌΠ°"},
"currency": "EUR", "timezone": "Europe/Sofia", "mode": "shadow"
} ],
"meta": {"total": 1, "page": 1, "per_page": 200, "last_page": 1}
}
}
Note "mode": "shadow" β your pushes will validate but not write until the
property is flipped live. That is the safe onboarding state; see
the mode gate.
If your token was issued to a partner that also owns the properties (a PMS partner spinning up hotels from your own dashboard), you can create them from your side instead:
# fetch the property-type dictionary once β cache it
curl https://staging-api.quendoo.com/v1/qc/property-types \
-H "Authorization: Bearer $TOKEN"
# then create β `type_id` from the dictionary above; `mode` defaults
# to `shadow` for partner-created properties.
curl -X POST https://staging-api.quendoo.com/v1/qc/properties \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "Hotel Panorama",
"type_id": 1,
"currency": "EUR",
"timezone": "Europe/Sofia",
"partner_code": "PARTNER-HOTEL-001"
}'
The response mirrors GET /properties β every subsequent call
addresses the new property by its external_property_id (or by
partner_code β every URL segment that accepts an
external_property_id also accepts a partner code as of 2026-08-27;
the middleware resolves external_id first and falls back to
partner_code when there's no match).
3. Declare your own catalogue
Tell Quendoo what your PMS has, by your own codes β this powers the mapping screens on the hotelier's side:
curl -X PUT https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/catalogue \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"rooms": [ { "code": "DBL", "name": "Double Room" },
{ "code": "FAM4", "name": "Family Apartment" } ],
"rates": [ { "code": "BB", "name": "Bed & Breakfast" },
{ "code": "NRF", "name": "Non-refundable" } ],
"meals": [ { "code": "BB", "name": "Breakfast" } ]
}'
A re-push replaces the whole catalogue β always send your full current state.
4. Read Quendoo's structure and map it
curl https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/room_types \
-H "Authorization: Bearer $TOKEN"
{ "status": "ok", "message": "ok", "data": {
"room_types": [
{ "external_room_type_id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"name": "Standard Double", "name_tr": {"en-GB": "Standard Double", "bg-BG": "Π‘ΡΠ°Π½Π΄Π°ΡΡΠ½Π° Π΄Π²ΠΎΠΉΠ½Π°"},
"kind": "room", "qty": 12,
"occupancy": { "adults": 2, "children": 1, "infants": 1 } },
{ "external_room_type_id": "f8b62d4a-3c7e-4a1f-8e5b-9d1c6a3f2e78",
"id": "f8b62d4a-3c7e-4a1f-8e5b-9d1c6a3f2e78",
"name": "Family Apartment", "name_tr": {"en-GB": "Family Apartment"},
"kind": "apartment", "qty": 4,
"occupancy": { "adults": 4, "children": 2, "infants": 1 } } ]
} }
Attach your code to each entity you matched:
curl -X PATCH https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/room_types/c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13 \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "partner_code": "DBL", "partner_name": "Double Room" }'
Then the rate plans β note each entry is one pair (rate plan Γ room type)
with its sell_type and parent linkage:
curl https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/rate_plans \
-H "Authorization: Bearer $TOKEN"
{ "status": "ok", "message": "ok", "data": { "rate_plans": [
{ "external_rate_plan_id": "a5d91c3e-6b2f-4d8a-9c4e-1f7b3a8d5c26",
"id": "a5d91c3e-6b2f-4d8a-9c4e-1f7b3a8d5c26",
"name": "Best Available", "name_tr": {"en-GB": "Best Available"},
"room_type_id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"parent_rate_plan_id": null, "sell_type": "per_person",
"prices_writable": true,
"occupancies": [
{ "code": "A2", "kind": "manual", "persons": 2 },
{ "code": "A1", "kind": "derived", "persons": 1,
"derived_from": "A2", "modifier": ["decrease_by_percent", 10] },
{ "code": "AEB", "kind": "manual" },
{ "code": "CRB", "kind": "derived",
"derived_from": "A2", "modifier": ["decrease_by_percent", 75] },
{ "code": "IRB", "kind": "cost_free" } ] },
{ "id": "e3f72b5c-9a4d-4b6e-8d2a-5c1e9f4b7a38",
"room_type_id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"parent_rate_plan_id": "a5d91c3e-6b2f-4d8a-9c4e-1f7b3a8d5c26",
"sell_type": "per_person", "prices_writable": false },
{ "id": "b9c04e6d-2a7f-4c3b-a8e1-4d6f9b2c5e17",
"room_type_id": "f8b62d4a-3c7e-4a1f-8e5b-9d1c6a3f2e78",
"parent_rate_plan_id": null, "sell_type": "per_room",
"prices_writable": true,
"occupancies": [ { "code": "ROOM", "kind": "manual" } ] } ] } }
Read this as: Standard B&B (parent, per_person) and its child
Non-refundable on the Double Room, and Apartment Flex (per_room) on the
Family Apartment. Each entry self-describes its pricing: occupancies
lists the codes and their kinds, and prices_writable: false on the child
tells you its prices come from the parent. The full model:
pricing.
If a room or rate is missing on Quendoo's side, you can create it β
POST β¦/room_types and POST β¦/rate_plans create real records in the
hotelier's property and return the fresh id immediately.
5. Push availability
You send the number of free rooms; Quendoo adds its own booked count back.
curl -X POST https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/inventory/availability \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: 018f3c1e-7a2b-4c5d-9e8f-6a1b2c3d4e5f" \
-d '{ "values": [
{ "room_type_id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"date_from": "2026-06-01", "date_to": "2026-08-31", "availability": 7 },
{ "room_type_id": "f8b62d4a-3c7e-4a1f-8e5b-9d1c6a3f2e78",
"date_from": "2026-06-01", "date_to": "2026-08-31", "availability": 3 } ] }'
{ "status": "ok", "message": "accepted",
"data": { "written": 0, "unchanged": 0, "skipped_out_of_window": 0, "dry": true, "warnings": [] } }
"dry": true because Hotel Panorama is still in shadow β the payload was
validated, nothing was written, and the counters stay at zero: shadow does
not report what it would have written. The same call against a live
property writes and counts (184 day-rows here).
6. Push prices and restrictions
Prices, for the per_room pair (Apartment Flex):
curl -X POST https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/inventory/rates \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: 018f3c1e-9d4a-4b6c-8e2f-1a5b3c7d9e0f" \
-d '{ "values": [
{ "room_type_id": "f8b62d4a-3c7e-4a1f-8e5b-9d1c6a3f2e78",
"rate_plan_id": "b9c04e6d-2a7f-4c3b-a8e1-4d6f9b2c5e17",
"date_from": "2026-06-01", "date_to": "2026-06-30", "rate": 175.50 } ] }'
Per-code prices, for the per_person pair (Standard B&B β push to the
PARENT; only manual codes from its occupancies accept values):
curl -X POST https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/inventory/rates \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: 018f3c20-4c8d-4e0f-a1b2-5d7e9f0a2b4c" \
-d '{ "values": [
{ "room_type_id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"rate_plan_id": "a5d91c3e-6b2f-4d8a-9c4e-1f7b3a8d5c26",
"date_from": "2026-06-01", "date_to": "2026-06-30",
"prices": { "A2": 220.00, "AEB": 45.00 } } ] }'
A code that is not writable (derived, fee, cost-free β or any code on a
child plan) comes back as a row-level warning while the rest of the batch
writes; check warnings[] in every 202. You can read the resolved
per-code prices for any pair:
curl "https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/rate_plans/a5d91c3e-6b2f-4d8a-9c4e-1f7b3a8d5c26/rates?from=2026-06-01&to=2026-06-07" \
-H "Authorization: Bearer $TOKEN"
Restrictions (any pair, parent or child β the child owns its own):
curl -X POST https://staging-api.quendoo.com/v1/qc/properties/7d3e8a2c-1f4b-4c9e-9a6d-2b8f5e1c7a90/inventory/restrictions \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: 018f3c1f-2b6c-4d8e-9f0a-3c5d7e9f1a2b" \
-d '{ "values": [
{ "room_type_id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"rate_plan_id": "e3f72b5c-9a4d-4b6e-8d2a-5c1e9f4b7a38",
"date_from": "2026-08-14", "date_to": "2026-08-16",
"min_los": 3, "closed_to_arrival": true } ] }'
7. Receive bookings β poll, save, ack
curl "https://staging-api.quendoo.com/v1/qc/booking_revisions/feed?since=0&limit=50" \
-H "Authorization: Bearer $TOKEN"
{ "status": "ok", "message": "ok", "data": {
"booking_revisions": [ {
"id": 4812, "revision_type": "created",
"payload": {
"external_booking_id": "PNR-482913",
"external_revision_id": null,
"source_channel": "QDO",
"guest_first_name": "Maria", "guest_last_name": "P.",
"guest_email": "m***@***", "guest_phone": "********45",
"checkin_date": "2026-06-12", "checkout_date": "2026-06-15",
"nights": 3, "currency_code": "EUR",
"total_price": {"amount": 825.00, "currency": "EUR"},
"rooms": [ { "room_id": 4211, "rate_plan_id": 9087, "qty": 1,
"external_room_type_id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"external_rate_plan_id": "a5d91c3e-6b2f-4d8a-9c4e-1f7b3a8d5c26" } ],
"extras": [],
"processing_state": "promoted", "no_show_at": null, "cancelled_at": null
}
} ],
"next_cursor": 4812
}
}
Envelope is {data: {booking_revisions: [...], next_cursor: N}} β flat
under data, cursor pagination via ?since=<last_seen_id>. guest_*
fields are masked per the wire conventions.
Match rooms by external_room_type_id / external_rate_plan_id β the
numeric room_id / rate_plan_id are Quendoo's internal ids.
source_channel is where the booking was made: QDO our booking engine,
QDB the hotel's dashboard, QDA an agency, GHA Google Hotel Ads, or an
OTA code (BDC, EXP, ABB, β¦).
Monetary values ship as {amount, currency} β see
wire conventions Β§ Money.
Save the booking in your PMS, then acknowledge:
curl -X POST https://staging-api.quendoo.com/v1/qc/booking_revisions/4812/ack \
-H "Authorization: Bearer $TOKEN"
Next poll passes ?since=4812. Ack only after a durable save β an un-acked
revision keeps returning, which is your crash safety. The full semantics
(frozen payloads, per-consumer acks, why webhooks are only a doorbell):
bookings.
Optional low-latency trigger:
curl -X POST https://staging-api.quendoo.com/v1/qc/subscriptions \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "event": "booking.created", "target_url": "https://pms.example.com/webhooks/quendoo" }'
The response contains your signing secret once β store it.
8. Configuration surfaces (v2)
Beyond the pricing / availability / bookings loop, v2 exposes the
dashboard's configuration entities as first-class CRUD. Every write
below rides Idempotency-Key and reuses the same Application-layer
use cases the dashboard writes through β validations, defaults, and
side effects (gallery-usage sync, PolicyInUseException, MNG-172
content-page auto-heal) are identical between partner writes and
hotelier writes.
8.1 Extra services
curl -X POST https://staging-api.quendoo.com/v1/qc/properties/$EXT/extra_services \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "Airport transfer",
"name_tr": { "en-GB": "Airport transfer", "bg-BG": "ΠΠ΅ΡΠΈΡΠ΅Π½ ΡΡΠ°Π½ΡΡΠ΅Ρ" },
"is_daily": false,
"price": 25,
"price_type": 2,
"do_not_apply_promotions": false,
"active_week_days": ["mon","tue","wed","thu","fri","sat","sun"],
"client_select_dates_settings": { "type": "0" }
}'
client_select_dates_settings.type is a string: "0" the service applies
on every day of the stay, "1" the guest picks one day (a transfer), "2"
the guest picks a range, bounded by min_days / max_days.
At least one of name / name_tr[*] must be non-empty (mirror of the
dashboard hasAnyName() guard). PATCH accepts partial payloads.
8.2 Promotions
POST /v1/qc/properties/{ext}/promotions β shape locks 1:1 with the
dashboard's CreatePromotionRequest. Cross-field rules
(resv_min β€ resv_max, resv_to_time > resv_from_time with
midnight sentinels) live in the shared PromotionCrossFieldRules.
8.3 Payment policies
POST /v1/qc/properties/{ext}/payment_policies β pay_type β {on_res, af_res, be_arr, on_arr, on_dep, be_dat}. af_res /
be_arr require num_days (1..1000); be_dat requires date.
DELETE returns 409 payment_policy_in_use when a rate plan still
references the policy.
8.4 Cancellation policies
POST /v1/qc/properties/{ext}/cancellation_policies β carries a
translated name_tr bag and a free-form data[] array (up to 50
rules). Each rule needs c_type + p_type; the visibility-driven
fields (p_days, p_date, p_value) are still optional at the API
boundary β validate on your side against the reference-store map.
8.5 Amenity catalogue
curl "https://staging-api.quendoo.com/v1/qc/amenities?level=unit&property_type_id=1" \
-H "Authorization: Bearer $TOKEN"
Read-only. Returns grouped amenities with locale-resolved names and
optional value-list options for parametric amenities.
8.6 Auto-email templates
Owner-scoped (no external_property_id in the URL). property_ids[]
in the payload must be a subset of the OAuth client's granted
property set β the API refuses cross-tenant targeting.
curl -X POST https://staging-api.quendoo.com/v1/qc/auto_email_templates \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"event": "booking_create",
"title": "New booking confirmation",
"is_active": true,
"property_ids": [1057, 1058],
"pem_system_set": ["QDO", "QDB"],
"to": "client",
"subject": { "en-GB": "Booking #{bookingId} confirmed" },
"body_html": { "en-GB": "<p>Dear {#clientFullName#}, ...</p>" }
}'
8.7 Gallery
Owner-scoped image library. Upload a new file with POST /gallery
(multipart) or attach an already-stored legacy file id with POST /gallery/register. DELETE returns 409 with a usages[] list when
the image is still referenced by an entity; pass ?force=1 to sever
those usages and delete anyway.
8.8 Reviews (read + reply)
Reviews are OTA-sourced (ingested from the connected channels); partners can list, read one, and post a single reply per review. A retry against a review that's already been replied to (or an OTA that doesn't accept replies at all) returns 422 with the domain reason.
8.9 Content pages / Guest feedback
Legacy content_pages and guest_feedback under
/v1/qc/properties/{ext}/content_pages and
.../guest_feedback. DELETE of a content page strips its reference
from the property's guest-guide content_items[] so the editor
doesn't error on zombies (DEV-234).
8.10 Booking buttons + special offers
GET /v1/qc/properties/{ext}/booking_buttons β lightweight
discovery: {id, name, url_key, url_link, is_active, currency_code, lng_locale}. Cache the mapping once at onboarding.
POST /v1/qc/booking_buttons/{pbb_id}/special_offers β nested for
list/create; PATCH / DELETE on /v1/qc/special_offers/{id}
flatten. active_from_date + active_to_date are required on POST
(MNG-27 β offers must be bounded), rate_plans_ids must be non-empty,
and sfp.min_nights β€ sfp.max_nights.
8.11 Age groups (child pricing bands)
GET / PUT /v1/qc/properties/{ext}/age-groups β the property's
infant / child / teen brackets (I / C / T) the pricing engine
routes children through. PUT is a partial, idempotent merge and
answers with a preview of what moved; new brackets take effect on
the next quote, and saved bookings are untouched. Full shape in
configuration surfaces.
9. Go-live checklist
Before asking for the property to be flipped live, make sure that on
staging, from your real PMS UI (not a test script):
- a full-year availability + rates sync completes in a handful of calls (range rows, not per-date loops);
- changing one price / one availability / each restriction in your PMS produces one API call with only the changed range;
- your pushes answer
202with plausible accounting and you alert onwarnings/4xx, not just log them; - a booking created on the Quendoo side reaches your PMS through the feed and is acked; a modification and a cancellation arrive as their own revisions;
- killing your consumer mid-batch and restarting loses nothing (acks after save);
- a
429backs off toX-RateLimit-Resetinstead of retrying hot; - an expired token triggers one refresh, not a retry storm.
Then contact us β we flip the property live, and the same pushes that
answered "dry": true start writing.