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 }
  ]
}

Response — 202 Accepted

json
{
  "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 202 comes 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#

  • availability sets 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.
  • rate is 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_arrival and closed_to_departure write a restriction for that cell, separately from the inventory count.
  • closed: false genuinely reopens a date. The cell is a statement of its complete state — that is what makes this a PUT and a resend a no-op — so sending false is how you lift a stop-sell. We do not skip falsey values.
  • currency, single_rate, extra_adult_rate and extra_child_rate are 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.

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; 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_date and to_date are 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_code writes 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.

json
{
  "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": "…"
}
stateMeaning
queuedAccepted, not yet applied. Poll again.
appliedEvery row written.
partialSome rows written, some rejected. Read rejections.
rejectedNothing written — usually the hotel code.
failedOur job errored. Your payload is kept; we can replay it. Report it.
  • counts.cells is the number of date cells actually written, which differs from applied on a restrictions batch because one item is a range.
  • rejection_summary is exact; rejections is 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.
  • index is the position in the items array 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.

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_amountrequired, numeric ≥0
sell_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.
  • sell_amount is 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 a sell_publish_mismatch warning 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:

StatusMeaning
201Accepted and mapped. data.staylah_booking_id is set.
202Accepted and stored, but not mapped to a booking. data.warnings says why.
200A redelivery. data.is_duplicate is true and the original acknowledgement is returned.

The acknowledgement body:

json
{
  "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_state is one of mapped, unmapped, orphan, failed. Alert on anything other than mapped.
  • staylah_intake_id is our handle on the delivery itself, and exists even when no booking does. Quote it (or the request_id) to support and we can read your exact payload back.
  • staylah_booking_id is null unless 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:

WarningMeaning
sell_publish_mismatchYour 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_comparableYour 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 to channel_reservation_messages before any mapping is attempted, so a delivery is never lost to a downstream failure.

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.

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_amount is stored on channel_reservations.claimed_penalty_amount — the column is named claimed_ 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.

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.*.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
sell_amountoptional, numeric ≥0
sell_currencyrequired with sell_amount, 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.

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_amount whenever 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 a sell_amount_stale warning. 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.