Apify Toolkits

Input reference

Every input the Booking.com scraper takes, what it does, and which ones add a charge event - the part a generated field description cannot carry.

Describes build 1.0.173 of rl1987/booking-api-scraper, deployed 2026-10-29. The column set last changed 2026-10-29. Earlier builds can return a different one.

Every input this Actor takes, what it does, and what it costs. The Input tab on the Store page is generated from the schema and is the place to check a field’s exact type and default; this page is the part a field description cannot carry — how the modes differ, which toggles add a charge event, and where a run’s limits really come from.

Two dropdowns, answering different questions.

mode — what this run does. Either a search, or one enrichment surface for properties whose ids you already have:

modeDoesIts own ids field
search (default)find properties and price themhotel_ids (sub-mode below)
roomsroom pricing and availabilityhotelIdsRooms
rateswatch named rates: price and availability per product idproductIds
reviewsguest reviewshotelIdsReviews (+ ufi)
reviewScoresreview score summary and subscoreshotelIdsScores
surroundingsnearby placeshotelIdsSurroundings
detailsstatic property detailshotelIdsDetails
attractionssearch bookable tickets, tours and activities— (a place, not ids)
attractionDetailsone attraction in fullattractionIds
attractionAvailabilitywhich dates an attraction is bookableattractionIds

Each mode has its own input group, with the mode dropdown above them all. Nothing is shared: every mode has its own Hotel IDs field, and the options a mode needs are repeated in its group rather than borrowed — rooms has its own “only rates that fit” and “itemise taxes” switches, reviews its own “max reviews”. A value left in one group cannot change another mode, and filling the wrong group is refused by name:

