API reference
Push API
Endpoints for partners writing into Staylah — ARI, restrictions, reservations.
All under /api/connectivity/v1. Push keys should be issued with
requires_signature = true.
Direction note. Staylah's own outbound calls (the requests we make to HPN's
/API/RateInventory) are not routes and appear in neither file — they areHpnClientplus a queued job. This section is only for a partner writing into us.
ARI — rates, inventory, restrictions#
Two properties of this contract matter more than the field list:
- It is a per-date upsert, not a delta. Resending yesterday's payload is a
no-op — which is what every channel manager does when it loses track of what
it has sent.
PUT, notPOST, says so in the contract and not only in the documentation. - Closed and absent are different.
availability: 0means "no rooms left";closed: truemeans "do not sell regardless of rooms". They are separate fields and one is never inferred from the other — conflating them sells a room the hotel deliberately shut.
PUT /ari#
Scope: ari.write
Batched rate and inventory upsert. Accepted synchronously, applied on a queue: a 5,000-row overnight batch must not hold your connection open, and you get a batch id to ask about afterwards.
| Field | Rules |
|---|---|
hotel_code | required, string ≤120 |
items | required, array, 1–5000 |
items.*.room_type_code | required, string ≤120 |
items.*.rate_plan_code | optional, string ≤120 |
items.*.date | required, Y-m-d |
items.*.availability | optional, integer 0–9999 |
items.*.rate | optional, numeric ≥0 |
items.*.currency | optional, ISO-4217 |
items.*.single_rate | optional, numeric ≥0 |
items.*.extra_adult_rate | optional, numeric ≥0 |
items.*.extra_child_rate | optional, numeric ≥0 |
items.*.closed | optional, boolean |
items.*.min_stay | optional, integer 1–365 |
items.*.max_stay | optional, integer 1–365 |
items.*.closed_to_arrival | optional, boolean |
items.*.closed_to_departure | optional, boolean |
Notes:
- The 5,000 cap is a hard limit, not a suggestion. Split your batches.
rate_plan_codeis absent for an inventory-only update: inventory belongs to the room type, price belongs to the rate plan, and sending one without the other is legitimate.rateis per night, per room — not a stay total.min_stay/max_stayare in nights, not room-nights.
{
"hotel_code": "TAN-KL-77",
"items": [
{ "room_type_code": "DLX", "rate_plan_code": "BAR",
"date": "2026-09-14", "availability": 4, "rate": 240.00,
"currency": "MYR", "min_stay": 2 },
{ "room_type_code": "DLX", "date": "2026-09-15", "availability": 0 },
{ "room_type_code": "DLX", "rate_plan_code": "BAR",
"date": "2026-09-16", "closed": true }
]
}
Status:
501— pendingChannelAriWriteService(mapped upsert intoroomtype_inventories+rate_plan_lists).
PUT /ari/restrictions#
Scope: restriction.write
Restrictions on their own, as a date range rather than per-date rows.
Separate from /ari because "close this room type for August" is one statement,
and forcing it through 31 per-date rows loses that intent and multiplies the
failure modes.
| Field | Rules |
|---|---|
hotel_code | required, string ≤120 |
items | required, array, 1–1000 |
items.*.room_type_code | required, string ≤120 |
items.*.rate_plan_code | optional, string ≤120 |
items.*.from_date | required, Y-m-d |
items.*.to_date | required, Y-m-d, on or after from_date |
items.*.days_of_week | optional, array ≤7 |
items.*.days_of_week.* | integer 0–6 |
items.*.closed | optional, boolean |
items.*.min_stay | optional, integer 1–365 |
items.*.max_stay | optional, integer 1–365 |
items.*.closed_to_arrival | optional, boolean |
items.*.closed_to_departure | optional, boolean |
days_of_week selects which weekdays in the range the restriction applies to;
absent means all seven.
Status:
501— pendingChannelAriWriteService(restriction ranges).
GET /ari/batches/{batchId}#
Scope: ari.write — whoever was allowed to send the batch must be allowed to
find out what happened to it.
What happened to an accepted batch: applied, rejected rows and why. This is the counterpart to accepting batches asynchronously — without it, "we accepted it" is unfalsifiable from your side.
batchId must match [A-Za-z0-9-]{1,64}.
Status:
501— pendingchannel_messages(batch outcome tracking).
Reservations#
The distinction from the pull side is not cosmetic. A pull booking is created against our inventory and we may refuse it. A pushed reservation has already happened — the guest holds a confirmation — so refusing it is not an option. The endpoint's job is to accept it, acknowledge it, and reconcile afterwards if the inventory does not agree.
POST /reservations#
Scope: reservation.write
Accepts one confirmed reservation. Idempotent on
(partner, partner_reference) — a redelivery returns the original
acknowledgement, because a CM that does not get a timely 200 will always send
it again.
| Field | Rules |
|---|---|
partner_reference | required, string ≤120 — the idempotency key |
guest_confirmation | optional, string ≤120 |
hotel_code | required, string ≤120 |
status | required, one of confirmed, cancelled, modified |
check_in | required, Y-m-d |
check_out | required, Y-m-d, after check_in |
rooms | required, array 1–50 |
rooms.*.room_type_code | required, string ≤120 |
rooms.*.rate_plan_code | optional, string ≤120 |
rooms.*.quantity | required, integer 1–30 |
rooms.*.adults | required, integer 1–20 |
rooms.*.children | optional, integer 0–10 |
rooms.*.child_ages | optional, array of integers 0–17 |
rooms.*.nightly_rates | optional, array |
rooms.*.nightly_rates.*.date | required with nightly_rates, Y-m-d |
rooms.*.nightly_rates.*.amount | required with nightly_rates, numeric ≥0 |
guests | required, array ≥1 |
guests.*.room_number | required, integer ≥1 |
guests.*.first_name | required, string ≤100 |
guests.*.last_name | optional, string ≤100 |
guests.*.is_adult | required, boolean |
guests.*.nationality | optional, ISO-2 |
contact | optional, object |
contact.email | optional, valid email ≤255 |
contact.phone | optional, string ≤50 |
sell_amount | optional, numeric ≥0 |
sell_currency | optional, ISO-4217 |
net_amount | required, numeric ≥0 |
net_currency | required, ISO-4217 |
is_non_refundable | optional, boolean |
cancellation_deadline | optional, date |
special_requests | optional, string ≤2000 |
booked_at | optional, date |
Notes:
partner_referencevsguest_confirmation: one is your channel's id, the other is what the guest quotes at the desk. Support needs both.rooms.*.adultsis at least 1, enforced. Zero adult counts are a live ingest defect on other feeds and they silently destroy occupancy reporting; a stay with nobody in it is rejected at the door here.- Send
nightly_rates. Without a per-night breakdown we cannot verify the total, and a stay total divided by nights is not a nightly rate whenever the booking spans a rate change. - Two amounts, two currencies. What the guest paid the channel
(
sell_amount) and what the channel owes us (net_amount) are different numbers. Collapsing them is why revenue cannot be reconciled against a partner statement;net_*is required for exactly that reason.
Status:
501— pendingChannelReservationIntakeService(raw capture, then mapped booking create).
POST /reservations/{reference}/cancel#
Scope: reservation.cancel
| Field | Rules |
|---|---|
cancelled_at | optional, date |
reason | optional, string ≤500 |
penalty_amount | optional, numeric ≥0 |
penalty_currency | optional, ISO-4217 |
Accepted even if we never received the original. An orphan cancellation is recorded and flagged for reconciliation — the alternative is telling you your cancellation failed while your guest believes it succeeded.
penalty_amount is recorded as your claim, not applied as fact. The penalty we
charge is ours to compute.
Status:
501— pendingChannelReservationIntakeService(cancellation intake).
POST /reservations/{reference}/modify#
Scope: reservation.write — a modification carries the reservation's
complete new state, so it needs the write scope, not the cancel scope.
| Field | Rules |
|---|---|
check_in | required, Y-m-d |
check_out | required, Y-m-d, after check_in |
rooms | required, array 1–50 |
rooms.*.room_type_code | required, string ≤120 |
rooms.*.quantity | required, integer 1–30 |
rooms.*.adults | required, integer 1–20 |
rooms.*.children | optional, integer 0–10 |
net_amount | required, numeric ≥0 |
net_currency | required, ISO-4217 |
modified_at | optional, date |
Deliver the complete new state, never a diff. Channel managers are unreliable about diffs, and a partial update that drops a room is indistinguishable from one that never mentioned it.
Status:
501— pendingChannelReservationIntakeService(full-state modification intake).
