API reference
Pull API
Endpoints for partners reading from Staylah — handshake, mapping, availability, bookings.
All under /api/connectivity/v1.
Handshake#
GET /ping#
Scope: none. A key issued before commercial terms are agreed holds no scopes at all and must still be able to prove it works.
{
"success": true,
"data": {
"pong": true,
"environment": "sandbox",
"server_time": "2026-08-29T09:14:22+00:00"
},
"request_id": "…"
}
server_time is our clock. If you are implementing signing, diff it against
yours — a stale-request rejection is otherwise indistinguishable from a bad
secret at your end.
GET /whoami#
Scope: none. This is the endpoint that answers "why am I getting a 403".
{
"success": true,
"data": {
"partner": { "code": "dida", "name": "Dida Travel" },
"key": {
"key_id": "slk_test_9f2c1ab34d5e6f7089ab12cd",
"name": "Dida sandbox",
"environment": "sandbox",
"requires_signature": false,
"expires_at": null,
"rate_limit_per_minute": 300
},
"scopes": ["ari.read", "content.read", "booking.quote", "booking.write"],
"mapped_properties": 12
}
}
No secret material is ever returned. mapped_properties is a count, not a
list — the identifiers you may use are your own mapped codes, which
/mapping/hotels serves.
Two numbers here explain nearly every 403: the scopes you actually hold, and
whether mapped_properties is 0.
Mapping#
Scope: content.read
GET /mapping/hotels#
Every property mapped and switched on for you. Driven by active
property_distribution_configs, so a property the commercial team pauses
disappears from your list automatically — there is no second switch for anyone
to forget.
A code appears here only when both are true: a mapping row exists (what the
code means) and the property is switched on for you. A hotel you expected and
cannot see is either unmapped or paused — GET /whoami reports how many
properties you have in total, which usually tells you which.
{
"success": true,
"data": {
"hotels": [
{ "hotel_code": "DIDA-KL-001", "name": "Bukit Bintang Tower A",
"city": "Kuala Lumpur", "state": "WP", "country": "MY", "currency": "MYR" }
],
"count": 1
},
"request_id": "…"
}
currency is on the list, not just on the rates response, so you can set up FX
handling per hotel once at onboarding.
GET /mapping/hotels/{hotelCode}#
One property's room types and rate plans in your codes, with the occupancy limits and board type each rate plan implies. The occupancy bounds are there so you can avoid sending searches that can never match.
{
"success": true,
"data": {
"hotel_code": "DIDA-KL-001",
"name": "Bukit Bintang Tower A",
"currency": "MYR",
"max_child_age": 12,
"room_types": [
{
"room_type_code": "DLX-KING",
"name": "Deluxe King",
"room_size": "32 sqm",
"units": 24,
"max_occupancy": { "guests": 3, "adults": 2, "children": 2 },
"rate_plans": [
{ "rate_plan_code": "BAR-BB", "name": "Best Available — Breakfast",
"board_type": "breakfast", "base_occupancy": 2,
"nationality_restricted": false }
]
}
]
},
"request_id": "…"
}
Field notes, each of which is a real source of mis-integration:
max_occupancyis the room's ceiling.base_occupancyis how many people the rate covers before extra-person pricing applies. Confusing the two is how a four-adult booking gets quoted at a two-adult price.max_child_ageis the property's threshold. Anyone above it is an adult for pricing, so send them in your adult count — sending them as a child is refused withoccupancy_not_supportedrather than silently repriced at check-in.nationality_restricted: truemeans you must sendnationalityon searches and quotes against that plan. Without it the plan is refused, and a refusal you cannot explain is a support ticket.board_typeis normalised to a small closed set (room_only,breakfast,half_board,full_board,all_inclusive). Anything we do not recognise passes through lowercased rather than being dropped.- A room type mapped for inventory only — no rate plan — appears with
inventory_only: trueand an emptyrate_plans. It is mapped; it just has no price to sell.
Availability#
Scope: ari.read
Both are POST, not GET: the occupancy array is nested and repeated per room,
which a query string cannot express without inventing an encoding both sides
have to agree on.
POST /availability/search#
Which of your mapped properties have anything sellable for a stay. Deliberately lightweight — no per-night breakdown, no policies — so you can call it across a wide property set and cache the result.
| Field | Rules |
|---|---|
hotel_codes | required, array, 1–200 |
hotel_codes.* | required, string, ≤120 — your codes |
check_in | required, Y-m-d, today or later |
check_out | required, Y-m-d, strictly after check_in |
rooms | required, integer, 1–30 |
occupancy | required, array, ≥1 |
occupancy.*.adults | required, integer, 1–20 |
occupancy.*.children | optional, integer, 0–10 |
occupancy.*.child_ages | optional, array |
occupancy.*.child_ages.* | integer, 0–17 |
nationality | optional, ISO-2 |
currency | optional, ISO-4217 |
Notes:
check_outis strictly aftercheck_in. A same-day checkout is a day-use booking — a different product on a different endpoint.- Send
child_ageswhenever children are present. Child pricing runs against each property'smax_child_agethresholds, and a guessed age is how a booking gets repriced at check-in. nationalitydrives nationality-restricted rate plans. It is a pricing input, not a guest attribute for reporting.
{
"hotel_codes": ["DIDA-KL-001", "DIDA-KL-002"],
"check_in": "2026-09-14",
"check_out": "2026-09-17",
"rooms": 1,
"occupancy": [{ "adults": 2, "children": 1, "child_ages": [7] }],
"nationality": "SG",
"currency": "SGD"
}
Response
{
"success": true,
"data": {
"check_in": "2026-09-14", "check_out": "2026-09-17", "nights": 3, "rooms": 1,
"hotels": [
{ "hotel_code": "DIDA-KL-001", "property_name": "Bukit Bintang Tower A",
"currency": "MYR", "available": true, "offers_count": 3,
"cheapest_offer": { "room_type_code": "DLX-KING", "rate_plan_code": "BAR-BB",
"total": 642.00, "currency": "MYR" },
"reason": null },
{ "hotel_code": "DIDA-KL-002", "property_name": "Chow Kit Residences",
"currency": "MYR", "available": false, "offers_count": 0,
"cheapest_offer": null, "reason": "no_inventory" }
],
"unresolved": []
},
"request_id": "…"
}
cheapest_offer.totalis the guest total for the whole stay, across allroomsrequested — not a nightly rate. It is what you owe us.reasonis set only whenavailableis false, and is the most common blocker across the offers we looked at. The vocabulary is theno_availabilitytable in §4.unresolvedlists hotel codes that did not resolve, with the reason. Codes are reported, not dropped: a search across 200 codes is not lost because one was retired, but a hotel that quietly vanishes from every search is impossible to debug.
POST /availability/rates#
The full picture for one property: per-night rate and inventory per room type and rate plan, restrictions, and the cancellation policy that applies.
One property per call on purpose — this response is large, and thirty hotels batched into one request produces a payload neither side can usefully time out on or retry.
| Field | Rules |
|---|---|
hotel_code | required, string, ≤120 |
check_in | required, Y-m-d, today or later |
check_out | required, Y-m-d, after check_in |
rooms | required, integer, 1–30 |
occupancy | required, array, ≥1 (same shape as search) |
room_type_codes | optional, array ≤100 of strings ≤120 |
rate_plan_codes | optional, array ≤100 of strings ≤120 |
nationality | optional, ISO-2 |
currency | optional, ISO-4217 |
The two code arrays narrow the response to codes you already know about.
Response
{
"success": true,
"data": {
"hotel_code": "DIDA-KL-001",
"property_name": "Bukit Bintang Tower A",
"currency": "MYR",
"requested_currency": "SGD",
"currency_note": "Rates are returned in MYR, the property's own currency. …",
"check_in": "2026-09-14", "check_out": "2026-09-17", "nights": 3, "rooms": 1,
"offers": [
{
"room_type_code": "DLX-KING", "rate_plan_code": "BAR-BB",
"room_type_name": "Deluxe King", "rate_plan_name": "Best Available — Breakfast",
"board_type": "breakfast",
"max_occupancy": { "guests": 3, "adults": 2, "children": 2 },
"currency": "MYR",
"sellable": true, "reason": null, "total": 642.00,
"nightly_rates": [
{ "date": "2026-09-14", "rate": 214.00, "line_total": 214.00 },
{ "date": "2026-09-15", "rate": 214.00, "line_total": 214.00 },
{ "date": "2026-09-16", "rate": 214.00, "line_total": 214.00 }
],
"availability": [
{ "date": "2026-09-14", "remaining": 4, "loaded": true, "open": true }
],
"restrictions": [
{ "date": "2026-09-14", "closed": false, "min_stay": 2, "max_stay": null,
"closed_to_arrival": false, "closed_to_departure": false }
],
"cancellation_policy": {
"type": "free_until_deadline",
"free_cancellation": true,
"deadline_utc": "2026-09-13T00:00:00+00:00",
"cutoff_hours": 24,
"penalty_pct": 100,
"no_show_pct": 100,
"description": "Free cancellation until … After that, 100% of the stay total. …"
}
},
{
"room_type_code": "TWIN", "rate_plan_code": "NR",
"sellable": false, "reason": "restricted",
"reason_detail": "2026-09-15 is closed to arrival, so a stay cannot start on it.",
"total": null
}
]
},
"request_id": "…"
}
Notes:
- Unsellable offers are returned, not filtered, each with
reasonandreason_detail. "Your dates are full" and "we have never loaded a rate for you on this plan" look identical once the row is dropped, and only one of them is something you can act on. nightly_rates[].rateis per room per night;line_totalis that night across the rooms you asked for;totalis the whole stay. They are read back from the same rows the total was summed from — never the total divided by nights, which invents a figure no night was sold at whenever the stay spans a rate change.availability[].loaded: falsemeans no calendar row exists for that date. That is a Staylah-side gap, not a full hotel — see §0.restrictions[].min_stay/max_stayare in nights.cancellation_policyis what you will be charged if you cancel, and it is snapshotted onto the booking when you create it. A property changing its terms afterwards does not change what an existing booking owes.
Currencies are never converted. Every figure is in the property's own
currency. If you send currency and it differs, we echo it back as
requested_currency and explain in currency_note — we do not convert, because
the FX feed behind our rate table is not currently reliable and a quoted rate we
cannot stand behind is worse than one you convert yourself.
Bookings#
Three things in this contract are load-bearing:
partner_referenceis required and is the idempotency key. A retriedPOSTwith the same reference returns the original booking — it never creates a second one.quote_tokencarries the price we offered. Present it, or accept that the live price wins.- Inventory is decremented atomically as part of creating the booking, not
checked-then-written. Two partners hitting the last room in the same second
both pass a check; only one passes an
UPDATE … WHERE remaining >= n.
POST /bookings/quote#
Scope: booking.quote
Prices an exact stay and returns a short-lived token. Optional in the flow, but strongly recommended: it is the only way to show a price and then book that same price.
| Field | Rules |
|---|---|
hotel_code | required, string, ≤120 |
room_type_code | required, string, ≤120 |
rate_plan_code | required, string, ≤120 |
check_in | required, Y-m-d, today or later |
check_out | required, Y-m-d, after check_in |
rooms | required, integer, 1–30 |
occupancy | required, array ≥1 (adults 1–20; children 0–10; child_ages.* 0–17) |
nationality | optional, ISO-2 |
currency | optional, ISO-4217 |
Response
{
"success": true,
"data": {
"quote_token": "qt_eyJ2Ijoi….9f2c…",
"expires_at": "2026-09-01T10:15:00+00:00",
"holds_inventory": false,
"note": "A quote fixes the price, not the room. Inventory is checked again when the booking is created.",
"hotel_code": "DIDA-KL-001", "room_type_code": "DLX-KING", "rate_plan_code": "BAR-BB",
"check_in": "2026-09-14", "check_out": "2026-09-17", "nights": 3, "rooms": 1,
"total": 642.00, "currency": "MYR",
"nightly_rates": [ { "date": "2026-09-14", "rate": 214.00, "line_total": 214.00 } ],
"cancellation_policy": { "…": "as on /availability/rates" },
"board_type": "breakfast"
},
"request_id": "…"
}
A quote fixes the price, not the room. holds_inventory is always false,
and that is deliberate: a token that held a room would let any partner exhaust a
hotel's availability by quoting and never booking. The room can still be lost
between quoting and booking, and POST /bookings re-checks availability before
it writes — so you may be quoted successfully and then get no_availability.
Handle it.
What invalidates a token. The token is signed over the fields that move the
money: the property, room type, rate plan, dates, room count, adult and child
counts, and nationality. Changing any of those makes it a token for a
different stay. Guest names, the contact block and special requests are not
in it, so correcting a spelling between quoting and booking is free — and neither
are child ages, because nothing prices off them and guests.*.age is optional
on the booking.
An unsellable stay does not produce a token. You get the same no_availability
or validation_failed you would get from /availability/rates, with the same
details.reason.
POST /bookings#
Scopes: booking.write and booking.quote
Creates a confirmed reservation. Idempotent on partner_reference; a repeat
returns the existing booking with 200 and a flag rather than 201.
| Field | Rules |
|---|---|
partner_reference | required, string ≤120 — your booking id, unique per partner, the idempotency key |
quote_token | optional, string ≤512 |
hotel_code | required, string ≤120 |
room_type_code | required, string ≤120 |
rate_plan_code | required, string ≤120 |
check_in | required, Y-m-d, today or later |
check_out | required, Y-m-d, after check_in |
rooms | required, integer 1–30 |
guests | required, array ≥1 |
guests.*.room_number | required, integer ≥1 |
guests.*.first_name | required, string ≤100 |
guests.*.last_name | required, string ≤100 |
guests.*.is_adult | required, boolean |
guests.*.age | optional, integer 0–120 |
guests.*.nationality | optional, ISO-2 |
guests.*.is_lead | optional, boolean |
contact | required, object |
contact.first_name | required, string ≤100 |
contact.last_name | optional, string ≤100 |
contact.email | required, valid email ≤255 |
contact.phone | optional, string ≤50 |
total_amount | required, numeric ≥0 |
currency | required, ISO-4217 |
special_requests | optional, string ≤2000 |
arrival_time | optional, H:i |
Notes:
guestsis a per-room list keyed byroom_number, so a two-room booking with different occupants per room is representable. A flat guest list is not, and collapses occupant counts into a single total.total_amountis what you believe you owe, in the currency you believe. It is checked against our figure, not trusted. A mismatch is a rejected booking, not a booking at your number.
{
"partner_reference": "DIDA-8827361",
"quote_token": "qt_…",
"hotel_code": "DIDA-KL-001",
"room_type_code": "DLX-KING",
"rate_plan_code": "BAR-BB",
"check_in": "2026-09-14",
"check_out": "2026-09-17",
"rooms": 1,
"guests": [
{ "room_number": 1, "first_name": "Ada", "last_name": "Lim",
"is_adult": true, "is_lead": true, "nationality": "SG" }
],
"contact": { "first_name": "Ada", "last_name": "Lim",
"email": "ada@example.com", "phone": "+6591234567" },
"total_amount": 642.00,
"currency": "SGD"
}
Response — 201 Created
{
"success": true,
"data": {
"partner_reference": "DIDA-8827361",
"staylah_booking_id": "BK-00042-0917",
"status": "confirmed",
"hotel_code": "DIDA-KL-001",
"room_type_code": "DLX-KING",
"rate_plan_code": "BAR-BB",
"check_in": "2026-09-14", "check_out": "2026-09-17", "nights": 3, "rooms": 1,
"total": 642.00, "currency": "MYR",
"created_at": "2026-09-01T10:02:11+00:00",
"quote": { "presented": true, "honoured": true }
},
"request_id": "…"
}
Idempotency#
partner_reference is the key, and it is enforced by a unique database index,
not by a check-then-write. A retry returns the original booking with 200 and
"is_duplicate": true — never a second stay, and never a second charge — and that
holds for two retries arriving at the same moment, which is the case a
select-then-insert loses. Retrying after a 500 is safe for the same reason.
The total is checked, not trusted#
total_amount is compared against our own figure for the stay:
- a difference greater than one cent →
409 rate_changed, withdetails.expected_totalanddetails.received_total. Nothing is created; re-quote and book the price you are given. - a
currencythat is not the property's →409 conflictwithdetails.reason: "currency_mismatch". We do not convert (see/availability/rates), so we cannot check it, and a total we cannot check is not one we will book against. Read the currency from/mapping/hotels.
What the quote token does on this call#
quote.reason | Outcome |
|---|---|
| (absent) | No token sent. Booked at the live price. |
— (honoured: true) | Token verified. Booked at the quoted price. |
expired | Booked anyway, at the live price. quoted_total and booked_total are both returned so you can see whether the price moved. |
stay_mismatch | 409 — the token prices a different stay. Nothing created. |
wrong_partner | 409 — the token was not issued to you. Nothing created. |
invalid_signature | 409 — the token does not verify. Request a fresh quote rather than reusing or editing one. |
An expired token does not fail the booking, because bouncing you back around a re-quote loop against inventory that has since moved helps nobody. What it never does is silently book at its own price — the response always says which figure was used.
Inventory#
Rooms are taken atomically as part of the same transaction that writes the
booking: UPDATE … WHERE remaining_inventory >= n, per night, every night. Two
partners hitting the last room in the same second both pass a check; only one
passes that. If any night fails, nothing is booked and nothing is held, and
you get 409 no_availability with details.dates naming the nights that failed.
GET /bookings/{reference}#
Scope: booking.read
Current state of a booking by your own reference. Scoped to the calling partner — you can never read another partner's booking, even with a correct reference.
reference must match [A-Za-z0-9._-]{1,120}. It is your reference, so it may
carry characters Laravel's default segment pattern would swallow; the pattern is
deliberately conservative rather than open, because a slash in a reference would
match a different route entirely.
Returns the same body as POST /bookings, plus guests, contact and
cancellation_policy — the terms the booking was sold under, not today's.
A cancelled booking also carries cancelled_at and penalty.
404 not_found means no booking with that reference exists for you. It is
deliberately the same answer as one belonging to another partner, so a reference
cannot be used to probe whether somebody else's booking exists.
POST /bookings/{reference}/cancel#
Scope: booking.cancel
Cancels and returns the penalty actually applied.
| Field | Rules |
|---|---|
reason | optional, string ≤500 |
expected_penalty_amount | optional, numeric ≥0 |
currency | optional, ISO-4217 |
expected_penalty_amount lets you refuse a cancellation that would cost more
than you expect, instead of discovering the penalty afterwards.
The penalty is computed against the policy in force at booking time, not the
policy in force today — a property changing its terms must not change what an
existing guest owes. The policy is snapshotted onto the booking when it is
created and is what GET /bookings/{reference} returns as
cancellation_policy; the cancellation reads that snapshot and never the rate
plan.
Response
{
"success": true,
"data": {
"partner_reference": "DIDA-8827361",
"staylah_booking_id": "BK-00042-0917",
"status": "cancelled",
"cancelled_at": "2026-09-12T04:31:00+00:00",
"penalty": {
"amount": 0.00, "currency": "MYR", "percentage": 0,
"band": "free",
"explanation": "Cancelled before the free-cancellation deadline quoted at booking."
},
"cancellation_policy": { "…": "the snapshot this was charged against" }
},
"request_id": "…"
}
penalty.band is the machine string to branch on:
band | Meaning |
|---|---|
free | Before the deadline quoted at booking. amount is 0. |
after_deadline | Past the deadline. percentage of the stay total. |
no_show | Cancelled on or after the arrival date. |
unknown_policy | No policy was recorded against the booking, so nothing was charged. Only possible on bookings predating the policy snapshot. |
expected_penalty_amount is a ceiling, honoured before anything is cancelled.
If the penalty exceeds it, you get 409 conflict with
details.reason: "penalty_exceeds_expected" and both figures, and the booking is
left exactly as it was — you can re-send without the ceiling once you have
decided. That is the entire point of sending one: discovering the penalty after
the room is gone leaves you no way back.
Send currency with it. A ceiling in a currency other than the booking's is
refused as uncheckable (409 conflict) rather than silently ignored — we do not
convert, and silently ignoring it would defeat the purpose.
Cancelling an already-cancelled booking returns the original outcome with
"is_duplicate": true. It does not re-stamp the time or charge a second penalty.