Mode "rooms" works on properties you already have ids for.
Put them in "hotelIdsRooms" (the Hotel IDs field in that mode's own group).

Inputs several modes legitimately read sit above the groups, beside the mode dropdown rather than inside one group where they would look mode-specific: stay dates, guests, currency, language. Search prices that stay, rooms re-prices it, reviews uses it when no ufi is given.

The universal-but-rarely-touched settings — output shape, limits, exports, proxy — stay in their own groups at the end, out of the way. hotel_ids still works as a fallback for every mode, so existing tasks and API callers run unchanged.

searchBy — how a search finds properties. Six sub-modes of search, ignored by the enrichment modes:

searchByFill in
auto (default)anythingthe sub-mode is inferred from what you filled
destinationsearcha city, region or landmark name. It also resolves hotel names, but not reliably — for one property prefer hotel_ids
hotelIdshotel_idsnumeric Booking ids, one record per id whatever the answer (scenario 8 )
cityId / regionIdcityId / regionIda Booking destination by numeric id; renamed from city / region, which still work
pointlatitude, longitude, optional radiusnearest first; radius is km, applied client-side
areaswLat, swLon, neLat, neLona custom rectangle; all four corners required together

auto keeps the original behaviour: the sub-mode is inferred from whichever field you filled in, and if you filled several the run says which it used and which it ignored, in the log and the status message, rather than choosing silently:

You set 2 location modes. Used Hotel IDs; ignored destination name.

Precedence, highest first: hotel_ids → cityId → regionId → point → bounding box → search.

Choosing a sub-mode explicitly drops the others’ fields, so a leftover value cannot change where the run searches, and a sub-mode picked with nothing filled in is rejected rather than falling through. searchBy was called searchMode; the old name still works.

Giving no location is rejected, with a message naming the fields you could set. It used to default to Amsterdam and bill you for 100 properties.

country was retired — Booking’s mobile backend has no country-level search. Cover a country by its regions, or a bounding box.

Flexible dates: a window instead of one stay

“The cheapest single night in the next 30 days” is a different question from “this exact night”, and checkin/checkout can only express the second. Set windowDays and stayNights to ask the first:

// every single night in the next 30 days, for one hotel
{ "searchBy": "hotelIds", "hotel_ids": ["1392565"],
  "windowDays": 30, "stayNights": 1,
  "outputFields": ["price", "availability"] }

// two-night stays starting any day next week, across a destination
{ "search": "Ghent", "windowDays": 7, "stayNights": 2, "maxItems": 200 }

One row per property per stay, each carrying its own checkin/checkout, so the output is a rate calendar you can sort by price:

2026-09-26 -> 2026-09-27  total=150.58  available
2026-09-27 -> 2026-09-28  total=98.10   available
2026-09-28 -> 2026-09-29  total=134.10  available

The window starts today, or at checkin if you set one; a past checkin is pulled forward rather than sweeping dates Booking cannot quote.

Each day in the window is a separate search, billed separately — a 30-day window costs roughly 30× a single-date run. Two things keep that in hand: maxItems is a budget across the whole sweep, not per day (maxItems (3) reached after 1 of 5 stays; the rest of the window was not searched.), and windowDays is capped at 60.

Only search and rooms accept a window. The others return the same answer whatever the dates, so sweeping one would bill the same record repeatedly — that is refused with an explanation rather than run.

Enrichment modes: re-check what you already have

Each enrichment mode calls the one Booking endpoint that surface needs and bills only that surface — never a search result: an enrichment row carries one surface and no property summary, so there is no search result to bill.

What each mode fires, and what one property costs in requests against events, is in what each mode bills — one table rather than one per page.

Only rooms returns prices, so only a rooms row carries a currency. The other four return static facts, and the field was the run’s currency input echoed onto a row with no money in it — misleading beside childAndBedPolicy, whose amounts are in the property’s currency and carry their own code. So details, surroundings, reviewScores and reviews rows have no currency key. Stay dates are still echoed everywhere, so you can tell which request produced a row.

// re-price a comp set every morning, no search
{ "mode": "rooms", "hotel_ids": ["1392565", "235613"],
  "checkin": "2026-11-20", "checkout": "2026-11-22" }

// reviews without the price payload; ufi from an earlier search run
{ "mode": "reviews", "hotel_ids": ["1392565"], "ufi": "-1958757", "maxReviews": 50 }

// nearby places only
{ "mode": "surroundings", "hotel_ids": ["1392565"] }

Which surfaces need a search at all, once you have hotel ids:

SurfaceNeeds a search?Turn on with
Price and availabilityno(always returned)
Rooms and ratesnoincludeRooms
Guest reviewsnoincludeReviews
Review scores and subscoresnoincludeReviewScores
Nearby placesnoincludeSurroundings
Static detailsyes, one lookup per propertyincludeDetails

Static details is the exception: Booking’s property endpoint carries no slug, star rating, review count, property type or photo list, so on the search path those come from the search result for the same property, found by coordinates. mode: "details" skips that lookup and returns the content endpoint alone.

Why reviews wants a ufi. Booking’s review endpoint returns nothing without the destination’s ufi. Paste one from an earlier search run and each property costs a single request; leave it empty and one extra lookup per property derives it. A ufi identifies a destination, so one covers every property in it.

Watching named rates: mode rates

The enrichment modes re-check a property. This one re-checks a rate — one row per productIds entry, which is what a price or availability monitor needs.

{
  "mode": "rates",
  "productIds": [
    "25768602_431556758_2_0_0",
    "25768603_431556758_2_0_0",
    "941859811_398694054_2_1_0"
  ],
  "checkin": "2026-11-28",
  "checkout": "2026-11-30",
  "adults": 2,
  "rooms": 1,
  "currency": "EUR"
}

A product ID is a products[].id from an earlier search or rooms run. The property comes from the ID — the room id inside it carries the hotel id — so nothing else names it. Watched rates are grouped by property, so ten rates at one hotel cost one request.

Three things the ID does not carry, so they stay inputs and should match the run the IDs came from:

Every ID you ask about comes back, with a fixed set of columns whether or not the rate is still on sale, so a watchlist export has one line per watched rate:

statusmeans
availablethe rate is on sale; price, priceWithCharges, cancellationType, prepaymentPolicy, paymentTypes and the full product are filled
not_offeredthe rate is not on sale for this stay and party, or the property returned no rates at all (sold out, or not a property id) — reason says which
lookup_failedwe could not ask. This one is our problem, not an answer about the rate

Both price (before extra charges) and priceWithCharges are on the row deliberately: different numbers, and the second is the one checked against what the app shows a guest. A monitor diffing the wrong one sees movement no guest would be quoted.

fitsParty on the nested product is reported, not used as a filter: a watched rate is named by its ID, so a dormitory bed Booking would not sell to a party of two still returns available with fitsParty: false. Reporting “withdrawn” for “does not fit your party” is the one thing a monitor must not do.

Billing is per ID checked, not per rate found — a sold-out rate costs the same request, and that answer is what a monitor pays for.

Exports by mode: what Excel and KML actually contain

Both exports are built from the rows a run delivers, so their contents depend on the mode. The map export is empty in every enrichment mode — those rows carry no coordinates. Measured on one property with both exports on:

modeExcel sheets that fillMap (KML)
searchProperties, and Rates / Rooms / Reviews for whatever was includedworks — one pin per property
roomsRates (33 rows), Rooms (5 rows); Properties holds the id only0 placemarks
reviewsReviews (2 rows); Properties holds the id only0 placemarks
reviewScoresnothing beyond the id0 placemarks
surroundingsnothing beyond the id0 placemarks
detailsnothing beyond the id0 placemarks

Two things follow.

The Properties sheet is near-empty in enrichment modes. It keeps its full column set — name, stars, review score, address, coordinates — but an enrichment row carries none of those, because fetching them is what search is for. The row exists so the sheet’s ids line up with the others; it does not mean the property has no name.

Nearby places, review scores and static details have no sheet of their own. They are nested structures rather than tables, so they reach you through the dataset, not the workbook.

A map export with nothing to place logs it rather than leaving you to open the file:

Map export ready (0 placemarks): ...
  1 property was left off the map for having no coordinates.

If you want a map of properties you have ids for, run mode: "search" with searchBy: "hotelIds" — that path returns coordinates, so the pins land.

Modular output: take only the blocks you need

By default every record carries everything — pricing, property facts, rooms, reviews, scores, nearby places: around 28 top-level keys. Right for exploring, wrong for a job that runs every morning wanting a price and a status.

outputFields narrows the record to the blocks you pick:

BlockKeeps
priceprice, freeCancellation, geniusRate, dealBadges
availabilityavailability (status, min stay, alternatives)
propertystars, review score, address, coordinates, accommodation type
roomsrooms, products — the full rate table
detailsfacilities, photos, house rules, languages
reviewsreviews
reviewScoresreviewSubscores, reviewSummary
surroundingsnearby places

id, name, url, the stay dates, the currency and datesAdjusted are always kept, so rows stay joinable and re-checkable. datesAdjusted especially: it says Booking served a different stay than you asked for, which is what makes a price comparable or not.

Recipes:

// price monitoring — 12 keys instead of 28
{ "searchMode": "hotelIds", "hotel_ids": ["1392565", "235613"],
  "outputFields": ["price", "availability"] }

// availability checking only
{ "searchMode": "hotelIds", "hotel_ids": ["1392565"],
  "outputFields": ["availability"] }

// review mining, no pricing payload
{ "searchMode": "destination", "search": "Ghent",
  "includeReviews": true, "includeReviewScores": true,
  "outputFields": ["reviews", "reviewScores"] }

This changes what you receive, not what you are charged. Billing follows what was fetched and delivered: the search still ran, so it is still billed. To spend less, turn off the include* toggles so the requests are never made — narrowing output afterwards refunds nothing.

One exception: with exportKml on, coordinates are kept even without the property block, because a KML without them is an empty map.

Upgrading: what changed for existing runs

Search-mode runs are unchanged. A saved task sending search, dates, guests and the include* toggles produces the same records, fields and charges as before — verified by diffing old and new builds on the same input, property by property.

Breaking changes, all of them. Each one removes or refuses something that was wrong rather than merely unhelpful, and each is loud instead of silent:

whatwhy
attractions: latitude / longitude renamed to cityLatitude / cityLongitudethey are the destination centroid, not the product: 21 products in three cities gave three pairs, one per city. A product’s own are on an attractionDetails row
attractions: cancellationLabel removednull on 143 of 143 products; freeCancellation, stillRefundable and cancellationUntil fill every time
scrapedAt / scrapedAtUnix added (not a breaking change)ISO-8601 UTC to milliseconds, and epoch seconds, on every row: when that row’s price was fetched, not when it was pushed. Booking’s own date strings are not ISO — do not share a parser. RUN_SUMMARY.window bounds the run
extra_charges.conditional[] and conditionalTotal removedempty everywhere: 0 of 4,934 room-list lines, 0 of 405 search lines. A line’s own inclusion still reads conditional, and such a line now groups with excluded, never unknown. depositTotal stays — a refundable deposit there would overstate the bill
bookerCountry removedread by nothing since the day it was added — never in src/, never on a request. The point of sale follows the proxy exit country, now reported per row as provenance.exitCountry

The earlier 1.0.94–1.0.141 wave — geniusDiscountPercentage, sortBy: "price_desc", the cot/extra-bed booleans and the conditional charge bucket — is in docs/upgrading.md .

sortBy: "price_desc" fails rather than changing quietly: a task pinned to it stops at input validation, before the Actor runs. Deliberate — the alternative was serving a different order under the old label, and a wrong answer that looks right beats no answer for nobody. It is refused by value, not just dropped from the dropdown, because sortBy: "price" with sortDirection: "descending" resolved to the same thing and would have left it reachable by a second route while appearing gone.

Three more things changed, and only one can affect an existing schedule:

  1. Two flags that did nothing now work — and are billed. includeSurroundings without includeDetails, and includeReviewScores without includeReviews, were silent no-ops: flag on, no request, no data, no charge. They now fetch what you asked for and pay the matching per-item charge. If one was set by accident, turn it off.
  2. No location is rejected instead of defaulting to Amsterdam, where a run used to return 100 Amsterdam properties and charge for them. It now finishes with an explanation and charges nothing.
  3. country was retired. It never worked — Booking’s mobile backend has no country-level search — so it only ever produced an error.

Renames are aliased, not breaking: city and region still work beside cityId and regionId, searchMode defaults to auto (the old inference), and an empty outputFields gives the full record (the old shape).

How many properties can you actually get?

Booking’s search stops returning new properties at roughly 1,000 per destination, and it does not tell you it has stopped: past that point it keeps answering with full pages of properties it has already sent. Every scraper built on this API inherits that ceiling.

deepSearch works around it by asking several narrower questions instead of one broad one: it reads the destination’s true total, splits by star rating, splits any slice still too large into price bands — bisecting until every slice fits under the ceiling — then pages each and merges. Every slice keeps the destination, so results never wander into a neighbouring city, and a property is returned, and billed, only once however many slices find it.

Because Booking reports that total, a run states its own coverage rather than guessing: the log ends with, for example, 5,822 of 5,844 properties (99.6%).

Measured 2026-09-23, check-in 2026-11-10, two nights:

DestinationAvailableReturnedCoverageSlices
Singapore40140099.8%none needed
New York52251999.4%none needed
Jakarta2,6592,63699.1%10
Tokyo5,8445,82299.6%28

Planning is cheap — Tokyo costs 54 one-row count queries — and a destination under the ceiling is detected in one query, so a small town triggers none of this.

Two caveats. Slices overlap, because a property matches a price band if any of its rates falls inside it, so the run fetches more rows than it delivers; de-duplication means you are never billed twice, but the requests are real. And the mobile API holds slightly less inventory than the website — 5,844 against 6,084 for Tokyo — so 100% here means 100% of what the API offers.

Telling a complete run from a truncated one

A run that stops early still ends successfully, and the rows it did deliver look exactly like the rows of a run that got everything. So every run writes a RUN_SUMMARY record to its own key-value store, saying what complete would have been:

{
  "requested": 100, "delivered": 100, "available": 419,
  "truncated": true, "truncationCause": "max_items",
  "maxItems": 100, "deepSearch": false,
  "givenUpRequests": { "total": 0, "rows": 0, "fields": 0, "byEndpoint": {} },
  "droppedClientSide": { "filters": 0, "duplicate": 0, "outside_destination": 0,
                         "radius": 0, "sold_out": 0 }
}

available is how many properties the destination holds for your dates and filters — what the website prints as “N properties found”. One extra count query buys it, so the log says Pushed 100 of 419 available accommodations (truncated at maxItems=100) rather than Pushed 100/100, which meant only “we stopped” and could equally have meant “that was all of them”.

truncationCause is one of:

causewhat to do about it
nonenothing — the run delivered everything available
max_itemsraise maxItems
charge_limitraise the run’s max total charge, or ask for fewer optional surfaces
destination_ceilingset deepSearch: true — Booking stops yielding new properties past ~1,100 per search
slice_ceilingpart of the destination could not be narrowed below that ceiling; it is under-covered
incompletefewer rows than ids requested (the hotel_ids and enrichment modes)

A cut-short run says so in its status message, so you need not read the record to notice; a clean run gets no message and truncationCause: none.

available is null for a coordinate or area search — Booking reports no total for those, and the area sweep prints its own coverage verdict instead.

givenUpRequests is the other way a dataset comes up short, and it means something different: Booking stopped answering and the Actor gave up after its retries. A truncation is a setting to raise; a give-up is an outage to re-run. The tally splits by what each cost:

"givenUpRequests": {
  "total": 4, "rows": 0, "fields": 4,
  "byEndpoint": { "graphql:PropertyPageReviewsQuery": 2, "graphql:PropertySurroundings": 2 }
}

The status message names whichever happened, and when a run both lost something to an outage and hit a limit it says both — outage first, because the remedy differs.

That makes completeness checkable by a program. A scheduled task diffing yesterday’s 100 rows against today’s cannot otherwise tell a stable destination from one that silently grew past the limit.

The dataset itself carries no completeness marker, deliberately. A per-row flag repeats one run-level fact a thousand times; an appended summary row is worse, being the one row with a different shape, which every consumer must special-case and anyone who forgets reads as a phantom accommodation with no price. Completeness lives in one place, and the output schema links RUN_SUMMARY beside the dataset.

Sorting: the orders Booking actually has

sortBy offers exactly the orderings Booking’s own app offers, and nothing else:

sortByBooking’s own label
pricePrice (low to high)
review_scoreGuest review score
stars_descProperty rating (high to low)
stars_ascProperty rating (low to high)
distanceDistance from downtown
homes_firstEntire homes & apartments first
popularity / noneOur top picks (Booking’s default)

The list is the app’s full sorter menu, checked both ways: every menu entry is reachable here, and nothing here is outside the menu. Both are asserted by tests, because the previous dropdown managed to be wrong in each direction at once — missing homes_first while offering an order Booking does not have.

There is no most-expensive-first order, and that is not a gap in this Actor: the sorter does not exist on the surface it searches. Booking’s SortType has no descending constant, its sorter input no direction field, and a sweep of 14 plausible tokens (price_descending, price_high, …) found every one falling back silently to Our top picks. The website’s descending sort comes from a different, partner-only API.

For most-expensive-first, sort the dataset after the run — every row carries its price. Asking the Actor to rank would mean ranking whatever was fetched first, which is how a “cheapest” sort once returned the 7th-cheapest property.

When Booking stops answering mid-run

Booking occasionally fails a burst of requests — usually a brief 502. The actor retries each six times with backoff, rotating its exit IP, and if the upstream is still failing it keeps what it has, stops, and says so: a partial result beats throwing away a scrape you have already paid for.

What it does not do is leave you guessing. A property lost that way is counted in RUN_SUMMARY.givenUpRequests and named in the status message:

Delivered 1 accommodation(s): 1 was skipped after Booking repeatedly failed to answer. This is an upstream outage, not the end of the results — re-run to pick up what was missed.

The count splits into rows and fields exactly as in Telling a complete run above. Re-running picks up what was missed, and de-duplication means you are not billed twice.

What a destination covers, and what it does not

A destination is Booking’s own idea of a place, and it can be narrower than the name suggests. "New York" is a city destination of ~520 properties; the surrounding metro — Jersey City, Hoboken, Brooklyn, North Bergen — sits under separate destinations, unreachable through it.

Three destination shapes work, and free text resolves to whichever fits:

inputexamplenotes
search"Bali", "Tokyo"resolves to a city or a region automatically
cityId-2140479a numeric destination id, if you already know it
regionId835a numeric region id - broader than a city

Autocomplete matches what Booking has a destination for, which is not always what you would guess: "Queens" resolves to Queenstown, New Zealand, while "Manhattan", "Newark" and "Long Island City" resolve to nothing. The run logs what it resolved to — read that line before trusting a sweep.

For ground with no destination of its own, use latitude/longitude with a radius: results come back nearest-first, trimmed to the radius.

Two gaps worth knowing before you plan around them:

When a run returns nothing

A run that could not have worked finishes successfully with an empty dataset and an explanation in its status message, rather than failing. You are not charged, and it does not count against the Actor’s failure rate — a mistyped city is your input to fix, not a fault in the Actor.

You will see it for a date that will not parse or is past, a check-out before its check-in, an unresolvable destination, or hotel_ids that are not numeric. The status message names the problem: Nothing to scrape: could not resolve destination: 'Delfr'.

An empty dataset from a valid search means something different: the search genuinely matched nothing for those dates. Widen the dates or drop filters.

What each charge event pays for

What each mode bills has it: which events each of the ten modes fires, what one unit of each is, which toggles add one, and the four that are not priced yet. It is generated from the same JSON the tests hold against the mode enum and the event names in the code, so it cannot drift from what a run actually charges.

The one rule that covers every row: charging happens on delivery, not on request. A surface that fails or comes back empty is free.

Accommodation types

accommodationTypes takes Booking’s numeric ids as strings ("201"); the snake_case accommodation_types also accepts integers and Booking’s own en-us names, plus synonyms such as apartment, B&B, aparthotel, holiday home, guest house, campsite and glamping, case-insensitively. Every result’s accommodationType uses the same labels.

The ids, the names and every accepted spelling are tabled in docs/data-dictionary/id-reference.md , generated from the Actor’s own mapping tables — along with the other numeric vocabularies (room type, bed type, charge type, payment method) and how complete each one is.

Children and family stays

Child ages are supported — and required for correct family prices. Booking.com prices a stay by each child’s age (free cots, kids-stay-free rules, extra beds), so 2 adults + children aged 2 and 9 pays differently from 2 adults + teenagers, and differently again from 4 adults. Give the actor the ages and it sends them on every request — destination search, hotel page, room list — so every price is for exactly your party.

Two equivalent spellings; the flat fields win when both are present:

{ "adults": 2, "rooms": 1, "childrenAges": [5, 9] }
{ "guests": { "number_of_adults": 2, "number_of_rooms": 1, "children": [5, 9] } }

Per-room guests.allocation[] ([{ "number_of_adults": 2, "children": [5] }, …]) is accepted too, and summed, because the mobile API takes aggregate occupancy.

With children in the party, family rooms surface and rooms that cannot host the party are marked fitsParty: false (and by default left out of products); each product carries occupancy.requestedChildrenAges so you can confirm the ages reached Booking; and every result’s web url includes group_children / age, so clicking through lands on the same party.

Verified on Hotel Hortus, Amsterdam (2026-12-10 → 12, EUR): 2 adults + children (2, 9) = 452.80, 2 adults + (12, 15) = 603.73, 4 adults = 603.73 — the same 0.75 ratio the booking.com website shows.

The attractions modes

Booking sells more than places to stay. The three attractions modes read its bookable-product catalogue — museum admissions, guided tours, day trips, transfers — a different vertical from the six property modes, sharing nothing with them but the location primitive. No stay window, no guests, no room blocks.

attractions — search the catalogue. Give it somewhere to look: a place name in attractionsQuery, Booking’s destination id in attractionsUfi, or an attractionsLatitude/attractionsLongitude pair with an optional radius. One row per product with price and currency, review score and count, duration, cancellation terms, bookable dates, city, the city’s coordinates (cityLatitude / cityLongitude — a ufi is a destination, so every product of a city shares one point) and a link. A product’s own coordinates are on an attractionDetails row, where they fill on about a third of products.

A note on free text, because it can be wrong. attractionsQuery resolves through Booking’s autocomplete, which is ambiguous for landmarks: “Eiffel Tower” offers a Jamaican locality called Tower Isle as well as the Paris landmark, and “Colosseum” matches a three-product Australian locality exactly while the Rome original sits under points of interest. The run prefers an exact landmark match, then an exact destination match, then the destination with most products, and logs every candidate and its choice. If that is wrong, pass attractionsUfi, which is exact. Filters (attractionsTypes, attractionsLabels, attractionsMinRating) and attractionsSortBy are applied by Booking, not client-side.

attractionsMinRating is out of 5, not out of 10. Booking scores attractions 1-5, while every property mode here reports the familiar 1-10 — so 4.5 is strict, and the 8 or 9 you would type for a hotel is off the scale. Booking answers an off-scale value with an empty result set and HTTP 200, which reads as “this city has no attractions”: in Amsterdam, Paris and Tokyo alike, 5.5 and above match zero of several thousand products. So the input is a dropdown of the nine half-steps from 1 to 5, and anything outside ends the run at once with the star equivalent named (pass 8, get told to use 4).

attractionDetails — one attraction in full. Takes attractionIds from an attractions run and adds what the search card lacks: category, the full description, what the ticket includes and excludes, restrictions, meeting point and address, accessibility and health notes, guide and audio-guide languages, the bookable offers, operator and terms links, and the photo set.

attractionAvailability — which dates work. Takes attractionIds plus attractionsStartDate and attractionsEndDate (absolute or relative, e.g. 2 weeks) and reads Booking’s availability calendar: the bookable dates in your window, how many days were checked, how many were unavailable, and the first and last bookable date.

It also reports calendarFrom / calendarTo, the span Booking actually publishes, which is what separates “in the horizon and sold out” from “your window is past the horizon”. On one product the calendar ran 2026-10-13 to 2027-04-11, 132 of 181 days bookable; a December window came back fully available, and a June 2027 window returned daysChecked: 0 — not unavailable, just beyond what Booking publishes yet.

Each row carries a status saying which kind of answer it is, because three different things end with no bookable dates and a buyer filtering on bookableInWindow would otherwise read a mistyped id as “sold out”:

The status column tells the three negatives apart — no availability in your window, a window outside the calendar Booking publishes, and an id that resolved to nothing. Its field description on the attraction availability page says which is which, and why the last one has to be inferred.

Timeslots, if you want them. attractionsIncludeTimeslots adds the bookable timeslots per available date: start time, offer and ticket type, tickets remaining and price per ticket class. Booking serves timeslots one date per request, so this costs one extra request per date, capped by attractionsTimeslotDays (default 3, max 14); the run logs how many dates it skipped. One December date on a museum product returned 108 timeslot/ticket rows — 09:00 and 09:15 entries with Adult, Child and audio-guide tickets priced separately.

One limit: Booking exposes no deep-link field for an attraction, so the url is built from the product slug and country — correct in testing, but constructed rather than returned.