Booking.com Hotel Scraper – Prices, Room Rates & Reviews avatar

Booking.com Hotel Scraper – Prices, Room Rates & Reviews

Pricing

from $1.00 / 1,000 search results

Go to Apify Store
Booking.com Hotel Scraper – Prices, Room Rates & Reviews

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.

Pricing

from $1.00 / 1,000 search results

Rating

0.0

(0)

Developer

R.L.

R.L.

Maintained by Community

Actor stats

0

Bookmarked

373

Total users

205

Monthly active users

1.2 days

Issues response

2 hours ago

Last modified

Categories

Share

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 / requestedChildrenAges
echo what you asked for. The search-path price.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.74 with those three charges "excluded" (extra_charges.excluded: 84.23); from an EU IP they are included and total is 275.97. total + extra_charges.excluded is 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

Two dropdowns, answering different questions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Flexible dates: a window instead of one stay

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

// every single night in the next 30 days, for one hotel
{ "searchBy": "hotelIds", "hotel_ids": ["1392565"],
"windowDays": 30, "stayNights": 1,
"outputFields": ["price", "availability"] }
// two-night stays starting any day next week, across a destination
{ "search": "Ghent", "windowDays": 7, "stayNights": 2, "maxItems": 200 }

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

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

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

Each day in the window is a separate search, billed separately — a 30-day window costs roughly 30× a single-date run. Two things keep that in hand: maxItems is a budget across the whole sweep, not per day (

maxItems (3) reached after 1 of 5 stays; the rest of the window was not searched.
), and windowDays is capped at 60.

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

Enrichment modes: re-check what you already have

Each enrichment mode calls the one Booking endpoint that surface needs and bills only that surface — never a search result. Measured on one property:

modeRequestsCharged
rooms3room-details=5
reviews3 (2 if you pass ufi)review=3, review-scores=1
reviewScores1review-scores=1
surroundings1surroundings=1
details2hotel-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:

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

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

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

Watching named rates: mode rates

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

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

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

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

  • 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's maxOccupancy on 86 of 130 rates and is 0 on 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:

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

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

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

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

Exports by mode: what Excel and KML actually contain

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

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

Two things follow.

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

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

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

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

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

Modular output: take only the blocks you need

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

outputFields narrows the record to the blocks you pick:

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

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

Recipes:

// price monitoring — 12 keys instead of 28
{ "searchMode": "hotelIds", "hotel_ids": ["1392565", "235613"],
"outputFields": ["price", "availability"] }
// availability checking only
{ "searchMode": "hotelIds", "hotel_ids": ["1392565"],
"outputFields": ["availability"] }
// review mining, no pricing payload
{ "searchMode": "destination", "search": "Ghent",
"includeReviews": true, "includeReviewScores": true,
"outputFields": ["reviews", "reviewScores"] }

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

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

Upgrading: what changed for existing runs

Search-mode runs are unchanged. A saved task sending search, dates, guests and the include* toggles produces the same records, fields and charges as before — verified by 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:

whatwhy
products[].geniusDiscountPercentage removednull 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" refusedBooking 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 removedfalse/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 meaningthey 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:

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

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

How many properties can you actually get?

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

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

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

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

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

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

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

Telling a complete run from a truncated one

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

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

available is how many properties the destination holds for your dates and filters — what the website prints as "N properties found". One extra count query buys it, so the log says

Pushed 100 of 419 available accommodations (truncated at maxItems=100)
rather than Pushed 100/100, which meant only "we stopped" and could equally have meant "that was all of them".

truncationCause is one of:

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

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

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

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

"givenUpRequests": {
"total": 4, "rows": 0, "fields": 4,
"byEndpoint": { "graphql:PropertyPageReviewsQuery": 2, "graphql:PropertySurroundings": 2 }
}
  • rows — a whole property (or attraction) never reached the dataset.
  • fields — the row arrived with an optional field empty: reviews, review scores or nearby places. This matters because outputFields lets you omit fields on purpose, so an accidental gap looks deliberate.

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

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

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

Sorting: the orders Booking actually has

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

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

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

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

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

When Booking stops answering mid-run

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

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

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

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

What a destination covers, and what it does not

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

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

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

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

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

Two gaps worth knowing before you plan around them:

  • 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:

