Booking.com Hotel Scraper – Prices, Room Rates & Reviews
Pricing
from $1.00 / 1,000 search results
Booking.com Hotel Scraper – Prices, Room Rates & Reviews
Scrape Booking.com hotel prices, room rates and availability via the mobile API. Full per-room rate table with itemised taxes and fees, family pricing by child age, guest reviews and property details — one actor, not four. Search by destination, GPS radius or hotel id. No API key needed.
Booking.com API Scraper – Hotel Prices, Rooms, Reviews & Availability
Scrape Booking.com at scale — hotels, live room prices, availability, full rate breakdowns, facilities, photos and guest reviews as clean, structured JSON. No login, no captchas, no browser automation. Give it a destination and dates and export to CSV, Excel, JSON or the API.
Whether you track competitor prices, build a travel app, feed a rate-shopping dashboard or do market research, this Booking.com data extractor turns the world's largest accommodation site into a structured feed.
Fast and reliable — calls Booking.com's own mobile app API (
mobile-apps.booking.com) instead of rendering pages, so it needs no browser automation or login and is far faster and more stable than page-rendering scrapers. No partner contract needed — it does not call the partner-gated Demand API and needs no affiliate ID, API key or approval.
What you can extract
- Hotel search results – every property for a city, region, free-text destination or GPS radius, with live total price, currency and booking links.
- Live prices and itemised taxes – per-stay pricing with every tax and fee broken out (city tax, VAT, service charge, tourism fee, cleaning fee…), in your currency.
- Family stays priced by child age – pass the ages and get the rates Booking would actually sell your party, family rooms surfacing and non-fitting rooms flagged.
- Rooms and availability – every room type and bookable rate: occupancy, structured bed layout, bedrooms and bathrooms, size, board, cancellation policy, payment options, and how many rooms are left at each price.
- Availability status – for a fixed list of hotels you always get a record, saying why there is no price: sold out, minimum stay not met (with the nights required), closed, or unknown id.
- Guest reviews – paginated, with score, title, positive/negative text, date and reviewer country.
- Property details – name, star rating, review score and count, coordinates, address, 100+ named facilities, family amenities, languages, house rules, photos.
Example use cases
- Hotel price monitoring & rate shopping – track competitor and market prices over time for revenue management.
- Family & group travel products – price parties with children correctly, and show only rooms that fit.
- Travel & metasearch products – power a booking site, app or comparison tool with fresh inventory.
- Market & investment research – analyze pricing, occupancy signals, ratings and amenities across a destination.
- Review & reputation analysis – mine guest sentiment and review trends.
- Lead lists & data enrichment – build accommodation datasets with geo and facilities context.
Why this scraper, not a flat-listing one
Most Booking.com scrapers return one row per hotel with a single total price. Enough to see who is cheapest today; not enough for revenue management, a family stay, or explaining a number to a client. This actor returns the layer underneath:
- It tells you why a hotel has no price. Query a fixed list of ids and you always get a record back — sold out, minimum stay not met (with the nights required), closed, or unknown id — instead of the property vanishing.
- It searches by GPS radius or bounding box, not just destination name.
- It is one actor, not four. Search, details, rooms and reviews come from the same run, so you do not chain a listing scraper into a reviews scraper into a photos scraper.
- There is no per-run start fee. That matters more than it looks on a schedule: a start fee is charged whether a run returns 2 rows or 2,000, so it falls hardest on exactly the small, frequent runs price monitoring is made of. Most alternatives charge one.
How to use it
No coding required. Set your search up in the visual input form, then run it on demand, schedule it, or call the API and connect Make, Zapier, n8n, Google Sheets and more. Results land in a dataset you can download as JSON, CSV, Excel or HTML.
Children / 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.
Ready-made examples (one click, no setup)
Nine published tasks sit on this Actor's Examples tab: rate-shopping a fixed compset room by room, city-wide price monitoring, sell-out tracking for compression dates, full rate tables with taxes, reviews with per-category subscores, cheapest well-rated properties, family pricing by child age, self-catering supply with bedroom counts, and a landmark radius search. Open one, press run, and it works with the input already filled in; running it copies it into your account, so you can change anything afterwards without touching the original. None pins a date — check-in defaults to 30 days ahead, so they return live availability rather than going stale.
Dates, absolute or relative. Every date input — checkin, checkout,
attractionsStartDate, attractionsEndDate — takes either a chosen day
(2026-12-10) or a relative value (30 days, 2 weeks, 1 month). Prefer the
relative form in anything saved or scheduled: a fixed date is correct the day you
set it and then drifts into the past, and Booking only quotes future stays, so a
schedule built on one eventually rejects its own input. Both are resolved to a
real date before any request goes out, and every row carries that date, not the
words.
Use cases
Every input below validates against the actor's input schema; every output is a
trimmed record from a real run on 2026-09-17 (fields not relevant to the scenario
are replaced with …). The complete record shape is on the Output tab of the
Store page.
1. Family stay with children
Two adults with a 5- and a 9-year-old want two nights in Amsterdam and need to
know what it will actually cost, per room type. childrenAges makes Booking
price the party by age; includeRooms adds the room list. By default
(onlyFittingProducts: true) products holds only rates Booking would sell this
party, while rooms lists every room type offered for it — including ones that
do not fit, flagged fitsParty: false.
That is every room type Booking offers, which at some party sizes is fewer than
the property has: Booking withholds a room's rates entirely when the party cannot
use it, and a room with no rates cannot appear in rooms. On one property, 2
adults returned six room types while 1 and 4 returned all seven — the missing one
was a single room. roomsOnlyFitting: false returns every rate Booking will
offer, but cannot recover one it withheld. Query a smaller party for the fullest
inventory.
{"search": "Amsterdam","checkin": "2026-12-10","checkout": "2026-12-12","currency": "EUR","adults": 2,"childrenAges": [5, 9],"includeRooms": true,"maxItems": 3}
{"id": 10422,"name": "ibis Amsterdam Centre","url": "https://www.booking.com/hotel/nl/ibiscentre.html?…&group_adults=2&group_children=2&age=5&age=9","price": { "total": 599.17, "extra_charges": { "excluded": 200.72, "…": "…" } },"rooms": [{ "name": "Family Room", "maxOccupancy": 4, "fitsParty": true,"productCount": 4, "minPrice": 799.9, "bedCount": 3, "hasSofaBed": false,"beds": [ { "type": "double", "count": 1, "label": "1 full bed" },{ "type": "twin", "count": 2, "label": "2 twin beds" } ],"childrenPolicy": { "maxChildrenFree": 0, "maxChildrenFreeAge": 3 } },{ "name": "Standard Double Room", "maxOccupancy": 2, "fitsParty": false,"productCount": 0, "minPrice": null, "…": "…" }],"products": [{ "id": "1042225_95453819_4_2_0", "room": 1042225, "roomName": "Family Room","fitsParty": true, "maxOccupancy": 4,"occupancy": { "adults": 4, "children": 0,"requestedAdults": 2, "requestedChildrenAges": [5, 9] },"price": { "base": 599.17, "total": 799.9,"extra_charges": { "included": [{ "charge": "CITYTAX", "name": "City tax", "mode": "percentage", "percentage": 12.5, "total_amount": 74.9 },{ "charge": "VAT", "name": "VAT", "mode": "percentage", "percentage": 21, "total_amount": 125.83 } ] } },"policies": { "cancellation": { "type": "free_cancellation", "…": "…" }, "…": "…" } }]}
occupancy.adults / children is how Booking allocates the party to this rate
(a 9-year-old may count as an adult-equivalent);
requestedAdults / requestedChildrenAgesprice.total
(599.17) is the display price before the listed taxes, and
price.total + extra_charges.excluded is the room's gross 799.9.
2. Price-monitor a fixed list of properties
You know the Booking ids of your competitors and want their headline price for a
date every morning, taxes itemised. hotel_ids skips the search and fetches each
property directly; the top-level price is the cheapest rate fitting the party
(2 adults by default), and extra_charges lists every tax and fee behind it.
{"hotel_ids": ["1392565", "235613", "10422"],"checkin": "2026-12-10","checkout": "2026-12-12","currency": "EUR","adults": 2,"includeExtraCharges": true}
{"id": 1392565,"name": "Mercure Amsterdam Sloterdijk Station","currency": "EUR","checkin": "2026-12-10", "checkout": "2026-12-12", "datesAdjusted": false,"price": {"base": 191.74, "book": 275.97, "total": 275.97,"extra_charges": {"included": 84.23, "excluded": 0,"details": [{ "charge": "VAT", "name": "VAT", "mode": "percentage", "percentage": 21, "total_amount": 40.26, "currency": "EUR", "inclusion": "included" },{ "charge": "CITYTAX", "name": "City tax", "mode": "percentage", "percentage": 12.5, "total_amount": 23.97, "…": "…" },{ "charge": "SERVICECHARGE", "name": "Service charge", "mode": "per_night", "unit_amount": 10, "total_amount": 20, "…": "…" }]},"priced_block": { "block": "139256509_91477112_2_34_0", "room": 139256509, "source": "hotelPage" }},"availability": { "status": "available", "requestedNights": 2, "minStayNights": null, "alternatives": [] },"deep_link_url": "booking://hotel/1392565?affiliate_id=337862&checkin=2026-12-10&checkout=2026-12-12&mcid=10"}
Add includeRooms: true to price from the full rate table instead of the
recommended rate (priced_block.source becomes roomList), or
childrenAges to monitor a family price.
Taxes and point of sale. Booking decides per point of sale whether taxes sit inside the displayed price. Through Apify Proxy (US datacenter) the same Mercure rate is
total: 191.74with those three charges"excluded"(extra_charges.excluded: 84.23); from an EU IP they areincludedandtotalis 275.97.total + extra_charges.excludedis the all-in figure in both cases — compare that across runs.
3. Cheapest well-rated hotels in a city
The five cheapest hotels in Amsterdam scoring 8+ and at most €300 for the stay. Rating, price and sort are applied client-side on top of Booking's own order, so the actor keeps paging until it has enough.
{"search": "Amsterdam","checkin": "2026-12-10","checkout": "2026-12-12","currency": "EUR","minReviewScore": 8,"maxPrice": 300,"sortBy": "price","maxItems": 5}
{"id": 2374707,"name": "Hotel Levell","accommodationType": "Hotels","starRating": 3,"reviewScore": 8.4,"reviewScoreWord": "Very Good","reviewsCount": 15170,"address": "…", "city": "Amsterdam", "countryCode": "nl","latitude": 52.36, "longitude": 4.88,"currency": "EUR","checkin": "2026-12-10", "checkout": "2026-12-12","price": { "book": 113.86, "total": 113.86,"extra_charges": { "excluded": 38.14, "included": 0,"details": [ { "charge": "CITYTAX", "mode": "percentage", "total_amount": 14.23, "chargeTypeId": 22 },{ "charge": "VAT", "mode": "percentage", "total_amount": 23.91, "chargeTypeId": 21 } ] } },"freeCancellation": true,"availability": { "status": "available", "soldOutMessage": null },"url": "https://www.booking.com/hotel/nl/lowell.html?aid=337862&checkin=2026-12-10&…","deep_link_url": "booking://hotel/2374707?…"}
Name, address, star rating, review score, coordinates and the badges (free
cancellation, Preferred) come back on every search result at no extra cost.
This plain record is the baseline; rooms, products, reviews and details
are added only when you switch them on.
4. Apartments and holiday homes only
Restrict a destination search to self-catering places. accommodationTypes takes
Booking's numeric ids (201 Apartments, 220 Vacation Homes — see
Accommodation types); the API also accepts names, e.g.
"accommodation_types": ["Apartments", "holiday homes"]. With includeRooms
each unit reports its bedroom and bathroom count.
{"search": "Amsterdam","checkin": "2026-12-10","checkout": "2026-12-12","currency": "EUR","accommodationTypes": ["201", "220"],"includeRooms": true,"maxItems": 3}
{"id": 2197656,"name": "houseboat Rose","accommodationType": "Apartments","accommodationTypeId": 201,"address": "Amstel 171G", "city": "Amsterdam","price": { "total": 475.5, "…": "…" },"rooms": [{ "name": "Two-Bedroom Apartment", "maxOccupancy": 3, "fitsParty": true,"bedroomCount": 2, "bathroomCount": 1, "privateBathroom": true,"bedCount": 2, "hasSofaBed": false,"beds": [ { "type": "double", "count": 2, "label": "2 full beds", "size": "131–150 cm wide" } ],"surfaceM2": 60, "productCount": 1, "minPrice": 634.8, "currency": "EUR" }],"url": "https://www.booking.com/hotel/nl/houseboat-rose.html?…"}
bedroomCount is null for ordinary hotel rooms — Booking publishes a count only
for apartment-like units — so you never guess it from the room name.
5. Guest reviews for competitive analysis
The 50 most recent guest reviews of a property, in British English, for a
sentiment pipeline. Reviews page 10 at a time, so maxReviews goes well beyond
the first page.
{"hotel_ids": ["1392565"],"checkin": "2026-12-10","checkout": "2026-12-12","includeReviews": true,"maxReviews": 50,"language": "en-gb"}
{"id": 1392565,"name": "Mercure Amsterdam Sloterdijk Station","price": { "…": "…" },"reviews": [{ "id": "a32af29c3af79834","title": "Excellent hotel close to Amsterdam, Haarlem as well as Schiphol.","pros": "Very friendly and helpful staf. Great clean rooms with plenty of space and a great bathroom.","cons": "N/a","score": 10,"date": "2026-09-12","author": { "name": "Johan", "countryCode": "se", "countryName": "Sweden" } },"… 49 more"]}
6. Radius search around coordinates
Everything within 1 km of Dam Square, nearest first. Coordinates run Booking's own
lat/long search; the radius (km) cut-off applies to the returned coordinates,
and paging stops once a whole page falls outside it.
{"latitude": 52.3731,"longitude": 4.8926,"radius": 1,"checkin": "2026-12-10","checkout": "2026-12-12","currency": "EUR","maxItems": 5}
{"id": 10502,"name": "Swissôtel Amsterdam","latitude": 52.37350156398789,"longitude": 4.893312156200409,"address": "Damrak 96", "district": null, "city": "Amsterdam", "countryCode": "nl","reviewScore": 8.2, "starRating": 4,"price": { "total": 353.31, "…": "…" },"url": "https://www.booking.com/hotel/nl/swissotel-amsterdam.html?…"}
district is Booking's neighbourhood label, null where it has none (as in the
Amsterdam centre); latitude / longitude are always present.
7. Full property details for enrichment
One property, everything static: address, stars, review score, coordinates, the
full facility list, photos, languages, family amenities. On the hotel_ids path
the actor also looks the property up by coordinates to fill the fields the hotel
page lacks (web URL, star rating, review score, accommodation type, facilities,
photos).
{"hotel_ids": ["1392565"],"checkin": "2026-12-10","checkout": "2026-12-12","includeDetails": true}
{"id": 1392565,"name": "Mercure Amsterdam Sloterdijk Station","url": "https://www.booking.com/hotel/nl/mercure-amsterdam-sloterdijk-station-opening-july-2015.html?…","starRating": 4, "reviewScore": 8.6, "accommodationType": "Hotels","price": { "…": "…" },"details": {"name": "Mercure Amsterdam Sloterdijk Station","accommodationType": "Hotels", "accommodationTypeId": 204,"starRating": 4, "reviewScore": 8.6, "reviewScoreWord": "Excellent", "reviewsCount": 3899,"address": "Naritaweg 1, Westpoort, 1043 BP Amsterdam, Netherlands","city": "Amsterdam", "countryCode": "nl", "ufi": -2140479,"latitude": 52.387911, "longitude": 4.834296, "timezone": "Europe/Amsterdam","facilities": ["Parking", "Room service", "24-hour front desk", "… 106 more"],"familyFacilities": ["…"], "photos": ["…"],"languagesSpoken": ["pl", "nl", "fr", "es", "en-gb", "de", "ar"],"isFamilyFriendly": false, "wifiReviewScore": 8.4,"minCheckinAge": 18, "checkinAgeRestriction": "The minimum age for check-in is 18","mainPhotoUrl": "https://cf.bstatic.com/xdata/images/hotel/max1024x768/873579185.webp?…","isBookingHome": null, "bedroomCount": null, "houseRules": null}}
For apartment-like properties details adds isBookingHome,
isSingleUnitProperty, bookingHomeGroup, the unit's bedroomCount /
bathroomCount and houseRules.
8. Sold out or minimum stay?
A property that cannot host the stay never appears in a destination search — that
is Booking's behaviour. On the hotel_ids path the actor always emits one
record per id and explains itself in availability. Here Hotel Hortus enforces
a 2-night minimum over New Year's Eve, and id 100 does not exist.
{"hotel_ids": ["235613", "1392565", "100"],"checkin": "2026-12-31","checkout": "2027-01-01","currency": "EUR"}
[{ "id": 235613, "name": "Hotel Hortus","checkin": "2026-12-31", "checkout": "2027-01-01", "datesAdjusted": false,"price": { "book": null, "total": null, "extra_charges": { "excluded": 0, "included": 0, "details": [] } },"availability": {"status": "min_stay_not_met", "requestedNights": 1, "minStayNights": 2,"soldOutMessage": "Sold out on Booking.com","alternatives": [{ "checkin": "2026-12-31", "checkout": "2027-01-02", "nights": 2, "price": 440.22, "currency": "EUR" },{ "checkin": "2027-01-02", "checkout": "2027-01-03", "nights": 1, "price": 209.63, "…": "…" },"…" ] } },{ "id": 1392565, "name": "Mercure Amsterdam Sloterdijk Station","price": { "total": 314.57, "…": "…" },"availability": { "status": "available", "requestedNights": 1, "minStayNights": null, "alternatives": [] } },{ "id": 100, "name": null,"price": { "book": null, "total": null },"availability": { "status": "not_found", "message": "hotelPage: Hotel with id 100 was not found." } }]
availability.status is available, min_stay_not_met (with minStayNights),
sold_out, closed, no_fit_for_party (rates exist but none fits — only
detectable with includeRooms) or not_found. alternatives are the nearby
bookable stays Booking suggests. If Booking moved the dates, datesAdjusted is
true and checkin / checkout carry the echoed ones. Not-found records are
free. On the search path availability is
{ "status": "available" | "sold_out" }, and the log reports how many results
your filters or radius dropped client-side.
Input reference
What a run does: one mode, and a sub-mode for search
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.
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 available2026-09-27 -> 2026-09-28 total=98.10 available2026-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.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. Measured on one property:
mode | Requests | Charged |
|---|---|---|
rooms | 3 | room-details=5 |
reviews | 3 (2 if you pass ufi) | review=3, review-scores=1 |
reviewScores | 1 | review-scores=1 |
surroundings | 1 | surroundings=1 |
details | 2 | hotel-details=1 |
Re-measured 22 October 2026. rooms and details each gained the
cot-and-extra-bed lookup, and rooms also pays the one-field slug query behind
its url. The charges did not change — these are proxied requests, counting
toward platform usage rather than a per-item event.
search-result=0 in all five: an enrichment row carries one surface and no
property summary, so there is no search result to bill.
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.
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:
- the stay — a rate exists only for a given
checkin/checkout; - the party —
adults,rooms,childrenAges. The ID has a segment that looks like occupancy and is not dependable: it equals the room'smaxOccupancyon 86 of 130 rates and is0on 42, the per-bed dormitory ones; - children's ages, which move the price and are not in the ID at all.
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.
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:
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.
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:
| 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.
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 running old and new builds side by side on the same input
and diffing the output structure 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 |
|---|---|
products[].geniusDiscountPercentage removed | null on every row ever emitted, and a permanent null reads as "no Genius discount here" when it means "Genius is not visible to us" |
sortBy: "price_desc" refused | Booking has no most-expensive-first order on this surface; ranking it client-side was only ever as good as the over-fetch behind it. See Sorting above |
rooms[].babyCotAvailable, rooms[].extraBedAvailable, rooms[].extraBedPrice removed | false/false/null on every room of every property, including hotels that give you a cot free of charge. Replaced by childAndBedPolicy |
extra_charges.conditional[] / conditionalTotal changed meaning | they held charges whose status Booking did not state; those moved to unknown[] / unknownTotal, because conditional turns out to be a real value of Booking's own vocabulary |
Nothing can have depended on the first, since it never had a value. The cot
fields were actively wrong, so code reading them got a false answer. The
conditional bucket was empty on every row ever measured, so nothing moves in
practice — but if you read conditionalTotal as "charges of unstated status",
read unknownTotal now.
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:
- Two flags that did nothing now work — and are billed.
includeSurroundingswithoutincludeDetails, andincludeReviewScoreswithoutincludeReviews, 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. - 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.
countrywas 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:
| 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.
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)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 becauseoutputFieldslets 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.
Sorting: the orders Booking actually has
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.
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:
| 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:
- Countries are not searchable. Booking's mobile backend has no country
destination, so
"Lithuania"resolves to nothing. Cover a country by its regions or cities instead. - Districts usually are not either.
"Shinjuku","Manhattan"and"Newark"all resolve to nothing, while"Brooklyn"and"Jersey City"are their own destinations. If a district fails, search its city.
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
Every optional surface costing a separate API request is its own charge event, so you pay for what you asked for and nothing else:
| event | charged | covers |
|---|---|---|
| Search result | per accommodation returned | the property result with live pricing |
| Hotel details | per property | two requests - property page + static content - deliberately billed as one |
| Room details | per room option | the per-rate products list comes from the same request and is not charged again |
| Rate check | per product id checked | mode rates only - including rates that have sold out, which cost the same request |
| Review | per review returned | the paginated review list |
| Nearby places | per property | includeSurroundings only |
| Review scores | per property | includeReviewScores only, one request regardless of review count |
A surface that fails or comes back empty is free — charging happens on delivery, not on request.
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.
Output reference
Every column, with its type and what it means, is on the Output tab of this Store page — 113 of them, generated from the dataset schema, so it describes what the deployed build actually returns rather than what someone once wrote down. Read a field there first. The same tables, by mode and with a real row beside each, are in docs/data-dictionary/README.md.
What follows is the part a field description cannot carry: how the surfaces differ, what the measurements behind the caveats were, and the handful of columns that are read wrong often enough to be worth arguing about.
Each record is a search-response object: id, currency, checkin, checkout,
deep_link_url, url, price, availability, plus the free summary fields
(name, accommodationType, starRating, reviewScore, reviewsCount,
address, city, district, countryCode, latitude, longitude,
freeCancellation, isPreferred…) and, when switched on, rooms, products,
reviews, details.
starRatingdoes not say whether the stars are official. Booking awards some an official classification and lets others self-estimate; the mobile surface returnssymbol: "STARS"for both, so a 3 can be either and ranking by it mixes them.reviewScorecan likewise differ by 0.1 from the partner API for the same property the same day — both Booking's own numbers from different surfaces, and this Actor passes through the one the app shows.
Do not rebuild url — read it. Booking's slugs are not derivable from the
name: hotel 6331862, "Holiday Inn Express Amsterdam - North Riverside by IHG",
is holiday-inn-express-amsterdam-motorkade, after the street. Across 14
properties, 10 accommodation types and 9 countries every emitted URL loaded its
page, while two controls — a real slug under the wrong country, and one guessed
from the name — produced none on three attempts each. An unavailable slug leaves
null rather than a plausible dead link.
If you validate these links yourself, a failed fetch is not reliably distinguishable from a transient one: 2 of the 14 real pages needed up to three attempts, so a single-shot check calls a working link dead. Match on the property's name or address in the body, not a status code — and normalise, because Booking renames properties, 3 of the 14 no longer matching their slug.
price carries base, book / total, the summed
extra_charges.included / excluded, and extra_charges.details[] — one entry
per tax or fee, with its code, localised name, mode, rate and amount. The Output
tab has the keys; what matters here is that total means a different thing on
each path. On search it is Booking's display price before the listed charges,
so total + extra_charges.excluded is the gross rate, and entries carry only a
numeric chargeTypeId. On hotel_ids it is the rate as displayed for the run's
point of sale — taxes included from an EU IP, excluded through a US proxy (see
use case 2) — and priced_block says which rate produced it.
price.total_with_charges is the all-in amount — total plus everything
Booking reports as charged on top. Read that field rather than reassembling it:
-
Every entry in
extra_charges.details[]is excluded, whatever Booking's label says. Booking marks some linesINCLUDEDand its own aggregate counts them anyway — over 180 search rows in six markets the aggregate equalled the sum of all entries, every time. Soinclusionis normalised toexcludedhere, with Booking's token kept ininclusionRaw. Summing only the lines you think are excluded was the trap: on a VAT-charging property it dropped the whole VAT. -
inclusionis one ofincluded,excluded,conditional,deposit— Booking's own vocabulary — orunknown, which is ours and means Booking stated nothing.unknownis not "already paid for", so it gets its own bucket instead of counting as included.conditionalanddeposithave never arrived in any sample; they are carried because the API defines them, anddepositmatters most — a refundable deposit is not money you owe, so it stays out ofexcludedTotal.Changed 22 October 2026. Entries of unstated inclusion used to sit in
conditional[]/conditionalTotaland now sit inunknown[]/unknownTotal. See Upgrading for the measurement. -
extra_charges.excluded/includedare numbers on the property row and arrays at rate level.excludedTotal/includedTotal/conditionalTotal/depositTotal/unknownTotalare numbers at both, so write against those for one shape. -
extra_charges.chargesCompletenesssays whether that itemisation is all of it, and nothing else does. Booking's own value:all_included("Includes taxes and fees") orincalculable("Additional charges may apply" — more exists that Booking will not quantify). A rate can itemise VAT, a tourism fee and a service charge, mark all threeincluded, and still beincalculable, because the flag is about charges beyond the listed ones. SoexcludedTotal: 0means "nothing more to pay" only underall_included; underincalculablethe all-in figure is a floor. Present on all 1,821 rate blocks measured across eight markets (2026-10-21, EUR, EU exit). A third value,extra_charges, means "more exists and here is the figure" — seen on Istanbul rates with achargesAmountof 4.00 EUR. Treat the vocabulary as open: Booking does not constrain it, and its own app reads the value in one place, only to compare two rates for equality. Rate level only — hotelPage and search carry no equivalent, so the key is absent on property rows. -
extra_charges.chargesAmountis the figure that goes withextra_charges—{value, currency},0under the other two modes and omitted when zero, so its presence is the signal. It is an object, not a number: code coercing it to a float reads nothing. -
If the itemised lines stop adding up to Booking's aggregate, the row carries
extra_charges.reconciliationwith the delta and the run logs it; likewisecurrencyMismatchfor a charge in another currency. -
The search payload's charge lines are thinner, and cannot be thickened. Search gives a numeric type and an amount;
name,percentageandunit_amountarenull. The wire typeChargeDisplayIrenehas exactly four fields: 43 candidate names (name,chargeName,translation,chargeRate,label, …) were put to the server on 22 October 2026 and all were rejected, as were the sibling prose fields (chargesInfonull,taxExceptionsempty on 6 of 6 live rows). An upstream limit, not a mapping gap. A real breakdown — names, percentages, per-unit amounts — needsincludeRooms, which is also the only way to see charges absent from the search aggregate entirely (Dubai's 10% service charge, Las Vegas's resort fee). -
chargeis a code we recognise, orTYPE_<n>when we do not. Known:VAT(21),CITYTAX(22),TAX(23),MUNICIPALITYFEE(141),TOURISMFEE(142),HERITAGECHARGE(146),GOVERNMENTTAX(148). No list is published and none is in Booking's app, so the map grows only by observation — each id learned by matching a search amount, to the cent, against the named rate-level line for the same property.chargeTypeIdalways carries the raw number, so an unmapped charge stays identifiable, and the rate-levelnameis Booking's own label.
Compare against
total_with_charges, nottotal— not becausetotalis always wrong, but because it is only sometimes right, and the other column is right on both paths.Where Booking excludes the taxes from its display price the two diverge, and only the all-in figure matches the app: a Kuala Lumpur property showing "16 EUR, was 18" for one adult returns
total: 13.20/total_with_charges: 16.44andbefore_discount: 14.66/before_discount_with_charges: 18.01. The pre-tax pair matches nothing on screen. Where the taxes are inside the display price the two are the same number and both match: captured 2026-10-23, an app price sheet totalling MYR 151 againstgross_amount151.305, its MYR 32 of VAT, tourism fee and service charge already inside and nothing itemised beyond the prose "Additional charges may apply".
before_discount_with_chargesrecomputes the percentage charges on the undiscounted rate, because 8% VAT on 14.66 is 1.17, not the 1.06 it was on 13.20 — reusing the discounted total would read 17.90. Fixed charges (per night, per stay) carry across unchanged. It needsincludeRooms, the only place Booking states the percentage; a plain search omits the field rather than guessing.A third surface quotes a third figure, so name the one you check against. Booking's partner/affiliate connector returns a
bookprice equal to this Actor'stotal, to the cent on 8 of 8 Amsterdam properties carrying no mobile-only rate (400.72, 350.63, 325.23, 371.04, 262.58, 444.69, 460.69, 195.54 USD, 2026-10-22);total_with_chargesmatched it on 0 of 10. The other 2 areisMobileRate: true, where ourtotalis lower because the app sells a rate that surface never sees — 218.73 against 243.03 on hotel 6331862.before_discountis not the partner figure either, matching on one and missing on the other (448.50 against 426.08). An app comparison and a partner-API comparison are different checks, and a mobile rate is why they disagree — not a mapping error.
Undiscounted prices, and the mobile-only discount. This Actor reads Booking's mobile API, so its price already has the mobile-only discount applied where one exists. A desktop browser may show a higher number — not the Actor being wrong. Both figures are in the row:
| field | meaning |
|---|---|
price.total | what you pay through this channel (mobile discount applied) |
price.without_mobile_discount | the same rate without it — what a desktop browser shows |
price.mobile_discount | the mobile-only cut itself |
price.before_discount | with no discount at all (Booking's struck-through price) |
price.discount | the total saving against before_discount |
price.discount_percent | the saving as a % of the struck-through price |
price.discounts[] | every discount line: Booking's code, name, amount and its own wording |
price.isMobileRate | property level: whether this price is a mobile-only rate |
discount_percentis not Booking's badge. It isfrom Booking's own figures, and two things move it. The point of sale: the same rate is 10.0% where prices come back tax-exclusive and 9.2% where tax-inclusive, because the struck-through base differs — both correct for their own. And Booking's web page sometimes strikes through a different comparator than its mobile API: one page badged every rate "9% off", matching thediscounted_amount / strikethrough_amountbasic-dealrates but not the campaign-priced ones, whose API figures give 26.4%. Use it to rank savings within a run, not to reproduce a browser.
Discount codes seen so far: mobile-discount, basic-deal ("Bonus savings"),
early-booker-deal and campaign_<n> (a seasonal campaign, e.g. "Late Escape
Deal"). Campaign ids are open-ended, so the code passes through unmapped.
These keys appear only on a discounted rate — an undiscounted one does not
grow five empty columns. before_discount is exact, equalling total plus every
discount line on 488 of 488 rates carrying one. The per-rate figures need
includeRooms; without it the row carries only isMobileRate, because the
search payload states the badge and no amount.
mainPhotoUrl — one photo per property, on every search row. It rides
the search request itself, so it costs no extra call, no extra charge and does
not need includeDetails. Every row of a 25-row search carried a distinct URL
and all 25 answered 200 image/webp.
The gallery and full facilities list come from
mode: "details", and are much larger than the search row's versions. The static-content request carries both, at no extra call or charge. Four properties:
property popularFacilitiesfacilitiesphotos9176203 10 52 23 11141419 9 49 68 10004 9 67 84 257686 5 37 85
popularFacilitiesis Booking's own shortlist and keeps its own column. On the search path withincludeDetailsthese two hold the search result's values instead — 59 facilities and 2 photos on one property — so a search run's payload does not change. Usemode: "details"for the gallery.Both are absent, not empty, when Booking states none, so a property with no named facilities stays distinguishable from a surface that did not carry them.
rooms[] — one entry per room type with availability on your dates; the
Output tab lists its keys. Bed and bedroom data is from that room type only.
This is not the property's whole catalogue. It is what Booking will sell for your dates, usually fewer — across six property pages, one hotel listing 14 room types returned 4.
soldout_roomswas empty on every property checked, so what it withholds is named nowhere and the gap is undetectable from the data. Widen the dates for the full catalogue.
maxOccupancyis the largest occupancy Booking offers for your party, not the room's capacity. One room type is sold as several rates, one per occupancy, and only the fitting ones come back — so a room its page shows as sleeping 3 can report 2 on a 2-adult search.
bedCountcounts beds, not sleeping places. A bunk counts once and sleeps two, so a "14-Bed Private Room" can reportbedCount: 7— exactly what its own page's bed line says. UsemaxOccupancyfor capacity.
childrenPolicy is Booking's one-line summary for that rate — one age
ceiling and a count. The full bands with prices are childAndBedPolicy.ageBands
on the property row (below); prefer it for anything beyond "how many stay free".
Breaking change, 22 October 2026.
rooms[].extraBedAvailable,rooms[].extraBedPriceandrooms[].babyCotAvailableare removed. They werefalse/nullon every room of every property, including hotels that give a cot free: the wire keys still exist but Booking's own app stopped reading them, and there is no per-room source.childAndBedPolicybelow replaces them — age-banded, with prices, which is what Booking publishes.
childAndBedPolicy — the property's cot and extra-bed policy, the table
Booking prints under House rules → Children and beds. Present when the property
publishes one, omitted when it does not: "Booking does not say" and "no cot
here" are different answers, so this is never a guess.
"childAndBedPolicy": {"allowChildren": true,"cotAvailable": true,"extraBedAvailable": true,"ageBands": [{ "minAge": 0, "maxAge": 2,"cot": { "free": true, "amount": 0.0, "currency": "EUR","priceType": "free", "priceMode": "per_night", "guaranteed": false },"extraBed": { "free": false, "amount": 25.0, "currency": "EUR","priceType": "fixed", "priceMode": "per_night", "guaranteed": null },"existingBed": { "free": true, "amount": 0.0, "…": "…" } },// a percentage type carries no money: 70% of the adult rate, per night{ "minAge": 0, "maxAge": 2,"existingBed": { "free": false, "amount": null, "percentage": 70.0,"priceType": "adult_percentage", "priceMode": "per_night" } },{ "minAge": 3, "maxAge": 17, "cot": null, "…": "…" },{ "minAge": 255, "maxAge": 255, "cot": null, "…": "…" }],"notes": [ "All cribs and extra beds are subject to availability." ],"childrenNotes": [ "Children 18 and above will be charged as adults at this property." ],"summary": "Children of all ages are allowed.\n1 child (up to and including 2 years old) stays for free when using available cribs.…"}
Four traps, all of them measured:
255means "and above", not "adult", and the adult band is encoded two ways: hotel 6331862 gives255–255for the row its page prints as "Adult (18+ years)", Stikliai gives18–255for "3+ years", and Marina Bay Sands stops at3–12because it charges children 13 and over as adults. So the threshold is not always 18 — it is stated only inchildrenNotes. Both bounds pass through as sent.- The
currencybeside each amount is the property's own, not the run's. This endpoint does not convert: hotel 6331862 returns25.00for an extra bed on a USD run and an EUR run alike, byte-identical, and Kyoto's 9552136 returns5000.00on a USD run — read as USD, $5,000 for an extra bed against a true ~$32. The code always comes from a field Booking states it in, never from a symbol, because¥is JPY and CNY andkrthree Nordic currencies. Indetailsmode these amounts are the only money in the row, which carries nocurrencyat all. priceTypesays whether the number is money at all, and a percentage is not: over 29 properties the values arefree,fixedandadult_percentage, the last meaning percent of the adult rate. One London hotel charges a child in an existing bed 70% per night, carried aspercentage: 70.0withamountandcurrencynull. Readpercentagewhenever it is non-null; an unknown fourth type leaves all three null and passes the raw type through, because a number is recoverable from a re-run and a wrong price is not.- A
nullforcot,extraBedorexistingBedmeans that band has no such rule — verified both ways, which is what makescotAvailable: falsemeaningful. notesandsummarycarry the conditions, unparsed. The counts live insummaryand nowhere else ("The maximum number of extra beds is 1"); Booking publishes them only as prose, and parsing a localised sentence into an integer is the guess this field exists to avoid. Per-room maximum cots and extra beds, and whether the two combine, are partner-only — served to Booking's own booking funnel and to nothing else (201 rate blocks, every request flag set).
It rides with includeRooms or includeDetails, costing one extra request
per property.
Rooms offering a choice of bed layouts. Some rooms are sold as "1 queen bed
or 2 single beds", the guest picking at booking. bedsSelectable is true
there, bedConfigurationCount gives the number of layouts, and bedLayouts[]
nests the beds under each:
"bedsSelectable": true,"bedConfigurationCount": 2,"bedCount": 2,"bedLayouts": [{ "configuration": 0, "bedCount": 1, "beds": [ { "type": "queen", "count": 1 } ] },{ "configuration": 1, "bedCount": 2, "beds": [ { "type": "twin", "count": 2 } ] }]
Two traps it keeps you out of:
- Do not sum
beds[].count. It is flat across every layout, so the room above sums to 3 — a count existing in neither layout.bedCountis the largest single layout (2), the room's real capacity. UsebedLayoutsfor per-layout arithmetic. hasSofaBedmeans "in at least one layout" — good for discovery, wrong as a hard filter.sofaBedInEveryConfigurationis the strict reading.
roomTypeCode is Booking's internal category, resolved from the numeric
roomTypeId — the full enum is in
docs/data-dictionary/id-reference.md.
It may disagree with name, and that is Booking's taxonomy, not a defect.
On one property, two rooms called "Family Room" are typed QUADRUPLE and
TRIPLE, and a "Twin Room" is typed DOUBLE. Use name for display and
roomTypeCode to group comparable rooms across properties. An uncatalogued
category leaves roomTypeCode null and passes the numeric id through.
pricedPerBed is true for BED_IN_DORMITORY: that rate buys one bed in
a shared room, so its price is not comparable with a per-room one. Booking
distinguishes it from DORMITORY_ROOM, the whole room.
products[] — one entry per bookable rate, joined to rooms[].id by
room. Keys on the Output tab; the policies behind them are below.
A rate's commercial terms
products[].policies carries what the rate costs you to change your mind about,
how you may pay it and when each payment falls due. The keys are on the Output
tab; what is here is the behaviour behind them.
policies.cancellation
{"type": "partially_refundable","free_cancellation_until": "2026-11-17 23:59:59 +0200","bucket": "SMP_FLEX","description": "You can cancel for free until 1 day before arrival. ...","partialRefundFrom": "2026-11-18 00:00:00 +0200","partialRefundFee": 27.20, "partialRefundAmount": 27.20,"ladder": [{ "stage": 0, "state": "FREE", "until": "2026-11-17 23:59:59 +0200","fee": 0, "refund": 54.40, "isFree": true, "currency": "EUR" },{ "stage": 1, "state": "PAID", "from": "2026-11-18 00:00:00 +0200","fee": 27.20, "refund": 27.20, "isFree": false, "currency": "EUR" }]}
type is free_cancellation, non_refundable or partially_refundable —
that third state covers rates that charge only part of the stay after the
deadline, which used to be reported as fully refundable and overstated the
refund. state is FREE, PAID or NONREF, and timestamps carry the
property's UTC offset, because a deadline is in the property's local time.
ladder[] is the full schedule, one rung per window: fee is what cancelling
then costs, refund what comes back, and the two always sum to the stay total
in the ladder's own currency.
The ladder measures the room rate, not the all-in.
feeandrefundsum toprice.base, net of taxes and fees, because a tax collected at the property is not forfeit when you cancel — verified to the cent on six rate/currency pairs. A fee that looks short againstprice.totalis not an error.Amounts are in the currency you asked for. Booking sends each rung twice, in the property's currency and yours; this takes yours, and
ladder[].currency/payment.schedule[].currencysay which. True of every money field exceptdetails.damageDeposit.amount, which stays in the property's currency because a deposit is collected at the desk. Every amount carries its owncurrency, so read that, not the row's.
Booking's own refund figure is not always usable: on a three-rung ladder its
fee_remaining comes back negative on the terminal rung — the one you check to
confirm you get nothing. There refund is recomputed from the total, with
Booking's value kept as refundReported so the correction is visible:
| rung | state | from | fee | refund |
|---|---|---|---|---|
| 0 | FREE | — | 0 | 37.80 |
| 1 | PAID | 2026-11-17 | 18.90 | 18.90 |
| 2 | NONREF | 2026-11-20 | 37.80 | 0 |
A non-refundable rate normally has no ladder, and ladder is then [].
policies.payment — payment timing, and the prepayment schedule:
Changed 23 October 2026.
typesis now decided byprepaymentPolicyalone. It used to readpay_in_advancefirst, which means "prepaid at some point before arrival" rather than "prepaid now", so a rate stating no prepayment until a date — its own description promising a charge "within 2 days" — came back aspay_now. Over 688 rates in four cities, 20 were affected, a third of theno_prepayment_untilones; those now readpay_later, orpay_at_propertyunderno_prepayment_at_all. Nothing else changed.
types, payLaterDeadline, choosable, prepaymentPolicy, noPrepayment,
dueDate, amountDueNow and schedule[], each described on the Output tab.
Four of them are read wrong often enough to argue about here:
noPrepayment: truemeans "nothing due today", not "nothing due ever". True for bothno_prepayment_at_all(settle at the property) andno_prepayment_until(the full amount taken before arrival) — in one sample about a fifth ofnoPrepaymentrates do take payment first. ReadprepaymentPolicyto separate them,dueDatefor when.amountDueNowisnull, not0, when the rate states no payment stage. It is the current stage of Booking's prepayment timeline, and 198 of 688 sampled blocks carried a prepaying policy with no stage at all.nullis "not stated"; only0is "nothing is due". Readingnullas0turns every one of those into a false "pays nothing at booking".dueDateis not the cancellation deadline. "Cancel free until X" and "owe nothing until Y" are different obligations and differ in practice — on one rate payment fell due 16 November with free cancellation to 18 November. Across 48 comparable ratesdueDateis never the same day and always earlier.dueDateandpayLaterDeadlineare one deadline in two encodings — 60 seconds apart on 49 of 49 rows (2026-11-19T00:00:00Zagainst2026-11-18T23:59:00+00:00). The app shows it once, as "Pay nothing until 19 November".dueDateis absent on someno_prepayment_untilrates — 12 of 17 carried it across 130 products in three markets. The five without are real rates withamountDueNow: 0and a fullschedule[]; Booking simply states no deadline, and they readtypes: ["pay_later"], so a row can correctly say "pay later" with no date anywhere. Do not fall back toschedule[]: its boundary tracks free cancellation, not the charge —fromruns two days afterdueDatewherever both exist — and the app does not parse the stage dates at all. An absentdueDatemeans Booking did not say, and the app shows none either.
prepaymentPolicy passes through rather than mapping to a closed set. Observed:
full_prepayment, no_prepayment_at_all, partial_prepayment,
no_prepayment_until. The app binary's own constants are a narrower set
(full_prepayment, no_prepayment, partial_prepayment), so the server sends
values the app does not enumerate and a closed list of ours would eventually be
wrong. Treat it as a string; use noPrepayment for the yes/no.
policies.deposit — despite Booking calling the field deposit_required,
this is the pay-now / pay-later flag, not a damage deposit:
prepaymentAtBooking, noPaymentDueAtBooking, the verbatim depositRequiredRaw
and an opaque termsId that has no consumer in Booking's own app either.
noPaymentDueAtBooking and payment.types answer different questions and
often differ: the first is "is anything taken when you book", the second "when
may it be paid". A no_prepayment_at_all rate is pay_at_property and also
no-payment-at-booking; a rate that requires prepayment but lets you schedule it
is pay_now+pay_later and is payment-at-booking. Use types.
The real damage deposit is property-level and carries its amount — in
details.damageDeposit (needs includeDetails):
{ "policyType": "DAMAGE_DEPOSIT", "isDamageDeposit": true,"amount": 200.0, "currency": "EUR" }
null when the property asks for none. policyType separates a real deposit
from Booking's damage-protection programme, which is not money you put down.
amountis in the property's own currency — a Kuala Lumpur hotel returns50.0 MYRon a run requesting EUR. That is Booking's doing, not a conversion we skipped: a deposit is taken at the desk in local money.
paymentTerms (property level, with includeRooms) — what the property
requires in order to book, from the payment model down to whether a card is
needed at all. One of its keys needs explaining:
requestToBookRequiredis about instant booking, and it isnullon most properties. Booking runs two models: Instant Booking, confirmed on the spot, and Request to Book, where the host has 24 hours to accept (the guest then has 24 more to finish, price held and card pre-authorised rather than charged). Instant is the default; Request to Book is offered only to properties that qualify, in practice vacation rentals, where the host wants to vet who stays.So it appears only on that segment. Over 8 properties per type in two cities: hotels 0, hostels 0, B&Bs 1, apartments 2, vacation homes 7. On a hotel it is
nullbecause the question does not apply, not because data is missing — andnullis also what a property sends when it carries the field but does not use the model, passed through rather than invented as afalseBooking never stated.Read
trueas "this booking needs the host's approval". Over 77 room-list payloads in 8 destinations we have not seen one, so a non-null value is the interesting case.
Accepted payment methods — which cards, and whether cash is taken. Needs
includeDetails:
"acceptedPaymentMethods": [{"id": 1, "name": "American Express"}, {"id": 2, "name": "Visa"},{"id": 3, "name": "Mastercard"}, {"id": 10, "name": "Maestro"},{"id": 18, "name": "Cash only"}],"cashAccepted": true,"cardAccepted": true
Names are Booking's own and display-ready; ids are stable for joining and tabled
in docs/data-dictionary/id-reference.md. The list is wider
than cards — Cash only (18), ATM card, Debit card — and cash appears both
alone and beside real cards, so cashAccepted and cardAccepted are separate
booleans, null rather than false when Booking states nothing.
This is what the property accepts, at the property — not what you can pay with through Booking. Booking does not answer the second per card:
AcceptedCreditCardcarries only an id and a name. Read aVisaentry as "takes Visa at the desk", not "I can pay by Visa in the app".
For paying through Booking, use payment.types, prepaymentPolicy, and
paymentTerms's cardRequired / cvcRequired / noCardNeeded /
hostedPaymentPageOnly. One field bridges the two:
details.bookingManagedPayAtProperty — whether paying at the property is
handled through Booking rather than by the property directly.
details.houseRules is narrow, and on a hotel usually empty. It comes from
Booking's apartment-style ("booking home") block, so it is populated for
apartments and holiday homes and null for ordinary hotels. Where populated,
the observed vocabulary is POLICY_SMOKING, POLICY_HOTEL_PETS,
POLICY_PARTIES, POLICY_QUIET_HOURS.
The house rules that do answer for hotels are separate fields on the property
surface: details.petsAllowed, details.petsChargeMode and
details.groupBookingPolicy.
Both pets values are Booking's own tokens, populated on 16 of 16 properties and
agreeing with each page ("Pets are allowed on request. Charges may apply." is
UPON_REQUEST + CHARGES_MAY_APPLY). NOT_APPLICABLE arrives exactly when pets
are not allowed, so it means "no charge question to answer" rather than "we don't
know". groupBookingPolicy is per-property — "more than 6 rooms" at one hotel,
"more than 7" at another — and absent rather than empty on the 5 of 16 stating
no limit.
Four neighbouring rules are deliberately not emitted. curfew and
quietHours resolve on the same surface but were empty on all 16 properties.
smoking, parties and the property-level cancellation flag exist there too,
but Booking's app only asks for them after you have a booking, in its
post-booking check-in guide, and neither property-page query selects them. The
two with content are single negative phrases ("smoking is not allowed in all
rooms") rather than a state, and the cancellation flag is dead inside Booking's
own app — so for an anonymous run they would be three empty columns. Smoking and
party rules do reach you for apartment-style properties through
details.houseRules.
Booking's web house-rules block is richer — check-in/out, cancellation and
prepayment, damage deposit, children & beds, age restriction, pets, payment
methods — but that is a web surface. What this Actor gives you is
details.checkInTime / details.checkOutTime (verified identical to the web
page), the rate's terms under products[].policies, the childAndBedPolicy
table, and the check-in age:
details.minCheckinAge is null where the property states no requirement,
and details.checkinAgeRestriction is what makes that an answer rather than
missing data ("There's no age requirement for check-in").
This is not an adults-only flag. The age to check in and the age to stay are different rules and genuinely differ: Holiday Inn Express Amsterdam
- North Riverside returns
minCheckinAge: 18while welcoming children of all ages. For "does this property accept children", readchildAndBedPolicy.allowChildren. A graded minimum stay age (1–17) is not available anonymously — Booking has a policy type for it; it appeared on none of 22 properties probed.
refundabilityPremium (property level, with includeRooms) — what being
able to cancel costs here, when the property offers a comparable pair:
{ "pairs": 3, "medianDiscountPercent": 10.0,"minDiscountPercent": 10.0, "maxDiscountPercent": 20.0,"isolates": "cancellation.type","heldConstant": ["room", "meal_plan", "prepaymentPolicy"] }
Read isolates and heldConstant as part of the figure: pairs match on the same
room, board and prepayment policy, differing only in cancellation type.
Pairing on room alone mixes refundability with payment timing and means neither.
Across 27 properties, non-refundable is most often exactly 10% cheaper.
The field is absent, not null, where there is no comparable pair — most properties. There is deliberately no prepay-vs-pay-later equivalent: payment timing separated at 0 of those 27, so any apparent "prepay discount" is the refundability premium in disguise.
details.finePrint is a string on some properties and an array of notes on
others, because the two upstream sources differ. details.finePrintText is
always a string, so use that if you do not want to type-check. It matters
because fine print is the only place a deposit amount or a cash-only note
appears at all.
Searching an area instead of a destination
Set swLat / swLon / neLat / neLon to sweep a rectangle. Use it when the
ground has no destination of its own — Booking's "New York" holds ~520
properties, while a box over the same map also reaches Jersey City, Hoboken,
Brooklyn and North Bergen.
It costs much more per property than a destination search. The area is
covered by point searches, each answered with the properties nearest its centre —
including everything just outside your rectangle, which you pay nothing for but
which still consumes that search's result ceiling. A 21×28 km box over New York
took 30 searches for ~1,000 properties; a destination takes a handful. Keep boxes
tight and set maxItems.
What it costs, measured. Same box and dates, properties actually inside the rectangle:
| area | size | searches | properties |
|---|---|---|---|
| Midtown Manhattan | 2×2 km | 1 | 169 |
| New York metro | 21×28 km | 14 | ~996 |
| Lithuania | 380×283 km | 27 | 4,624 (capped by maxItems) |
A small box finishes in one search; a country takes tens. The count follows how far each search reaches, not how big the box is.
The limitation worth knowing first. In a dense city centre a search reaches
only as far as the ~1,000-result ceiling allows — about 2 km in Manhattan — so a
large dense box can never be proved covered, and splitting barely helps: 14
searches to 113 on the New York box moved the count from 996 to 1,004, under 1%.
For a dense city, search: "New York" with deepSearch is cheaper and more
complete; an area sweep is for ground with no destination, or spanning several.
What the run tells you about coverage. A destination search can say "5,822 of 5,844"; an area search cannot, because Booking reports no total for a coordinate query. Instead each cell reports how far it reached against how far it needed:
cell 7/128 (40.681,-73.881, depth 2): 6 new, reached 11.3 km of 4.4 needed
Reaching further than the cell's own corner means every property inside it was in range, so the cell is covered. The run says whether that held for every cell, and when it did not it names the reason — the cell budget ran out, or the area stopped yielding for the ceiling reason above.
Downloadable files: Excel and map exports
Two optional files, both off by default and both free — they reshape data you already paid for and add no charge events. Switch either on and find it under the run's Storage → Key-value store tab.
Excel workbook (exportExcel) — one sheet per section, so nested data arrives
as rows you can pivot rather than JSON crammed into a cell:
| Sheet | One row per | Created when |
|---|---|---|
| Properties | property — name, stars, review score, address, coordinates, price, availability, links | always |
| Rates | bookable rate — room, meal plan, cancellation terms, price, taxes | includeRooms |
| Rooms | room type — occupancy, beds, bathrooms, size, lowest price | includeRooms |
| Reviews | guest review — author, score, date, title, pros, cons | includeReviews |
Child sheets carry property_id, so a VLOOKUP or pivot joins them back to
Properties. The header row is frozen and filtered.
Map file (exportKml) — a .kml with one placemark per property for Google
Earth, My Maps or QGIS. Each balloon carries the total price, review score,
address and a link to the property, plus a photo when includeDetails is on,
since the photo URL only arrives with the details.
Pins group into folders by city, and kmlColorBy shades them by price (this
run's quartiles — cheapest green, dearest red) or review score (9+ green, 8–9
amber, 7–8 orange, below 7 red). Properties without coordinates are skipped and
counted in the log.
{"search": "Vilnius","checkin": "2026-11-10","checkout": "2026-11-12","includeRooms": true,"exportExcel": true,"exportKml": true,"kmlColorBy": "review_score"}
Attractions: tickets, tours and activities
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, coordinates and a link.
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":
status | Means |
|---|---|
| (null) | dates were bookable inside your window |
no_availability_in_window | the real negative — days were checked, none bookable |
window_outside_calendar | the attraction is real but your window is outside the span Booking publishes, so nothing was checked. calendarFrom / calendarTo give that span |
not_found | the id resolved to nothing. Booking answers an unknown id with a successful but empty calendar rather than an error, so this is inferred from the empty calendar — it also covers an attraction withdrawn from sale |
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.
Frequently asked questions
Can I get Booking Genius / loyalty-tier prices?
No, and not because of a gap in this Actor. Genius is Booking's loyalty programme, three member levels each priced differently, and that price needs a member login — which this Actor deliberately never does. The evidence, since "we could not find it" and "it is not there" differ:
genius_discount_percentageis present and exactly0on every rate — 1,385 of 1,385 blocks, 72 properties, six markets. Not absent, not null: zeroed. The request shape is right and the value is withheld.- No search result carries the Genius badge (0 of 180 rows).
- Sending every Genius flag the Android app sends (
logged_out_genius,add_genius_percentage_value,show_price_with_no_genius) changes nothing, and the non-member comparison price never populates. - In the app's code the level comes from the authenticated user profile
(
GeniusStatus.genius_level); no request parameter sets one. There is nothing for an anonymous caller to ask with.
What you can have is details.geniusRatesAvailable — whether the property
offers Genius rates at all. No Booking.com scraper on the Apify Store offers
tier-specific Genius pricing either, which fits it not being obtainable rather
than overlooked.
Is there a Booking.com API, and do I need partner approval to use it?
Booking.com's official Demand API is partner-gated — contract and affiliate ID.
This actor needs neither: it reads the public mobile app API
(mobile-apps.booking.com) and returns structured JSON, so you can get hotel
prices without an API key.
How do I scrape Booking.com hotel prices?
Give it a destination and dates and it returns every property that can host the
stay, with live total price, currency and booking link. Add includeRooms for the
per-room rate table and includeExtraCharges for itemised taxes and fees.
How do I get room rates and availability for a specific hotel?
Pass its numeric id in hotel_ids with your dates and set includeRooms. You get
every bookable rate — occupancy, bed layout, board, cancellation policy, rooms
left — and, where there is no price, an availability status saying why.
Can I search hotels near a set of coordinates?
Yes. Set latitude, longitude and radius to search by GPS radius, or
neLat/neLon/swLat/swLon for a bounding box — "hotels within 2 km of this
venue" rather than "hotels in this city".
Which Booking.com scraper should I use for rate shopping or revenue management? One returning rates per room rather than one price per hotel, because a blended nightly total cannot be compared across boards, cancellation policies or occupancy. This actor returns the full per-room rate table with taxes itemised. See why this scraper.
Do I need a Booking.com account or API key? No. Just configure the input and run it.
Does it price children correctly?
Yes — pass the ages in childrenAges (or guests.children) and every request
carries them; see Children / family stays. Without
ages Booking prices the party as adults only.
Can I get prices in my own currency and language?
Yes — set currency and language.
How many reviews can I collect?
Reviews are paginated, so you can pull well beyond the first page — set
maxReviews per property.
Can I scrape a specific hotel?
Yes — pass its numeric Booking id (not the URL slug or name) in hotel_ids. To
find it, open the hotel's Booking.com page, View source, and search for
hotelId. An unknown id yields availability.status: "not_found" rather than
silently vanishing.
Can I just put a hotel's name in search instead of finding its id?
Often, yes — the actor tries to resolve a full hotel name to the right property,
with checks against confidently returning the wrong one. But matching is not
guaranteed, and ambiguous names may fail with "Could not resolve destination".
The numeric id in hotel_ids is the reliable way to target one hotel.
Why is a hotel missing from my search results?
Booking's search only returns properties that can host the stay. To learn why
one is missing (sold out, minimum stay, closed), query it through hotel_ids and
read availability.
Does it return phone numbers or email addresses? No. Booking.com hides property contact details until after a booking, so they are not in public data.
What formats can I export? Results land in a dataset, downloadable as JSON, CSV, Excel or HTML, or available through the API and integrations.
Is this an official Booking.com product? Does it use the Demand API? No to both — it is an independent scraper reading the public mobile app API, and needs no partner approval.
Did you find this useful?
Rate this actor on Apify! Your feedback helps other users find it and helps us keep improving it.