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):

Then contact us β€” we flip the property live, and the same pushes that answered "dry": true start writing.