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

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
  }
}

Ordering guarantees

Reconciling after downtime

Your worker was down for six hours. Two safe recovery patterns:

  1. 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.
  2. Snapshot reconciliation (weekly belt-and-braces): pull GET /properties/{ext}/bookings?checkin_from=today and 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:

The endpoint is fast and cheap; polling it every 30 s from an ops screen is fine.

What we don't do