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 }
]
}
Response — 202 Accepted
{
"success": true,
"data": {
"batch_id": "7c1f0a52-3e6b-4c9a-9f2d-1b8e5a0c4471",
"kind": "ari",
"hotel_code": "TAN-KL-77",
"state": "queued",
"items": 3,
"poll": "/api/connectivity/v1/ari/batches/7c1f0a52-3e6b-4c9a-9f2d-1b8e5a0c4471"
},
"request_id": "…"
}
202, not 200: the rates are not live at the moment we answer. Poll the
batch to find out when they are.
Latency warning while the queue worker is not running. Staylah's deployment currently applies batches inline rather than on a worker, so a large batch is written before the
202comes back and your request waits for all of it. The contract is unchanged — same response, same batch id, same polling endpoint — but size your timeouts for the batch, and keep to the 5,000 cap rather than treating it as theoretical. This will start behaving asynchronously without any change on your side.
What a row means when we write it#
availabilitysets the room type's calendar for that date. We recompute what is free from what is already sold — "8 available" means eight rooms exist, not eight free after the three already booked. Copying your number straight across would hand back inventory we do not have.rateis the net the property is owed and lands on the rate plan for that date. Staylah's own sell rate is not yours to state and is never overwritten by a push — on a date we have never loaded, it is seeded from your figure because the column cannot be empty; on every subsequent push it is left alone.closed,min_stay,max_stay,closed_to_arrivalandclosed_to_departurewrite a restriction for that cell, separately from the inventory count.closed: falsegenuinely reopens a date. The cell is a statement of its complete state — that is what makes this aPUTand a resend a no-op — so sendingfalseis how you lift a stop-sell. We do not skip falsey values.currency,single_rate,extra_adult_rateandextra_child_rateare accepted and validated but are not yet stored. Send them; nothing reads them today.
A rejected row does not fail the batch#
Rejection is per row. A year of rates with one unmapped room type keeps the other 4,999 cells — losing them would take the property unsellable for a year over one code. Rejected rows are reported by their index in the array you sent, so you can find them in your own payload.
The one exception is hotel_code: it dooms every row, so it is reported
synchronously as 404 unmapped_resource. The batch id still comes back in
details.batch_id, so support can start from the same handle either way.
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;
0 is Sunday. Notes that bite:
- Absent means all seven. An empty array does not.
days_of_week: []says "no days", and we apply it to none — expanding it to every day would apply a stop-sell you explicitly narrowed to nothing. from_dateandto_dateare both inclusive. A restriction range names the days it covers, unlike a stay, whose check-out date is not a night.- A range may cover at most 400 days. A longer one is rejected as
range_too_long. - Omitting
rate_plan_codewrites the room-type-wide rule, which applies to every rate plan on it. Sending a code that does not resolve is rejected, not quietly widened to the room type — closing every plan when you asked to close one is the more expensive mistake.
Answers 202 with the same batch envelope as PUT /ari, with kind: "restrictions". Note that one item expands to as many date cells as the range
has days, which is why the batch reports applied and cells separately.
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}. It is scoped to your partner, not
to the key that sent it — rotating a credential does not lose access to batches
the old key delivered — and no partner can read another's, even given a correct
id.
{
"success": true,
"data": {
"batch_id": "7c1f0a52-3e6b-4c9a-9f2d-1b8e5a0c4471",
"kind": "ari",
"hotel_code": "TAN-KL-77",
"state": "partial",
"counts": { "items": 3, "applied": 2, "rejected": 1, "cells": 2 },
"rejection_summary": { "unknown_rate_plan": 1 },
"rejections": [
{ "index": 2, "room_type_code": "DLX", "rate_plan_code": "PROMO-X",
"date": "2026-09-16", "code": "unknown_rate_plan",
"message": "The room type resolved, but no mapping exists for rate plan \"PROMO-X\"." }
],
"received_at": "2026-09-01T02:00:00+00:00",
"completed_at": "2026-09-01T02:00:04+00:00"
},
"request_id": "…"
}
state | Meaning |
|---|---|
queued | Accepted, not yet applied. Poll again. |
applied | Every row written. |
partial | Some rows written, some rejected. Read rejections. |
rejected | Nothing written — usually the hotel code. |
failed | Our job errored. Your payload is kept; we can replay it. Report it. |
counts.cellsis the number of date cells actually written, which differs fromappliedon a restrictions batch because one item is a range.rejection_summaryis exact;rejectionsis capped at 100. The pathological batch rejects all 5,000 rows for one reason, and keeping 5,000 copies of that reason helps nobody — so the cap hides repetition, never information. Read the summary first.indexis the position in theitemsarray you sent.
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 | required, numeric ≥0 |
sell_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. sell_amountis the one money figure to send, and it is required. It is what the guest paid you — the only amount you are actually in a position to know. It is recorded and reconciled against the sell rate we contracted with you for those dates; a difference comes back as asell_publish_mismatchwarning rather than being silently adopted.- Do not send a net amount. What the property is paid, and what Staylah keeps, are commercial terms we set in our own rate grid — they are looked up per night for the stay's dates, not taken from the delivery. There is no field for you to state them in.
Responses. This endpoint does not have a single success status, because "we stored your reservation" and "the hotel can see the arrival" are different outcomes and you should be able to tell them apart:
| Status | Meaning |
|---|---|
201 | Accepted and mapped. data.staylah_booking_id is set. |
202 | Accepted and stored, but not mapped to a booking. data.warnings says why. |
200 | A redelivery. data.is_duplicate is true and the original acknowledgement is returned. |
The acknowledgement body:
{
"partner_reference": "TAN-556677",
"guest_confirmation": "HTL-99123",
"status": "confirmed",
"intake_state": "mapped",
"received_at": "2026-08-31T04:12:07+00:00",
"staylah_intake_id": 4471,
"staylah_booking_id": "SLMY2600183",
"needs_reconciliation": false
}
intake_stateis one ofmapped,unmapped,orphan,failed. Alert on anything other thanmapped.staylah_intake_idis our handle on the delivery itself, and exists even when no booking does. Quote it (or therequest_id) to support and we can read your exact payload back.staylah_booking_idisnullunless the reservation is live in the CRS.
A reservation is never rejected for a mapping problem. An unmapped
hotel_code, an unmapped room_type_code or a failed booking write all return
202 with a named entry in warnings, and the delivery is flagged for a human.
Returning 422 would not un-sell the room; it would only mean the hotel never
learns someone is arriving.
Mapping warnings you may see: unknown_code, inactive_mapping,
not_distributed, ambiguous_mapping, unknown_rate_plan,
multi_room_collapsed, booking_write_failed.
Money warnings, all of them about the sell_amount you sent:
| Warning | Meaning |
|---|---|
sell_publish_mismatch | Your sell_amount is not the sell rate we contracted with you for those dates. The booking is priced from the contracted rate; your figure is recorded as a claim. Either your pricing has drifted or our rate grid is stale — worth a conversation either way. |
sell_currency_not_comparable | Your sell_amount is not in the property's currency, so it could not be checked. We do not convert. This is explicitly not the same as "it matched". |
sell_amount_stale | (modify only) The modification moved the stay but carried no sell_amount, so the figure on file no longer describes it and was not checked. |
multi_room_collapsed deserves a note: a bookings row carries one room type
and one rate plan, so a delivery with more than one room line becomes one
booking against the first line, and the remainder is flagged for splitting. Send
one reservation per room type if you need each represented separately.
Implemented by
ChannelReservationIntakeService. The raw body is committed tochannel_reservation_messagesbefore any mapping is attempted, so a delivery is never lost to a downstream failure.
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.
Responses: 200 when the reservation was found and cancelled (or was already
cancelled, in which case data.is_duplicate is true), 202 when it was an
orphan — data.is_orphan is true and the cancellation is recorded against a
placeholder row for reconciliation.
Implemented by
ChannelReservationIntakeService.penalty_amountis stored onchannel_reservations.claimed_penalty_amount— the column is namedclaimed_so nothing downstream mistakes your figure for the penalty we charge.
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.*.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 |
sell_amount | optional, numeric ≥0 |
sell_currency | required with sell_amount, 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.
Two field notes that cost real money when missed:
- Send
rooms.*.rate_plan_code. It is optional but it is how the stay gets priced. A modification that omits it leaves the booking with no rate plan to price against, so the revenue split cannot be computed from the rate grid and the reservation is flagged for reconciliation. - Send
sell_amountwhenever the guest total changed. Unlike on create it is optional here, because plenty of modifications do not touch the money — a name correction, a room swap at the same rate. But a modification that moves the dates or the room count without restating it leaves the figure on file describing a stay that no longer exists; we detect that, decline to check the stale figure, and return asell_amount_stalewarning. The booking is still priced correctly from the rate grid either way.
There is no net amount on this endpoint either. The property's portion is re-priced from our rate grid against the new dates, exactly as on create.
Because the delivery is the complete current state, a modification is also a
second chance at mapping: if the original delivery could not be mapped and the
missing mapping has since been added, the modification is what finally creates
the booking. The booking's booking_id and source are never restated — a
modification must not renumber a confirmation the hotel has already printed.
Responses: 200 normally, 202 when the reference was never delivered
(data.is_orphan).
Implemented by
ChannelReservationIntakeService.
