Staylah

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.

json
{
  "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".

json
{
  "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.

json
{
  "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.

json
{
  "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_occupancy is the room's ceiling. base_occupancy is 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_age is 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 with occupancy_not_supported rather than silently repriced at check-in.
  • nationality_restricted: true means you must send nationality on searches and quotes against that plan. Without it the plan is refused, and a refusal you cannot explain is a support ticket.
  • board_type is 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: true and an empty rate_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.

FieldRules
hotel_codesrequired, array, 1–200
hotel_codes.*required, string, ≤120 — your codes
check_inrequired, Y-m-d, today or later
check_outrequired, Y-m-d, strictly after check_in
roomsrequired, integer, 1–30
occupancyrequired, array, ≥1
occupancy.*.adultsrequired, integer, 1–20
occupancy.*.childrenoptional, integer, 0–10
occupancy.*.child_agesoptional, array
occupancy.*.child_ages.*integer, 0–17
nationalityoptional, ISO-2
currencyoptional, ISO-4217

Notes:

  • check_out is strictly after check_in. A same-day checkout is a day-use booking — a different product on a different endpoint.
  • Send child_ages whenever children are present. Child pricing runs against each property's max_child_age thresholds, and a guessed age is how a booking gets repriced at check-in.
  • nationality drives nationality-restricted rate plans. It is a pricing input, not a guest attribute for reporting.
json
{
  "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

json
{
  "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.total is the guest total for the whole stay, across all rooms requested — not a nightly rate. It is what you owe us.
  • reason is set only when available is false, and is the most common blocker across the offers we looked at. The vocabulary is the no_availability table in §4.
  • unresolved lists 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.

FieldRules
hotel_coderequired, string, ≤120
check_inrequired, Y-m-d, today or later
check_outrequired, Y-m-d, after check_in
roomsrequired, integer, 1–30
occupancyrequired, array, ≥1 (same shape as search)
room_type_codesoptional, array ≤100 of strings ≤120
rate_plan_codesoptional, array ≤100 of strings ≤120
nationalityoptional, ISO-2
currencyoptional, ISO-4217

The two code arrays narrow the response to codes you already know about.

Response

json
{
  "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 reason and reason_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[].rate is per room per night; line_total is that night across the rooms you asked for; total is 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: false means no calendar row exists for that date. That is a Staylah-side gap, not a full hotel — see §0.
  • restrictions[].min_stay / max_stay are in nights.
  • cancellation_policy is 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:

  1. partner_reference is required and is the idempotency key. A retried POST with the same reference returns the original booking — it never creates a second one.
  2. quote_token carries the price we offered. Present it, or accept that the live price wins.
  3. 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.

FieldRules
hotel_coderequired, string, ≤120
room_type_coderequired, string, ≤120
rate_plan_coderequired, string, ≤120
check_inrequired, Y-m-d, today or later
check_outrequired, Y-m-d, after check_in
roomsrequired, integer, 1–30
occupancyrequired, array ≥1 (adults 1–20; children 0–10; child_ages.* 0–17)
nationalityoptional, ISO-2
currencyoptional, ISO-4217

Response

json
{
  "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.

FieldRules
partner_referencerequired, string ≤120 — your booking id, unique per partner, the idempotency key
quote_tokenoptional, string ≤512
hotel_coderequired, string ≤120
room_type_coderequired, string ≤120
rate_plan_coderequired, string ≤120
check_inrequired, Y-m-d, today or later
check_outrequired, Y-m-d, after check_in
roomsrequired, integer 1–30
guestsrequired, array ≥1
guests.*.room_numberrequired, integer ≥1
guests.*.first_namerequired, string ≤100
guests.*.last_namerequired, string ≤100
guests.*.is_adultrequired, boolean
guests.*.ageoptional, integer 0–120
guests.*.nationalityoptional, ISO-2
guests.*.is_leadoptional, boolean
contactrequired, object
contact.first_namerequired, string ≤100
contact.last_nameoptional, string ≤100
contact.emailrequired, valid email ≤255
contact.phoneoptional, string ≤50
total_amountrequired, numeric ≥0
currencyrequired, ISO-4217
special_requestsoptional, string ≤2000
arrival_timeoptional, H:i

Notes:

  • guests is a per-room list keyed by room_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_amount is 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.
json
{
  "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

json
{
  "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, with details.expected_total and details.received_total. Nothing is created; re-quote and book the price you are given.
  • a currency that is not the property's → 409 conflict with details.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.reasonOutcome
(absent)No token sent. Booked at the live price.
— (honoured: true)Token verified. Booked at the quoted price.
expiredBooked anyway, at the live price. quoted_total and booked_total are both returned so you can see whether the price moved.
stay_mismatch409 — the token prices a different stay. Nothing created.
wrong_partner409 — the token was not issued to you. Nothing created.
invalid_signature409 — 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.

FieldRules
reasonoptional, string ≤500
expected_penalty_amountoptional, numeric ≥0
currencyoptional, 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

json
{
  "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:

bandMeaning
freeBefore the deadline quoted at booking. amount is 0.
after_deadlinePast the deadline. percentage of the stay total.
no_showCancelled on or after the arrival date.
unknown_policyNo 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.