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:
mode | Does | Its own ids field |
|---|---|---|
search (default) | find properties and price them | hotel_ids (sub-mode below) |
rooms | room pricing and availability | hotelIdsRooms |
rates | watch named rates: price and availability per product id | productIds |
reviews | guest reviews | hotelIdsReviews (+ ufi) |
reviewScores | review score summary and subscores | hotelIdsScores |
surroundings | nearby places | hotelIdsSurroundings |
details | static property details | hotelIdsDetails |
attractions | search bookable tickets, tours and activities | — (a place, not ids) |
attractionDetails | one attraction in full | attractionIds |
attractionAvailability | which dates an attraction is bookable | attractionIds |
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:
searchBy | Fill in | |
|---|---|---|
auto (default) | anything | the sub-mode is inferred from what you filled |
destination | search | a city, region or landmark name. It also resolves hotel names, but not reliably — for one property prefer hotel_ids |
hotelIds | hotel_ids | numeric Booking ids, one record per id whatever the answer (scenario 8 ) |
cityId / regionId | cityId / regionId | a Booking destination by numeric id; renamed from city / region, which still work |
point | latitude, longitude, optional radius | nearest first; radius is km, applied client-side |
area | swLat, swLon, neLat, neLon | a 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.
“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.
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:
| Surface | Needs a search? | Turn on with |
|---|---|---|
| Price and availability | no | (always returned) |
| Rooms and rates | no | includeRooms |
| Guest reviews | no | includeReviews |
| Review scores and subscores | no | includeReviewScores |
| Nearby places | no | includeSurroundings |
| Static details | yes, one lookup per property | includeDetails |
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.
ratesThe 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:
checkin/checkout;adults, rooms, childrenAges. The ID has a segment that
looks like occupancy and is not dependable: it equals the room’s maxOccupancy
on 86 of 130 rates and is 0 on 42, the per-bed dormitory ones;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:
status | means |
|---|---|
available | the rate is on sale; price, priceWithCharges, cancellationType, prepaymentPolicy, paymentTypes and the full product are filled |
not_offered | the 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_failed | we 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.
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:
mode | Excel sheets that fill | Map (KML) |
|---|---|---|
search | Properties, and Rates / Rooms / Reviews for whatever was included | works — one pin per property |
rooms | Rates (33 rows), Rooms (5 rows); Properties holds the id only | 0 placemarks |
reviews | Reviews (2 rows); Properties holds the id only | 0 placemarks |
reviewScores | nothing beyond the id | 0 placemarks |
surroundings | nothing beyond the id | 0 placemarks |
details | nothing beyond the id | 0 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.
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:
| Block | Keeps |
|---|---|
price | price, freeCancellation, geniusRate, dealBadges |
availability | availability (status, min stay, alternatives) |
property | stars, review score, address, coordinates, accommodation type |
rooms | rooms, products — the full rate table |
details | facilities, photos, house rules, languages |
reviews | reviews |
reviewScores | reviewSubscores, reviewSummary |
surroundings | nearby 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.
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:
| what | why |
|---|---|
attractions: latitude / longitude renamed to cityLatitude / cityLongitude | they 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 removed | null 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 removed | empty 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 removed | read 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:
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.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).
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:
| Destination | Available | Returned | Coverage | Slices |
|---|---|---|---|---|
| Singapore | 401 | 400 | 99.8% | none needed |
| New York | 522 | 519 | 99.4% | none needed |
| Jakarta | 2,659 | 2,636 | 99.1% | 10 |
| Tokyo | 5,844 | 5,822 | 99.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.
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:
| cause | what to do about it |
|---|---|
none | nothing — the run delivered everything available |
max_items | raise maxItems |
charge_limit | raise the run’s max total charge, or ask for fewer optional surfaces |
destination_ceiling | set deepSearch: true — Booking stops yielding new properties past ~1,100 per search |
slice_ceiling | part of the destination could not be narrowed below that ceiling; it is under-covered |
incomplete | fewer 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 }
}
rows — a whole property (or attraction) never reached the dataset.fields — the row arrived with an optional field empty: reviews, review
scores or nearby places. This matters because outputFields lets you omit
fields on purpose, so an accidental gap looks deliberate.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.
sortBy offers exactly the orderings Booking’s own app offers, and nothing else:
sortBy | Booking’s own label |
|---|---|
price | Price (low to high) |
review_score | Guest review score |
stars_desc | Property rating (high to low) |
stars_asc | Property rating (low to high) |
distance | Distance from downtown |
homes_first | Entire homes & apartments first |
popularity / none | Our 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.
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.
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:
| input | example | notes |
|---|---|---|
search | "Bali", "Tokyo" | resolves to a city or a region automatically |
cityId | -2140479 | a numeric destination id, if you already know it |
regionId | 835 | a 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:
"Lithuania" resolves to nothing. Cover a country by its
regions or cities instead."Shinjuku", "Manhattan" and
"Newark" all resolve to nothing, while "Brooklyn" and "Jersey City" are
their own destinations. If a district fails, search its city.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 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.
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.
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.
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.