Staylah

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 are HpnClient plus 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, not POST, says so in the contract and not only in the documentation.
  • Closed and absent are different. availability: 0 means "no rooms left"; closed: true means "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.

FieldRules
hotel_coderequired, string ≤120
itemsrequired, array, 1–5000
items.*.room_type_coderequired, string ≤120
items.*.rate_plan_codeoptional, string ≤120
items.*.daterequired, Y-m-d
items.*.availabilityoptional, integer 0–9999
items.*.rateoptional, numeric ≥0
items.*.currencyoptional, ISO-4217
items.*.single_rateoptional, numeric ≥0
items.*.extra_adult_rateoptional, numeric ≥0
items.*.extra_child_rateoptional, numeric ≥0
items.*.closedoptional, boolean
items.*.min_stayoptional, integer 1–365
items.*.max_stayoptional, integer 1–365
items.*.closed_to_arrivaloptional, boolean
items.*.closed_to_departureoptional, boolean

Notes:

  • The 5,000 cap is a hard limit, not a suggestion. Split your batches.
  • rate_plan_code is 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.
  • rate is per night, per room — not a stay total.
  • min_stay/max_stay are in nights, not room-nights.
json
{
  "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 — pending ChannelAriWriteService (mapped upsert into roomtype_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.

FieldRules
hotel_coderequired, string ≤120
itemsrequired, array, 1–1000
items.*.room_type_coderequired, string ≤120
items.*.rate_plan_codeoptional, string ≤120
items.*.from_daterequired, Y-m-d
items.*.to_daterequired, Y-m-d, on or after from_date
items.*.days_of_weekoptional, array ≤7
items.*.days_of_week.*integer 0–6
items.*.closedoptional, boolean
items.*.min_stayoptional, integer 1–365
items.*.max_stayoptional, integer 1–365
items.*.closed_to_arrivaloptional, boolean
items.*.closed_to_departureoptional, boolean

days_of_week selects which weekdays in the range the restriction applies to; absent means all seven.

Status: 501 — pending ChannelAriWriteService (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 — pending channel_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.

FieldRules
partner_referencerequired, string ≤120 — the idempotency key
guest_confirmationoptional, string ≤120
hotel_coderequired, string ≤120
statusrequired, one of confirmed, cancelled, modified
check_inrequired, Y-m-d
check_outrequired, Y-m-d, after check_in
roomsrequired, array 1–50
rooms.*.room_type_coderequired, string ≤120
rooms.*.rate_plan_codeoptional, string ≤120
rooms.*.quantityrequired, integer 1–30
rooms.*.adultsrequired, integer 1–20
rooms.*.childrenoptional, integer 0–10
rooms.*.child_agesoptional, array of integers 0–17
rooms.*.nightly_ratesoptional, array
rooms.*.nightly_rates.*.daterequired with nightly_rates, Y-m-d
rooms.*.nightly_rates.*.amountrequired with nightly_rates, numeric ≥0
guestsrequired, array ≥1
guests.*.room_numberrequired, integer ≥1
guests.*.first_namerequired, string ≤100
guests.*.last_nameoptional, string ≤100
guests.*.is_adultrequired, boolean
guests.*.nationalityoptional, ISO-2
contactoptional, object
contact.emailoptional, valid email ≤255
contact.phoneoptional, string ≤50
sell_amountoptional, numeric ≥0
sell_currencyoptional, ISO-4217
net_amountrequired, numeric ≥0
net_currencyrequired, ISO-4217
is_non_refundableoptional, boolean
cancellation_deadlineoptional, date
special_requestsoptional, string ≤2000
booked_atoptional, date

Notes:

  • partner_reference vs guest_confirmation: one is your channel's id, the other is what the guest quotes at the desk. Support needs both.
  • rooms.*.adults is 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 — pending ChannelReservationIntakeService (raw capture, then mapped booking create).

POST /reservations/{reference}/cancel#

Scope: reservation.cancel

FieldRules
cancelled_atoptional, date
reasonoptional, string ≤500
penalty_amountoptional, numeric ≥0
penalty_currencyoptional, 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 — pending ChannelReservationIntakeService (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.

FieldRules
check_inrequired, Y-m-d
check_outrequired, Y-m-d, after check_in
roomsrequired, array 1–50
rooms.*.room_type_coderequired, string ≤120
rooms.*.quantityrequired, integer 1–30
rooms.*.adultsrequired, integer 1–20
rooms.*.childrenoptional, integer 0–10
net_amountrequired, numeric ≥0
net_currencyrequired, ISO-4217
modified_atoptional, 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 — pending ChannelReservationIntakeService (full-state modification intake).