Bookings — the revision feed and acknowledgement
QC delivers bookings as an append-only stream of revisions you consume at your own pace. This design survives your downtime, out-of-order delivery, and multiple consumers — read this page before wiring the webhook.
Why we built it as a feed, not a webhook queue. Every other vendor API in this space delivers bookings as a fire-and-forget HTTP POST — if you miss it (your box was rebooting, your firewall rule was wrong, your DB was full), the booking is gone. QC's ledger is the source of truth: you advance a cursor when you have durably persisted, so a missed webhook costs you nothing and multiple independent consumers can subscribe on the same account. See Why Quendoo Connect for how this compares against off-the-shelf channel managers.
Where bookings come from
Everything that lands in the property's book surfaces here: bookings made on Quendoo's own booking engine and dashboard, OTA bookings arriving through the channel sync, and bookings received via QC itself. A background projector watches the ledger and materialises changes within about a minute.
Snapshots and revisions
Every meaningful change to a booking produces one revision:
revision_type |
Fired when |
|---|---|
created |
the booking first appears |
modified |
dates, guests, rooms, price or contact data changed |
cancelled |
the booking was cancelled |
no_show |
a no-show was recorded |
confirmed |
a tentative booking was confirmed |
Each revision carries a frozen payload — the full booking as it looked at
that moment (guest, dates, rooms with your mapped ids, extras, totals). If a
booking changes three times, you get three revisions, each with its own
snapshot; processing the feed late still shows you every intermediate shape.
One deliberate exception: when a property is first connected, its history is
seeded silently — enrolment does not flood the feed with created rows
for old bookings.
The feed + ack loop
GET /booking_revisions/feed?since=0&limit=50 → revisions + next_cursor
POST /booking_revisions/{id}/ack → stop seeing that revision
- The feed returns revisions you have not acknowledged, oldest first. Pass
the returned
next_cursorback as?since=to page forward. - Acknowledge a revision only after you have durably saved it on your side. An ack is your receipt — an un-acked revision keeps coming back, which is exactly what you want after a crash.
- Ack is per consumer: two integrations polling the same property each see an independent stream; your ack hides nothing from anyone else, and nothing is ever deleted.
- Ack is idempotent — repeating one is a no-op, not an error.
Poll the feed every 1–5 minutes. It is cheap, and it is the loop your integration should be able to survive on alone.
Webhooks are a doorbell, the feed is the truth
You can also subscribe to webhooks (POST /subscriptions) for low latency —
signed with HMAC-SHA256, retried with backoff. But deliveries can arrive out
of order and, like all webhooks, can be missed. The doctrine:
Treat a webhook as a trigger to poll the feed — never as the data itself.
An integration that only uses the feed is correct. An integration that only uses webhooks is not.
The collection — current state, not changes
GET /properties/{id}/bookings lists bookings, newest first, with a
meta block for pagination. Use the collection for reconciliation
screens and backfills; use the feed for your sync loop.
Filters (all optional; malformed values are DROPPED, not 422 — a filter that parses to nothing does not narrow):
| Filter | Type | Notes |
|---|---|---|
page / limit |
int | limit clamped to MAX_LIMIT (100). |
checkin_from / checkin_to |
date | YYYY-MM-DD or YYYY-MM-DD HH:MM:SS. |
checkout_from / checkout_to |
date | same shape. |
inserted_from / inserted_to |
date | filters on the ledger row's received_at. |
booking_status |
enum | enum[] | created | confirmed | cancelled | no_show. Accepts a single value OR an array — ?booking_status=cancelled and ?booking_status[]=cancelled&booking_status[]=no_show both work. Derived from the row's terminal timestamps — see Statuses. |
source_channel |
string | exact match against the booking's origin code: QDO (booking engine), QDB (hotel dashboard), QDA (agency), GHA (Google Hotel Ads) or an OTA code such as BDC, EXP, ABB. Length ≤ 32. |
guest_name |
string | case-insensitive substring against guest_first_name OR guest_last_name, length ≤ 100. |
booking_id |
string | substring against external_booking_id — partial-id lookup. |
Pagination meta on every list response:
{
"data": {
"bookings": [...],
"meta": {"total": 4231, "page": 1, "per_page": 50, "last_page": 85}
}
}
total reflects rows under the ACTIVE filters, not the full table. last_page
is always at least 1 even for an empty result so a UI can render "page 1 of 1"
without a null check.
GET …/bookings/{external_booking_id} fetches one — no pagination.
Acting on bookings
POST …/bookings/{id}/cancel and POST …/bookings/{id}/no_show stamp the
state and materialise the matching revision. Both are idempotent — a second
call is a no-op.
What a revision payload looks like
Every revision carries the booking as it stood at the moment of the state change — no partial patch, no diff. Your side reads the full snapshot and updates.
{
"id": 4812,
"inbound_booking_id": 4790,
"revision_type": "modified",
"created_at": "2027-02-14T09:12:03+02:00",
"payload": {
"external_booking_id": "YV1NHP57",
"external_revision_id": null,
"source_channel": "QDO",
"guest_first_name": "Ivan",
"guest_last_name": "P.",
"guest_email": "i***@***",
"guest_phone": "***********45",
"checkin_date": "2027-02-14",
"checkout_date": "2027-02-18",
"nights": 4,
"currency_code": "EUR",
"total_price": {"amount": 720.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
}
}
payloadis the full state, not a diff. Overwrite your row wholesale.- Match rooms on
external_room_type_id/external_rate_plan_id.room_id/rate_plan_idare Quendoo's internal numbers. source_channelis where the booking was made:QDOour booking engine,QDBthe hotel's dashboard,QDAan agency,GHAGoogle Hotel Ads, or an OTA code (BDC,EXP,ABB, …).guest_*fields are masked per the wire conventions.- A cancellation or no-show is a new revision whose payload carries
cancelled_at/no_show_at.
Ordering guarantees
- The feed returns revisions in ascending
id, and?since=<id>resumes after the last one you saw. A later revision of the same booking always has a largerid, so applying them in feed order never overwrites a newer state with an older one. inbound_booking_idties the revisions of one booking together.- Webhooks ARE unordered by design (retries, parallel dispatch). See the doctrine above — poll the feed to reconcile.
Reconciling after downtime
Your worker was down for six hours. Two safe recovery patterns:
- Feed catch-up (the default): the next
GET /booking_revisions/feed?since=<your last cursor>returns every revision you missed, in order. Ack as you process. Nothing else needed. - Snapshot reconciliation (weekly belt-and-braces): pull
GET /properties/{ext}/bookings?checkin_from=todayand compare the current state against your local rows. Discrepancies go into your ops queue. Cheap under the reads bucket (rate limits) — one call per property per week is fine.
Statuses — one word, one meaning
status |
Means |
|---|---|
tentative |
Not yet confirmed. Guest may still confirm, or may drop off. Rare on modern OTAs. |
confirmed |
The booking exists in Quendoo. Default state. |
cancelled |
Cancelled by anyone (guest, hotelier, OTA). |
no_show |
The guest didn't arrive. Stamped by the hotelier or an automation. |
checked_in |
The hotelier stamped a physical arrival — informational only, does not gate any workflow. |
checked_out |
Same. |
Any status transition triggers a revision. Non-material updates (e.g. hotelier added an internal comment) do NOT trigger a revision — that's on purpose so your worker does not thrash on front-desk noise.
Cancellations — knowing WHO cancelled
Every revision_type: "cancelled" revision carries a
cancellation block on the booking:
"cancellation": {
"cancelled_at": "2027-02-13T18:44:12Z",
"cancelled_by": "guest" | "hotelier" | "ota" | "system",
"reason_code": "guest_change_of_plans" | "…",
"policy_snapshot": { /* the cancellation policy in effect */ },
"fee_charged": 45.00
}
The policy_snapshot is the policy in effect at the time of
the booking, not the policy live today. This matters when a
hotelier changed the property's policy after the booking was
made — the snapshot lets you show the guest the terms they
actually agreed to.
The two counters that answer "am I in sync?"
Your dashboard's "connection health" view usually needs one line per property:
GET /properties/{ext}/bookings/healthreturns{last_revision_at, pending_revisions_count, ack_lag_seconds}.pending_revisions_count == 0andack_lag_seconds < 60is "green". Anything else is yellow.
The endpoint is fast and cheap; polling it every 30 s from an ops screen is fine.
What we don't do
- No booking creation from your side. QC delivers bookings
Quendoo took from its own channels. If you need a partner-
originated booking flow, that's a separate B2B endpoint
(
/v1/qc/b2b/bookings) — see the reference. - No hotelier-facing UI callbacks. Rendering an iframe of your system in the Quendoo dashboard is not a supported flow.
- No booking-item replacement. Modifying an item is a
revision-emitting
modifiedon the whole booking; a partial patch of one item without new revision does not happen.