eventchargedcovers
Search resultper accommodation returnedthe property result with live pricing
Hotel detailsper propertytwo requests - property page + static content - deliberately billed as one
Room detailsper room optionthe per-rate products list comes from the same request and is not charged again
Rate checkper product id checkedmode rates only - including rates that have sold out, which cost the same request
Reviewper review returnedthe paginated review list
Nearby placesper propertyincludeSurroundings only
Review scoresper propertyincludeReviewScores 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.

starRating does not say whether the stars are official. Booking awards some an official classification and lets others self-estimate; the mobile surface returns symbol: "STARS" for both, so a 3 can be either and ranking by it mixes them. reviewScore can 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 lines INCLUDED and its own aggregate counts them anyway — over 180 search rows in six markets the aggregate equalled the sum of all entries, every time. So inclusion is normalised to excluded here, with Booking's token kept in inclusionRaw. Summing only the lines you think are excluded was the trap: on a VAT-charging property it dropped the whole VAT.

  • inclusion is one of included, excluded, conditional, deposit — Booking's own vocabulary — or unknown, which is ours and means Booking stated nothing. unknown is not "already paid for", so it gets its own bucket instead of counting as included. conditional and deposit have never arrived in any sample; they are carried because the API defines them, and deposit matters most — a refundable deposit is not money you owe, so it stays out of excludedTotal.

    Changed 22 October 2026. Entries of unstated inclusion used to sit in conditional[] / conditionalTotal and now sit in unknown[] / unknownTotal. See Upgrading for the measurement.

  • extra_charges.excluded / included are numbers on the property row and arrays at rate level. excludedTotal / includedTotal / conditionalTotal / depositTotal / unknownTotal are numbers at both, so write against those for one shape.

  • extra_charges.chargesCompleteness says whether that itemisation is all of it, and nothing else does. Booking's own value: all_included ("Includes taxes and fees") or incalculable ("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 three included, and still be incalculable, because the flag is about charges beyond the listed ones. So excludedTotal: 0 means "nothing more to pay" only under all_included; under incalculable the 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 a chargesAmount of 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.chargesAmount is the figure that goes with extra_charges — {value, currency}, 0 under 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.reconciliation with the delta and the run logs it; likewise currencyMismatch for 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, percentage and unit_amount are null. The wire type ChargeDisplayIrene has 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 (chargesInfo null, taxExceptions empty on 6 of 6 live rows). An upstream limit, not a mapping gap. A real breakdown — names, percentages, per-unit amounts — needs includeRooms, 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).

  • charge is a code we recognise, or TYPE_<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. chargeTypeId always carries the raw number, so an unmapped charge stays identifiable, and the rate-level name is Booking's own label.

Compare against total_with_charges, not total — not because total is 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.44 and before_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 against gross_amount 151.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_charges recomputes 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 needs includeRooms, 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 book price equal to this Actor's total, 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_charges matched it on 0 of 10. The other 2 are isMobileRate: true, where our total is lower because the app sells a rate that surface never sees — 218.73 against 243.03 on hotel 6331862. before_discount is 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:

