Inventory writes — availability, prices, restrictions
The killer property. Your values land in Quendoo's own calendar — the same rows the dashboard edits and the booking engine sells from. Quendoo's channel sync then distributes changes onward to the OTAs. You push once, to one place; you never double-push against a distribution layer you did not build, and there is no reconciliation loop between "your mirror" and "our mirror". This is what makes QC PMS-native — see Why Quendoo Connect.
Three write doors, one contract:
POST /properties/{external_property_id}/inventory/availability
POST /properties/{external_property_id}/inventory/rates
POST /properties/{external_property_id}/inventory/restrictions
All three take a values array (max 1000 entries) of date-range rows and
answer 202 Accepted with write accounting. All three require the
inventory.write scope and sit behind the Idempotency-Key header (see
auth & limits).
Where your data goes
Your values land in Quendoo's own calendar — the same one the hotelier's dashboard edits and the booking engine sells from. Quendoo's channel sync then distributes changes onward to the OTAs. You push once, to one place; you do not push to channels, and QC never double-pushes against its own sync.
The mode gate — and the dry flag
Every property has a QC mode. Your push is always accepted and validated, but whether it writes depends on the mode:
| Property mode | What happens | Response |
|---|---|---|
live |
values are written | 202, "dry": false |
shadow |
full validation, no write, counters all 0 |
202, "dry": true |
legacy / disabled |
nothing written | 202, "dry": true |
Treat "dry": true as "the connection works, the property is not cut over
yet". During onboarding you can push real payloads safely against a shadow
property and inspect the accounting.
Availability semantics — you send FREE rooms
availability is the number of rooms free to sell. Quendoo stores
qty_offered = availability + qty_booked, adding existing reservations back
server-side. You never need to know how many bookings Quendoo already holds,
and a push can never shrink capacity under them.
{ "values": [ {
"room_type_id": "c2a41f7e-8d3b-4e5a-b1c9-6f2d8e4a7b13",
"date_from": "2026-06-01", "date_to": "2026-06-30",
"availability": 7 } ] }
The write window
Writable dates span [yesterday, +730 days]. Days outside the window are skipped silently and counted — not errored — so a year-crossing range degrades gracefully:
{ "written": 58, "unchanged": 2, "skipped_out_of_window": 30, "dry": false }
written— day-rows whose stored value actually changed;unchanged— day-rows that already held the pushed value (the dirty-check skipped them — resending your full state is cheap and safe);skipped_out_of_window— day-rows outside the window.
One request may expand to at most 20 000 day-rows (sum of range lengths
across values); above that you get errors.qc.batch_too_large — split the
push.
Restrictions are UPDATE-only
inventory/restrictions amends calendar days that already exist (created by
availability/price pushes or by Quendoo itself); it never creates rows. Every
row must carry at least one of min_los, max_los, stop_sell,
closed_to_arrival, closed_to_departure.
Push discipline
- Send deltas immediately — when something changes in your PMS, push that change, as one call. Don't loop per date: one range row covers a whole period.
- Full sync nightly per property (the dirty-check makes it cheap — expect
high
unchangedcounts). - Under high volume, batch changes per property every 30–60 seconds rather than firing one call per keystroke — the inventory writes bucket has its own budget; see rate limits.
Restriction fields — what each one gates
Every field on inventory/restrictions gates a different check the
pricing engine runs at search time. A guest is offered a room only
when NONE of these deny the booking.
| Field | Type | Denies a search whose… |
|---|---|---|
min_los |
int ≥ 1 | length-of-stay in nights is BELOW this on the check-in date. |
max_los |
int ≥ 1 | length-of-stay in nights is ABOVE this on the check-in date. |
stop_sell |
bool | any night falls on a stop-sell date, regardless of length. |
closed_to_arrival |
bool | check-in date is closed for arrival. Existing stays that started elsewhere and cross the date are unaffected. |
closed_to_departure |
bool | check-out date is closed for departure. Symmetric to arrival. |
Length-of-stay is anchored to the check-in date row, not to
every night in the stay. min_los=3 on 2027-02-14 rejects a
guest asking 14 → 15 (1 night) but not 13 → 16 (3 nights, checking
in on the 13th).
stop_sell is a hard "no" — it beats every other field. A
stop-sell day cannot appear in an offered stay even as a middle
night. Use closed_to_arrival / closed_to_departure for the
softer "not this day, but the room is otherwise open" pattern.
Rates go with occupancy codes
inventory/rates writes the price for one date, one rate plan,
one occupancy placement. Rows carry an occupancies map keyed
by legacy occupancy codes — walk
Pricing & occupancy codes once
before you write your first rate row.
Rate plans have a sell_type — per_room / per_person /
per_occupancy — that decides which occupancy keys the map may
carry. A per-room rate plan takes one flat price under ROOM;
per-person plans take keys like A1, A2, C0-3; per-occupancy
plans expose the full matrix. The engine rejects a map that names
a code the rate plan doesn't own, with a row-level warning that
lets the rest of the batch succeed.
Reading what you pushed back
Every write door has a mirror read that answers back exactly the state your writes produced.
GET /properties/{ext}/availability?from=&to=&external_room_type_id=
GET /properties/{ext}/rate_plans/{external_rate_plan_id}/rates?from=&to=
The query takes from / to (YYYY-MM-DD), not the date_from /
date_to of the write body. One row per calendar day: availability per
room type (availability_days); prices AND restrictions per rate plan
(rate_days, restrictions under restrictions). Use these to reconcile: your
worker crashed mid-batch, or your PMS restarted, or you want to
verify a shadow-mode push wrote nothing. The reads are cheap
(reads bucket, see rate limits).
GET /availability returns free capacity (qty_offered - qty_booked), NOT the raw qty_offered you pushed. This is the
same semantic as the write direction: what you see on the read
is what a guest sees on the widget.
Racing writers — last write wins
Two workers pushing the same (room_type_id, date) at the same
second are not coordinated by us. The write that Redis acquires
last wins. This is by design — inventory pushes are already
idempotent by natural key, so a duplicate push doesn't corrupt
anything; a conflicting push simply resolves to whichever
arrived later.
If you must guarantee ordering (a hotelier picked 5 rooms then
immediately picked 4 rooms and both propagate through your
worker queue), pace one queue per property on your side. Or push
with a monotonic client-side sequence number embedded in the
Idempotency-Key; a later reconciliation run tells you which
sequence won.
The parity harness — how "shadow" writes are verified
When a property is in shadow mode, every write is compared
against what the legacy dashboard would have written. Diffs are
stored in qc_parity_observation and surfaced via the
qc:parity-report artisan command
on our side. Ask us for the score on any property that matters to
you; a shadow property never flips to live while its diff score
is non-zero.