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 }

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

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.