fieldmeaning
price.totalwhat you pay through this channel (mobile discount applied)
price.without_mobile_discountthe same rate without it — what a desktop browser shows
price.mobile_discountthe mobile-only cut itself
price.before_discountwith no discount at all (Booking's struck-through price)
price.discountthe total saving against before_discount
price.discount_percentthe saving as a % of the struck-through price
price.discounts[]every discount line: Booking's code, name, amount and its own wording
price.isMobileRateproperty level: whether this price is a mobile-only rate

discount_percent is not Booking's badge. It is

discounted_amount / strikethrough_amount
from 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 the basic-deal rates 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:

propertypopularFacilitiesfacilitiesphotos
9176203105223
1114141994968
1000496784
25768653785

popularFacilities is Booking's own shortlist and keeps its own column. On the search path with includeDetails these 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. Use mode: "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_rooms was 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.

maxOccupancy is 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.

bedCount counts beds, not sleeping places. A bunk counts once and sleeps two, so a "14-Bed Private Room" can report bedCount: 7 — exactly what its own page's bed line says. Use maxOccupancy for 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[].extraBedPrice and rooms[].babyCotAvailable are removed. They were false / null on 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. childAndBedPolicy below 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:

  • 255 means "and above", not "adult", and the adult band is encoded two ways: hotel 6331862 gives 255–255 for the row its page prints as "Adult (18+ years)", Stikliai gives 18–255 for "3+ years", and Marina Bay Sands stops at 3–12 because it charges children 13 and over as adults. So the threshold is not always 18 — it is stated only in childrenNotes. Both bounds pass through as sent.
  • The currency beside each amount is the property's own, not the run's. This endpoint does not convert: hotel 6331862 returns 25.00 for an extra bed on a USD run and an EUR run alike, byte-identical, and Kyoto's 9552136 returns 5000.00 on 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 and kr three Nordic currencies. In details mode these amounts are the only money in the row, which carries no currency at all.
  • priceType says whether the number is money at all, and a percentage is not: over 29 properties the values are free, fixed and adult_percentage, the last meaning percent of the adult rate. One London hotel charges a child in an existing bed 70% per night, carried as percentage: 70.0 with amount and currency null. Read percentage whenever 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 null for cot, extraBed or existingBed means that band has no such rule — verified both ways, which is what makes cotAvailable: false meaningful.
  • notes and summary carry the conditions, unparsed. The counts live in summary and 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. bedCount is the largest single layout (2), the room's real capacity. Use bedLayouts for per-layout arithmetic.
  • hasSofaBed means "in at least one layout" — good for discovery, wrong as a hard filter. sofaBedInEveryConfiguration is 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. fee and refund sum to price.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 against price.total is 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[].currency say which. True of every money field except details.damageDeposit.amount, which stays in the property's currency because a deposit is collected at the desk. Every amount carries its own currency, 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:

rungstatefromfeerefund
0FREE—037.80
1PAID2026-11-1718.9018.90
2NONREF2026-11-2037.800

A non-refundable rate normally has no ladder, and ladder is then [].

policies.payment — payment timing, and the prepayment schedule:

Changed 23 October 2026. types is now decided by prepaymentPolicy alone. It used to read pay_in_advance first, 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 as pay_now. Over 688 rates in four cities, 20 were affected, a third of the no_prepayment_until ones; those now read pay_later, or pay_at_property under no_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: true means "nothing due today", not "nothing due ever". True for both no_prepayment_at_all (settle at the property) and no_prepayment_until (the full amount taken before arrival) — in one sample about a fifth of noPrepayment rates do take payment first. Read prepaymentPolicy to separate them, dueDate for when.
  • amountDueNow is null, not 0, 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. null is "not stated"; only 0 is "nothing is due". Reading null as 0 turns every one of those into a false "pays nothing at booking".
  • dueDate is 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 rates dueDate is never the same day and always earlier.
  • dueDate and payLaterDeadline are one deadline in two encodings — 60 seconds apart on 49 of 49 rows (2026-11-19T00:00:00Z against 2026-11-18T23:59:00+00:00). The app shows it once, as "Pay nothing until 19 November".
  • dueDate is absent on some no_prepayment_until rates — 12 of 17 carried it across 130 products in three markets. The five without are real rates with amountDueNow: 0 and a full schedule[]; Booking simply states no deadline, and they read types: ["pay_later"], so a row can correctly say "pay later" with no date anywhere. Do not fall back to schedule[]: its boundary tracks free cancellation, not the charge — from runs two days after dueDate wherever both exist — and the app does not parse the stage dates at all. An absent dueDate means 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.

amount is in the property's own currency — a Kuala Lumpur hotel returns 50.0 MYR on 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:

requestToBookRequired is about instant booking, and it is null on 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 null because the question does not apply, not because data is missing — and null is also what a property sends when it carries the field but does not use the model, passed through rather than invented as a false Booking never stated.

Read true as "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: AcceptedCreditCard carries only an id and a name. Read a Visa entry 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: 18 while welcoming children of all ages. For "does this property accept children", read childAndBedPolicy.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:

areasizesearchesproperties
Midtown Manhattan2×2 km1169
New York metro21×28 km14~996
Lithuania380×283 km274,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:

SheetOne row perCreated when
Propertiesproperty — name, stars, review score, address, coordinates, price, availability, linksalways
Ratesbookable rate — room, meal plan, cancellation terms, price, taxesincludeRooms
Roomsroom type — occupancy, beds, bathrooms, size, lowest priceincludeRooms
Reviewsguest review — author, score, date, title, pros, consincludeReviews

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

statusMeans
(null)dates were bookable inside your window
no_availability_in_windowthe real negative — days were checked, none bookable
window_outside_calendarthe attraction is real but your window is outside the span Booking publishes, so nothing was checked. calendarFrom / calendarTo give that span
not_foundthe 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_percentage is present and exactly 0 on 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.