Booking.com Hotel Room Price, Availability & Min-Stay Scraper avatar

Booking.com Hotel Room Price, Availability & Min-Stay Scraper

Pricing

from $4.00 / 1,000 availability results

Go to Apify Store
Booking.com Hotel Room Price, Availability & Min-Stay Scraper

Booking.com Hotel Room Price, Availability & Min-Stay Scraper

Per-room, per-date prices and availability from Booking.com at any stay length, so minimum-stay properties don't read as sold out - with Booking's real per-date minimum stay. Search by hotel URL, or by destination to compare an area: up to 365 days ahead, any party size, full property details.

Pricing

from $4.00 / 1,000 availability results

Rating

5.0

(1)

Developer

Aurith Labs

Aurith Labs

Maintained by Community

Actor stats

1

Bookmarked

3

Total users

2

Monthly active users

8 days ago

Last modified

Share

This Booking.com scraper extracts hotel room prices and availability per room type and per check-in date - not just the headline nightly rate a search-results page shows, but every room rate a property is actually selling, with its price, cancellation terms, meal plan, occupancy, taxes and room amenities attached.

Give it a list of Booking.com hotel URLs, or a destination like "Cape Town", and it sweeps every property across a date window you choose. Unlike scrapers that probe a fixed 1-night stay, you set the stay length, so minimum-stay properties - safari lodges, villas, whole-house rentals, many resorts - return real rates instead of appearing permanently sold out.

Three output modes, one input: offers for full room-level detail, calendar for cheap date-level availability and minimum stay across a long window, calendar_rooms for the same room detail grouped by date. The mode changes the shape and the fetch count - never the price of the same data.

Export to JSON, CSV or Excel, call it from the Apify API - or let an AI agent run it through Apify's MCP server - and schedule a daily sweep of your comp set.

Booking.com room prices and availability per room type and date, in the Apify dataset

๐Ÿ“Š What data can you extract from Booking.com?

Room ratesTotal stay price, price per night, occupancy-normalised price, original price, discount %
AvailabilityPer-room, per-date availability, rooms left, stock confidence, sold-out confirmation
Minimum stayBooking's own per-date minimum stay length, plus its calendar availability signal
Rate termsRefundable or not, breakfast included, meal plan, free-cancellation date, pay-later policy
TaxesCharges folded into the price, and an explicit flag for whether the price includes tax
Room detailBed type, max guests, room size in mยฒ, view, private bathroom, facilities, premium amenities
Property dataName, full address, city, region, country, coordinates, star rating, review score, review count, property type, check-in/check-out windows, description, image
Group pricingThe same rate's total at every room quantity the property will sell

Output examples

Every example below is copied from a real run, not written by hand. Long records are abridged - fields are omitted, never altered. Run and dataset IDs are recorded so we can re-derive them; they are our own private storage, and Apify expires unnamed datasets after its retention window, so they are a note to us rather than something you can open.

offers - one row per room rate (default)

The full room-level result: every rate plan a property is selling for every date. One page fetch per hotel-date, and one row per rate - which varies a lot by property: a single-room lodge yields one or two, The Table Bay yields ~34.

{
"record_type": "room",
"hotel_url": "https://www.booking.com/hotel/za/the-table-bay.html",
"check_in_date": "2026-10-08",
"check_out_date": "2026-10-10",
"nights": 2,
"block_id": "1520126_95139897_2_1_0",
"room_name": "Classic Room",
"bed_type": "1 king bed",
"max_guests": 2,
"currency": "ZAR",
"price": 21600,
"price_per_night": 10800,
"price_primary": 21600,
"price_by_room_count": [
{
"rooms": 1,
"price": 21600
},
{
"rooms": 2,
"price": 43200
},
{
"rooms": 3,
"price": 64800
},
{
"rooms": 4,
"price": 86400
},
{
"rooms": 5,
"price": 108000
},
{
"rooms": 6,
"price": 129600
}
],
"taxes_included": [
{
"b_copy": "15 % VAT",
"b_type": "percentage__included",
"b_raw_value": "2817.3913"
}
],
"price_includes_taxes": true,
"rooms_left": 6,
"stock_confidence": "capped",
"availability_status": "available",
"is_refundable": false,
"is_breakfast_included": true,
"min_stay": 1,
"calendar_available": true,
"room_amenities": [
{
"facility_id": "3",
"facility_type": "1",
"text": "Minibar"
}
],
"proxy_country": "ZA"
}

Note taxes_included carries Booking's own keys, unaltered - b_copy, b_type, b_raw_value - rather than a shape we invented. price is the total for the whole stay, not per night.

Every run also emits one discovery record per hotel, free:

{
"record_type": "discovery",
"hotel_url": "https://www.booking.com/hotel/za/the-table-bay.html",
"hotel_name": "InterContinental Table Bay Cape Town by IHG",
"property_type": "HOTEL",
"address": "Quay 6, V&A Waterfront, 8001 Cape Town, South Africa",
"city": "Cape Town",
"region": "Western Cape",
"country_code": "za",
"latitude": -33.9024828836,
"longitude": 18.4221403449,
"star_rating": 5,
"rating_value": 8.5,
"rating_scale": 10,
"review_count": 811,
"checkin_from": "3:00 PM",
"checkin_until": null,
"checkout_until": "11:00 AM"
}

no_rooms - the date was checked and nothing was bookable

Emitted unconditionally, so "we checked and found nothing" is always distinguishable from "we never checked". This one is the minimum-stay signature - and it is the single most useful record this actor produces:

{
"record_type": "no_rooms",
"hotel_url": "https://www.booking.com/hotel/za/inyati-game-lodge-sabi-sand-game-reserve.html",
"check_in_date": "2026-10-30",
"nights": 1,
"min_stay": 2,
"calendar_available": true,
"include_sold_out": true,
"proxy_country": null
}

Read it together: Booking's calendar says the date is bookable (calendar_available: true), the property requires 2 nights (min_stay: 2), and we asked for 1 (nights: 1). So the property is not sold out - the stay length was too short.

A 1-night-only scraper reports this exact date as sold out, with nothing to tell you otherwise. Re-run with nights: 2 and you get priced rooms.

error - one unit of work failed, and you are not charged for it

{
"record_type": "error",
"hotel_url": "https://www.booking.com/hotel/za/this-property-does-not-exist.html",
"input_index": 2,
"check_in_date": "2026-10-08",
"nights": 2,
"error": "Client error '404 Not Found' for url 'https://www.booking.com/hotel/za/this-property-does-not-exist.html?checkin=2026-10-08&...'",
"proxy_country": null
}

One bad hotel-date never stops the rest of a run - the sweep carries on and the failure is reported on the record rather than swallowed. Errors are always free. A fetch or parse failing is our problem, not something to bill you for.

This also matters for trusting the data: a degraded or blocked fetch becomes an error record, never a no_rooms one, so an empty result is always a fact about the property rather than about our session.

calendar - one row per date

Booking's own per-date availability and minimum stay across the whole window. One page fetch per hotel plus one calendar call per 61 days - so a 30-date window costs one fetch, not thirty. No room detail and no real prices.

{
"record_type": "calendar",
"hotel_url": "https://www.booking.com/hotel/za/inyati-game-lodge-sabi-sand-game-reserve.html",
"check_in_date": "2026-10-08",
"nights": 2,
"calendar_available": true,
"min_stay": 2,
"avg_price_display": "$1.8K",
"bookable_at_requested_nights": true,
"adults": 2,
"rooms": 1,
"children": 0
}

avg_price_display is a display string - Booking returns "$1.8K", "$581", "0" depending on locale, currency and magnitude, and it may not be in the currency you asked for: the samples above are dollar-prefixed on a run made in ZAR. Treat it as an indicative magnitude, never parse it, and never assume its unit. bookable_at_requested_nights combines both halves that matter: the date is available and your nights meets its minimum.

calendar_rooms - one row per date, rooms nested

Every field the flat offers rows carry, regrouped - room-type facts on the room, and everything that can differ between two rates of the same room type (occupancy, price, taxes, stock, bed type) on the rate plan. Same fetches, same data, so it costs exactly the same. Suits rendering a calendar or consuming JSON; the flat default suits a table, a spreadsheet and Apify's own dataset view.

Abridged to one room type and one rate plan - this date carried ten room types across twenty-two rates. min_stay sits at the date level, never inside a room: it is one value per date for the whole property, and nesting it would imply a per-room restriction Booking does not express.

{
"hotel_url": "https://www.booking.com/hotel/za/the-table-bay.html",
"input_index": 0,
"check_in_date": "2026-10-08",
"nights": 2,
"include_sold_out": true,
"proxy_country": null,
"record_type": "calendar_rooms",
"calendar_available": true,
"min_stay": 1,
"avg_price_display": "$581",
"bookable_at_requested_nights": true,
"adults": 2,
"rooms": 1,
"children": 0,
"check_out_date": "2026-10-10",
"scrape_timestamp": "2026-08-18T09:14:22.117000+00:00",
"availability": [
{
"room_name": "Classic Room",
"room_name_clean": "Classic Room",
"room_id": 1520126,
"max_guests": 2,
"room_size_sqm": 38.0,
"room_view": [],
"has_private_bathroom": true,
"facilities": [
{
"facility_id": "0",
"facility_type": "0",
"text": "1 room"
},
{
"facility_id": "0",
"facility_type": "0",
"text": "38 m\u00b2"
},
{
"facility_id": "11",
"facility_type": "1",
"text": "Air conditioning"
},
{
"facility_id": "38",
"facility_type": "1",
"text": "Attached bathroom"
},
{
"facility_id": "75",
"facility_type": "1",
"text": "Flat-screen TV"
},
{
"facility_id": "120",
"facility_type": "1",
"text": "Coffee machine"
},
{
"facility_id": "3",
"facility_type": "1",
"text": "Minibar"
},
{
"facility_id": "wifi",
"facility_type": "2",
"text": "Free Wifi"
}
],
"room_amenities": [
{
"facility_id": "3",
"facility_type": "1",
"text": "Minibar"
}
],
"rooms_available": 6,
"stock_confidence": "capped",
"rate_plans": [
{
"block_id": "1520126_95139897_2_1_0",
"bed_type": "1 king bed",
"priced_occupancy": 2,
"price": 21600.0,
"price_per_night": 10800.0,
"price_primary": 21600.0,
"original_price": null,
"discount_percent": null,
"currency": "ZAR",
"taxes_included": [
{
"b_copy": "15 % VAT",
"b_type": "percentage__included",
"b_raw_value": "2817.3913"
}
],
"taxes_excluded": null,
"price_includes_taxes": true,
"price_by_room_count": [
{
"rooms": 1,
"price": 21600.0
},
{
"rooms": 2,
"price": 43200.0
},
{
"rooms": 3,
"price": 64800.0
},
{
"rooms": 4,
"price": 86400.0
},
{
"rooms": 5,
"price": 108000.0
},
{
"rooms": 6,
"price": 129600.0
}
],
"rooms_left": 6,
"stock_confidence": "capped",
"availability_status": "available",
"is_refundable": false,
"is_breakfast_included": true,
"meal_plan": "breakfast",
"book_now_pay_later": "Pay online",
"free_cancellation_until": null,
"rate_options": [
"non_refundable"
],
"rate_options_count": 1
}
]
}
]
}

Note price_includes_taxes: true and the 15 % VAT entry in taxes_included. That is not a fixed property of the source - it is geo-pricing. The same block_id returns 18,782.61 on default egress and 21,600.00 through proxyCountries: ["ZA"], and 18,782.61 x 1.15 = 21,600.00 exactly, because South African VAT is 15%. Booking decides tax-inclusive or tax-exclusive from the IP the request came from, so check price_includes_taxes on every record before comparing prices across countries. See "Geo-pricing, taxes and proxyCountries" below.

๐ŸŽฏ Who uses this Booking.com scraper

Revenue managers running a comp set. Track what a named list of competing hotels is actually charging, per room type and per date, at the stay length those properties really sell - not at a 1-night probe they may not accept at all.

Lodge, villa and whole-house operators. See your own minimum-stay policy the way a guest sees it - per date, not as one blanket rule - and catch dates that read as bookable on Booking's calendar while returning no rooms at the length you're probing.

Market analysts building a comp set they don't have yet. Start from a destination instead of a URL list: resolve every property matching a search, filtered by review score and property type, then sweep it.

Guest-facing price display and travel tech. Show a guest the number they'll actually be charged in their own market, with the tax basis stated rather than guessed.

Common use cases

  • Hotel rate shopping - the per-date, per-room-type competitor rate feed that rate-shopping tools are built on, delivered as raw data you own and can pipe anywhere.
  • Hotel rate benchmarking - compare price_primary across a comp set on matched dates and room types.
  • Minimum-stay discovery - find which dates a property won't sell at 1, 2 or 3 nights, and how that shifts by season.
  • Availability monitoring - daily sweeps showing when a competitor sells out, and at what price they got there.
  • Market mapping - resolve a destination into properties with coordinates, star rating and review score attached.
  • Room-type matching - line up comparable rooms across two hotels using bed type, capacity, size and a curated premium-amenity list.
  • Dynamic pricing models - feed per-date, per-room competitor rates into your own pricing engine.

๐ŸŒ™ The differentiator: it probes the stay length a property actually sells

Most room-level Booking.com scrapers probe a 1-night stay. Properties with a minimum-stay policy return nothing for a 1-night probe, and so appear permanently sold out even when they're wide open at the stay length they actually sell.

This actor takes a nights input instead of assuming 1. Verified live against Inyati Game Lodge (a minimum-stay-2 property) for check-in 2026-10-08:

ProbeResult
1 nightNo room table rendered - a 1-night-only scraper reports this as sold out
2 nightsA priced "Chalet" rate

Same property, same date, same run of code. The only variable is nights.

Per-date minimum stay data

min_stay is Booking's own real minimum-stay length for one specific date - not a property-wide setting. It comes from a separate call to Booking's availability calendar, alongside calendar_available, Booking's own per-date availability signal. Both are fetched once per hotel per run and stamped onto every date's records.

useMinimumStay: stop having to know the number

Because minimums are per-date, a single nights value is the wrong shape for a season. Setting nights: 2 for Inyati is right in November and wrong in June.

With useMinimumStay: true, you don't pick one. Each date is read off the availability calendar first, then priced at that date's minimum:

Datemin_stayWe probe at
2026-11-1422 nights
2027-06-1433 nights
A date the calendar didn't answerunknownyour nights

What that buys you is that the question matches the date. Whether a given date comes back with rates still depends on whether it is genuinely available - this removes one specific cause of an empty result, it does not promise a full one.

It costs nothing extra. The availability calendar is already fetched once per hotel per run, and each date is still one page fetch - we just ask it a different question. Same bill, no empty dates you have to interpret.

Check nights on the record before comparing prices across dates. It carries the length that record's price actually covers, which is now per-date. price_per_night is the field that normalises it.

Unknown stays unknown: when the calendar didn't give us a minimum for a date, we fall back to your nights rather than assuming 1. Guessing the shortest stay would manufacture exactly the false sell-out this option exists to remove.

Minimums are seasonal, and one property can carry more than one value across the year. Verified live against Inyati Game Lodge: in November 2026 every bookable date requires a 2-night minimum; in June 2027 - Southern Hemisphere dry season, when game viewing peaks - every bookable date requires 3 nights, and the average nightly rate rises with it (28.7K in November, 32.2K in June, same currency). A property-level "minimum stay: 2" setting would have missed June entirely.

A no_rooms record paired with calendar_available: true and a min_stay above your requested nights is the minimum-stay signature: the property isn't sold out, you simply asked for too short a stay.

โš™๏ธ Input parameters

FieldTypeDefaultNotes
hotelUrlsarray of {url, method}-Booking.com hotel page URLs - exactly one of hotelUrls/searchText/searchUrl is required
searchTextstring-A free-text destination, e.g. "Cape Town"
searchUrlstring-A pasted Booking.com search-results URL
maxPropertiesinteger, 1โ€“100050Destination mode only - a ceiling approached, not a count guaranteed
minReviewScorenumber, 0โ€“10-Destination mode only
maxReviewScorenumber, 0โ€“10-Destination mode only - client-side filter
propertyTypestring-Destination mode only, e.g. "Lodges", "Hotels"
startDatestring (YYYY-MM-DD)todayFirst check-in date to probe
daysAheadinteger, 1โ€“3657Consecutive check-in dates from startDate
nightsinteger, 1โ€“71Stay length per probe - set this to the length the property actually sells
useMinimumStaybooleanfalseProbe every date at that date's own minimum stay instead of one fixed length. nights becomes the fallback for dates the calendar didn't answer. Costs nothing extra. See "The differentiator" above
modeenumoffersWhat one row is. offers (default, full room detail), calendar (one row per date - far fewer fetches and far fewer billed results), calendar_rooms (same data and same bill as offers, grouped by date). See "Choosing a mode" below
currencystringUSD32-currency dropdown incl. ZAR, INR, SGD, HKD, IDR, COP
adultsinteger, 1โ€“302Adults per room
roomsinteger, 1โ€“301Rooms requested
childreninteger, 0โ€“100Requires childrenAges
childrenAgesarray of integers, 0โ€“17[]One age per child - required whenever children > 0
includeSoldOutbooleantrueDoes not change record count
tieredCadencebooleanfalseProbe every date near check-in, thin far-future dates
proxyConfigurationobject{useApifyProxy: true}Datacenter by default
proxyCountriesarray of 2-letter codes[]Run the sweep once per country for geo-pricing
timeoutSecsinteger900Stops the run after this many seconds

nights defaults to 1 for parity with other room-level scrapers, so anyone migrating sees unchanged output by default. Raise it to see what a minimum-stay property actually sells - or set useMinimumStay and stop having to know the number at all.

Choosing a mode

offers (default)calendarcalendar_rooms
One row isa room ratea datea date, rooms nested
Room detailโœ… fullโŒ noneโœ… full
Real pricesโœ…โŒ indicative onlyโœ…
Per-date min_stayโœ…โœ…โœ…
Page fetches per hotel, N datesN1N
Rows per hotel-dateone per rate11
Billed results per hotel-dateone per rate1one per rate

Three different numbers get confused here, so to be explicit:

  • Fetches โ€” calendar needs one page fetch per hotel plus one availability call per 61 days, however long the window. A 30-date sweep is 1 fetch, not 30.
  • Compute โ€” measured on one hotel across 30 dates: offers $0.0081, calendar $0.0037. Real, but small in absolute terms.
  • Billed results โ€” the one that dominates, and it depends entirely on how many rates a property sells. A single-room lodge bills about the same in either mode. The Table Bay sells ~34 rate plans a date, so 30 dates is ~1,020 billed results in offers against 30 in calendar โ€” 34x, and that gap is worth far more than the compute difference.

Pick calendar to learn when a property is free and what its minimum stay is, across a long window, cheaply. Pick offers when you need what a room actually costs. calendar_rooms is offers reshaped for rendering a calendar - same fetches, same bill, different JSON.

Input details worth knowing

childrenAges is required whenever children is set. Verified live: sending children: 1 with no childrenAges doesn't error and doesn't assume an age - Booking's response comes back children: 0, ages: []. The child is silently dropped, and you get rooms and rates for an adults-only search you didn't ask for, with nothing signalling it happened. So it's enforced at input time: one age (0โ€“17) per child, or the run fails immediately with a clear message.

Raise timeoutSecs when you raise daysAhead. A large daysAhead ร— several hotelUrls is thousands of probes, and the default 900s will stop the run early. That's not a silent failure - a timed-out run stops cleanly and writes an explicit error record for every probe it never reached - but raise the timeout, or turn on tieredCadence.

tieredCadence probes every date for the next 2 weeks, every 2nd date for the ~6 weeks after, and every 3rd beyond, because far-out prices move least. At daysAhead: 365 this cuts probe count by roughly 62% (139 dates instead of 365) with no loss of near-term granularity.

includeSoldOut never shrinks the dataset. A record is written for every hotel-date regardless, because "scraped and found sold out" and "never scraped" are different facts. The flag is stamped onto every record as include_sold_out so you can filter downstream yourself.

There is no maxTotalChargeUsd input. That name belongs to a platform-level run option - Console's "Max cost per run (USD)", or options.maxTotalChargeUsd via the API. Apify enforces it automatically; this actor doesn't implement its own spend limit.

๐Ÿ—บ๏ธ Destination mode: scrape a whole city instead of listing hotels by hand

Instead of hotelUrls, give searchText (e.g. "Cape Town", "Sabi Sand") or searchUrl (a Booking.com search-results URL) and the actor resolves matching properties itself, then sweeps them identically - same record types, same fields, no way to tell from the dataset which mode produced a hotel's records.

Exactly one of hotelUrls/searchText/searchUrl is required. Supplying none, or more than one, fails the run immediately with a message naming the conflict, rather than guessing.

maxProperties (default 50, max 1000 - Booking's own documented ceiling for one destination) bounds how many distinct properties get discovered. Treat it as a ceiling approached, not a count guaranteed: Booking's search pagination isn't perfectly stable (roughly 19% of rows repeated across result pages in testing, most likely live re-ranking), so a run may resolve fewer than maxProperties distinct hotels even when more exist. The actor dedupes and never inflates the count to compensate.

minReviewScore and propertyType are real server-side Booking filters, so they also reduce how much gets paged through. maxReviewScore has no server-side equivalent - Booking's filter UI only offers a floor, never a ceiling - so it's applied on this actor's side at no extra cost. All three are destination-mode only; setting one alongside hotelUrls is rejected outright rather than silently ignored.

Cost multiplies fast here. maxProperties: 50 ร— daysAhead: 14 is 700 hotel-date probes from one input field, before any proxyCountries multiplier. Test with a small maxProperties and a short daysAhead first.

๐Ÿ“ค Output

One dataset record per unit of work, discriminated by record_type. Which types appear depends on mode - room/no_rooms in the default, calendar or calendar_rooms otherwise - and discovery and error appear in all three. Every record, whatever its type, carries hotel_url, the key to group by hotel, plus proxy_country.

room - one bookable room rate

A naming wrinkle worth knowing: the mode is offers but its records carry record_type: "room" - the mode value predates the modes being named after what a row is. Filter on record_type == "room", never "offers". Everywhere else, one of these rows is a room rate.

FieldTypeWhat it is
block_idstrThe unique key for one rate
room_idintThe room type; not unique per rate
room_namestrAs Booking displays it
room_name_cleanstrEqual to room_name today
check_in_datestrISO date, first night of the stay
check_out_datestrISO date, derived from nights
nightsintStay length this probe asked for
bed_typestr | nulle.g. "1 king bed"
max_guestsint | nullThe room's true capacity
priced_occupancyint | nullThe occupancy this rate is priced for
currencystr | nullCurrency of every price on this record
pricefloat | nullThe total for the whole stay - not per night
price_per_nightfloat | nullprice divided by nights
price_primaryfloat | nullprice for standard 2-guest occupancy; null on other variants
original_pricefloat | nullPre-discount total
discount_percentfloat | nulle.g. 47.70
price_by_room_countlistThis rate's total at every room quantity sold
taxes_includedlistCharges already folded into price
taxes_excludednullAlways null
price_includes_taxesbool | nullNever a guessed false
rooms_leftint | nullBooking's own stock figure
stock_confidencestrHow far to trust rooms_left
availability_statusstrDerived from rooms_left
is_refundablebool | nullFrom the cancellation type
is_breakfast_includedbool | nullFrom the meal plan
meal_planstr | nullBooking's own meal-plan name
book_now_pay_laterstr | nullFrom the prepayment policy text
free_cancellation_untilstr | nullISO date, from the cancellation policy text
rate_options / rate_options_countlist / intRate-plan flags
min_stayint | nullBooking's real minimum stay for this date
calendar_availablebool | nullWhether Booking's calendar calls this date bookable
facilitieslistEvery highlighted room badge
room_amenitieslistThe curated premium subset of facilities
room_size_sqmfloat | nullSquare metres; imperial converted
room_viewlist of stre.g. ["Mountain view"]
has_private_bathroombool | null
scrape_timestampstrISO 8601, UTC

discovery - property identity, one per hotel per run, free

FieldTypeNote
hotel_namestr | nullClean, without the page title's marketing suffixes
addressstr | nullThe full formatted address
city / region / postal_codestr | nulle.g. "Western Cape"
country / country_codestr | nullFull name, plus lowercase ISO-2
latitude / longitudefloat | nullFull precision
star_ratingint | nullBooking's star classification
property_typestr | nullBooking's own class - HOTEL, LODGE, VILLA - verbatim
checkin_from / checkin_untilstr | nullArrival window, as Booking writes it
checkout_from / checkout_untilstr | nullDeparture window
rating_value / rating_scalefloat / intGuest review score, e.g. 8.4 out of 10
review_countint | nullA live count; it drifts between runs
descriptionstr | nullBooking's own teaser
image_urlstr | nullPrimary property image

Emitted on whichever hotel-date probe first parses successfully for that hotel - join on hotel_url alone, not (hotel_url, check_in_date). All of it costs no extra request, since it's read from the page the room probe already fetched.

property_type and the check-in/check-out times are Booking's own values, unmodified - an uppercase enum rather than a display label, and locale-formatted strings rather than parsed times, because the page states no timezone and guessing one on an arrival cut-off is worse than leaving it to you. A missing arrival or departure half means Booking states no cut-off, not that we failed to read one.

no_rooms - an explicit sold-out shell

Emitted unconditionally for a hotel-date where the page rendered no room offers, regardless of includeSoldOut. This is what distinguishes "scraped and found sold out" from "never scraped". For a minimum-stay property probed at too few nights, it's the correct result - and it still carries min_stay and calendar_available.

error - one per failed unit of work

A bad fetch or an unreadable page. One bad hotel-date never stops the rest of a run, and errors are free.

calendar - one date's availability (calendar mode)

FieldTypeWhat it is
check_in_datestrISO date this row answers for
calendar_availablebool | nullWhether Booking's calendar calls this date bookable
min_stayint | nullThe property's real minimum stay for this date
bookable_at_requested_nightsbool | nullcalendar_available and nights >= min_stay. Null when min_stay is unknown - that means we did not find out, not that no minimum applies
avg_price_displaystr | nullIndicative nightly magnitude - a display string, never a number
adults / rooms / childrenintThe occupancy this answer reflects
nightsintThe stay length asked for, which bookable_at_requested_nights is measured against

calendar_rooms - one date, rooms nested (calendar_rooms mode)

Every field above, plus check_out_date, scrape_timestamp and availability.

This mode carries every field the flat room rows carry - it is the same fetches and the same data, only regrouped, which is why it bills the same. The tables under "room" above are the field reference for the two nested levels; what follows is only where each field lives.

FieldTypeWhat it is
availabilitylistRoom types on this date. Empty on a date with nothing bookable - never absent

Room-type level (availability[]) - facts identical across every rate of that room type: room_name, room_name_clean, room_id, max_guests, room_size_sqm, room_view, has_private_bathroom, facilities, room_amenities, plus rooms_available and stock_confidence.

Rate-plan level (availability[].rate_plans[]) - everything that can differ between two rates of the same room type: block_id, bed_type, priced_occupancy, price, price_per_night, price_primary, original_price, discount_percent, currency, taxes_included, taxes_excluded, price_includes_taxes, price_by_room_count, rooms_left, stock_confidence, availability_status, is_refundable, is_breakfast_included, meal_plan, book_now_pay_later, free_cancellation_until, rate_options, rate_options_count.

priced_occupancy matters most here: a room type sold at 2, 3 and 4 guests appears once with three rate plans, and occupancy is what tells them apart. It is on the rate plan so you never have to parse it out of block_id.

rooms_available is repeated at the room level as rooms_left on each rate plan. The two agree whenever a room type's rates agree, which is the norm; the per-rate figure is the one Booking actually returned.

On every record, whatever the mode

FieldTypeWhat it is
record_typestrWhich shape this row is
hotel_urlstrThe demux key. Group a hotel's records on this alone, never on (hotel_url, check_in_date)
input_indexintThis hotel's position in your input list, so you can map results back to what you asked for
nightsintThe stay length this run probed at
include_sold_outboolYour includeSoldOut input, stamped for downstream filtering. It never changes how many records you get
proxy_countrystr | nullThe proxyCountries entry this result was fetched through, or null at the default

๐Ÿ’ก How to read the price fields

Which price should you benchmark against? price_primary. It's the one field comparable across properties without further work, because it pins occupancy: a property selling the same room at 2, 3 and 4 occupants emits three rows, and only the 2-guest one carries price_primary. Comparing raw price across a comp set silently compares a 2-guest rate at one hotel against a 4-guest rate at another.

UseFieldWhy
Competitor benchmarkingprice_primaryOccupancy-normalised; the apples-to-apples number
Showing a guest what they'll paypriceThe actual stay total for the searched party
Comparing across different nightsprice_per_nightThe only field that normalises stay length
Measuring discountingoriginal_price, discount_percentBoth null unless genuinely discounted

price_primary is null on most rows by design - that means the row is a non-standard occupancy variant. Filtering to price_primary is not null gives you one comparable row per room type.

Pricing a group? price_by_room_count gives this rate's total at every room quantity the property will sell - [{"rooms": 1, "price": 21600}, {"rooms": 2, "price": 43200}] - so you don't need a second run per room count. It also states the ceiling: if it stops at 2, that rate can't be booked for three rooms at any price.

block_id, not room_id, is the unique key for one rate. The same room type is routinely sold under several rate plans, each a separate row sharing one room_id. Key on (hotel_url, check_in_date, block_id).

Migrating from a scraper that ships price_public? You already have it - it's called price. Genius member rates are only served to a signed-in session. This actor scrapes logged out, so no member discount is ever applied and price is the public price. Measured, not assumed: b_rate_is_genius is 0 on all 65 blocks across captured fixtures and on 46 blocks re-confirmed live, including chain city hotels chosen because they're the likeliest to run Genius at all.

Reading availability and stock

availability_statusMeaning
availableMore than 5 left, or no stock signal at all
low_availability2โ€“5 rooms left
low_availability_urgent1 room left
sold_outNot currently reachable - never guessed. If you see it, treat it as a bug report
stock_confidenceMeaning
exactBooking displayed its own "X left" badge
cappedNo badge, and the figure sits at this page's maximum - true stock may be higher
hiddenNo badge and no cap reached; Booking's stock field alone
conflictingBadge and stock field disagree. rooms_left is null; the rate and price are unaffected

A conflicting row still reports availability_status: available, because that's derived from rooms_left and rooms_left is null. Check stock_confidence first if you're acting on scarcity.

Reading min_stay and calendar_available together

min_staycalendar_availableReading
3trueBookable but needs 3 nights. A 1- or 2-night probe returning nothing is correct
1trueOpen, no restriction
anyfalseBooking's calendar says the date isn't bookable at all
nullanyNo calendar data fetched, or the fetch failed - never a guessed value

On a date Booking's calendar reports as unavailable, min_stay commonly reads 1 - that's Booking's filler for "no restriction data here", not an observed one-night minimum. Both fields default to null where the calendar call failed; that's treated as a non-fatal enrichment failure so room data keeps flowing.

โœ… Data quality: what "no availability" actually means here

An empty result from this actor is a fact about the property, not about our session.

Booking.com doesn't only block scrapers - it degrades them. A client it identifies as automated isn't refused; it's served a page that looks entirely normal, listing every room type by name, with a "Show prices" button where the rates should be and no rates anywhere. Nothing errors. The page is 1.4 MB of real content.

In the page structure, that degraded page is indistinguishable from a property that's genuinely sold out. Both carry the same room-table markup; neither carries the priced-offers table. A scraper that decides availability from the DOM will report a full sweep of confident, fabricated "sold out" results the moment it gets fingerprinted - run after run, with no error and no warning.

This actor never makes that call from the DOM. Booking embeds a rate array in every properly served page, and its three states are unambiguous:

What the page carriesWhat it meansWhat we emit
Array present, populatedRooms are on offerroom records
Array present but emptyGenuinely no availabilitya no_rooms shell
Array absentThe page was degraded or blockedan error record - never no_rooms

A degraded fetch is reported as a failure, loudly, on the record itself. When this actor tells you a property has no availability for a date, it's because Booking said so.

Property metadata is not a health signal. A degraded page still carries a complete, correct address, rating, coordinates and star rating, because those describe the property rather than the stay. record_type is the field that tells you whether the fetch worked.

This is also why the actor masks its browser fingerprint rather than treating that as an optional optimisation. Getting fingerprinted here doesn't cost throughput - it costs correctness.

๐ŸŒ Geo-pricing, taxes and proxyCountries

Booking.com prices the same room differently depending on the visitor's country. Add two-letter codes (e.g. ["ZA", "ID"]) to run the whole sweep once per country, with every record tagged proxy_country.

Each country needs its own browser session mint - the anti-bot token is bound to the egress IP, and country is fixed at proxy-connection time - so an N-country run costs roughly Nร— a single-country run, not just Nร— the bandwidth.

Check price_includes_taxes before comparing across countries

A live 6-country run (2 hotels ร— 2 dates, residential proxy, currency held fixed at ZAR):

Countryroom recordstaxes_included populatedPrice vs. ZA
ZA46461.0000
GB48481.0000
FR48481.0000
AE48481.0000
AU48481.0000
US4800.8696

ZA, GB, FR, AE and AU returned identical prices on every matched block. US was the only outlier, at a uniform 0.8696 - exactly 1 รท 1.15, South Africa's VAT rate - with no charge data in the record at all. For a South African property, proxyCountries behaves as a tax-presentation control, not a price-discovery tool.

Be clear about what this does and doesn't show. Two South African properties, two dates, one run. Booking clearly has the machinery to vary prices by country - that's what the US/VAT result is - and a property in a different market may well use it. Don't read this as license to skip checking your own properties.

price_includes_taxes is true or null, never a guessed false: an empty taxes_included alone doesn't tell you which way the price is missing tax, only that this actor can't yet say. Pin proxyCountries to your actual source market for any comparison you're going to trust.

One caveat on record counts. In an earlier run, US returned 46 room records against ZA's 48; in the run above, ZA returned 46 and US 48. That's inventory noise between requests, not a geographic pattern. The reliable signal is taxes_included and price_includes_taxes, not row counts.

Leaving proxyCountries unset has a data consequence

If you don't set it, the run uses whatever your proxy configuration's default egress is - on Apify Proxy, typically US. The table above shows what that costs: prices ~15% below every other country tested, tax-exclusive, with nothing in the record flagging it. If you want the price a guest in your source market actually sees, set proxyCountries to that market.

Datacenter vs. residential

Targeting a specific country requires a proxy group with IPs there. Residential covers any country but is billed per GB transferred; datacenter is billed per IP and is far cheaper - but only works for a country your Apify plan has a datacenter group in. A measured proxyCountries run on residential spent 85.6% of total cost on proxy bandwidth alone; the equivalent probe count on datacenter cost roughly 13ร— less per probe. This is billed to you by Apify, not by us, and proxyConfiguration stays datacenter by default so you're never charged for a tier you didn't choose.

If your plan has no datacenter IPs in the country you pick, the run fails loudly - with a proxy-mint timeout, not a clear "no datacenter group for this country" message. If you hit that, it means your proxy group doesn't cover that country, not that the actor is broken. Escalate to residential, or check what your plan's groups cover.

โšก How this Booking.com scraper works

Booking.com serves a JavaScript proof-of-work challenge (AWS WAF) to any plain HTTP client. Rather than pay for a headless browser page load per hotel-date - measured at ~$0.08โ€“0.15 per page load on scrapers that do - this actor loads one masked Playwright page per run to solve the challenge and mint an aws-waf-token, then replays that token as a plain httpx GET for every subsequent hotel-date.

One browser load per run, not one per result. That's the whole cost architecture.

min_stay and calendar_available add one call per hotel for the whole run, not one per hotel-date: Booking's availability calendar answers up to 61 days in a single ~5 KB response, against ~0.45 MB for one hotel-date HTML page.

๐Ÿค– Use it from the API, AI agents and MCP

Every run produces a structured dataset you can consume programmatically - which makes this actor usable as a live Booking.com hotel availability API, without building or maintaining the scraping infrastructure yourself.

  • Apify API - start runs and pull results as JSON from any language, or synchronously with a single HTTP call.
  • Apify MCP server - AI agents and LLM applications can discover this actor, run it with structured input, and read the dataset back through the Model Context Protocol. In Claude, for example: claude mcp add --transport http apify "https://mcp.apify.com".
  • Apify Scheduler - daily comp-set sweeps with no server of your own.
  • Make, Zapier, n8n - trigger runs and route results into spreadsheets, dashboards or alerts.

Example: asking Claude

Once the Apify MCP server is connected, this is a question you can just ask - the agent picks the mode, runs the Actor and reads the dataset back:

Using the Booking.com hotel room price, availability & min-stay scraper, check booking.com/hotel/za/inyati-game-lodge-sabi-sand-game-reserve.html for the next 60 days. Tell me which dates I could actually book a 2-night stay, which ones need longer, and roughly what a night costs.

A capable agent will reach for mode: "calendar" here rather than the default, because the question is about when, not about room-level rates - one page fetch and one availability call covers all 60 days instead of 60 fetches. It then reads three fields per date:

  • bookable_at_requested_nights - can I book this date at 2 nights?
  • min_stay - and if not, how many nights would it take?
  • avg_price_display - roughly what a night runs, as an indicative magnitude

That third field is a display string, not a number, and a good answer will say "around" rather than quoting it as a rate. For real prices, ask a follow-up against specific dates - that run uses the default offers mode and returns per-room, per-rate-plan prices with taxes and cancellation terms attached.

Other things worth asking:

Compare nightly rates across these four lodges for the first week of June, at 3 nights, and flag any that are already sold out.

Which weekends in the next three months does this property require a minimum stay longer than 2 nights?

That second question is one most Booking.com scrapers cannot answer at all: a 1-night probe reports a minimum-stay property as sold out rather than telling you what it actually requires.

๐Ÿ’ฐ Pricing

You pay per answer, and only for answers.

The billed unit is one room rate or one answered date - never a row. That is what stops mode being a discount: the same data costs the same whichever shape you ask for.

What you getEmitted asBilledPrice per 1,000
A priced, bookable room rateroom (offers mode)1 per rate$4.00
The same rates, grouped by datecalendar_rooms1 per nested rate$4.00
A hotel-date confirmed to have no availabilityno_rooms, or an empty calendar_rooms1 per date$4.00
A date's availability and minimum staycalendar1 per date$4.00
Property metadatadiscoveryโ€”free
A failed fetch or unparseable pageerrorโ€”free

So one date at The Table Bay โ€” 34 rate plans โ€” bills 34 either way: thirty-four room rows in offers, or one calendar_rooms row with thirty-four plans nested inside it. Verified on build 0.0.16. calendar mode bills 1 per date because it returns one answer per date, not thirty-four.

A sold-out date is a result, not a gap. If a competitor has no availability for a date - or won't sell the stay length you asked for - that's the demand signal you came for. It carries the property's real min_stay for that date, the one number a 1-night scraper can't give you at all.

You never pay for our failures. An error record means a fetch or parse failed. That's on us, and it's free however many a run produces.

You never pay for property metadata. discovery is one record per hotel per run, not per date, and free. It doesn't get more expensive because you set a longer daysAhead.

Nothing here can be inflated by us. Your inputs decide how many hotel-dates get probed, and each probe produces exactly one answer. The only thing that multiplies is rates - a property selling one room type under six rate plans bills six, because that's six real, differently-priced rates you asked to see.

Apify platform usage (compute, and proxy bandwidth beyond datacenter) is billed to you separately by Apify on top of this fee. To cap total spend, use Apify's platform-level "Max cost per run (USD)" option in Console or as an API run option - the sweep stops cleanly, writing an error record for every probe it doesn't reach, the moment that budget is reported spent.

What a run actually costs

Measured on the Apify platform, not estimated. A sweep of 3 hotels ร— 14 check-in dates at nights=2 (42 hotel-date probes) costs $0.013334 in platform compute - $0.000317 per hotel-date probed, $0.03887 per 1,000 dataset records. A 1-hotel ร— 7-date run confirms the same allocation on a different input shape: $0.001959 total, $0.000280/probe. No residential proxy bandwidth was used in either.

For comparison, a scraper that loads a full headless browser page for every hotel-date measures at ~$0.08โ€“0.15 per hotel-date - roughly 250โ€“470ร— this actor's cost for the same unit of work.

proxyCountries breaks the one-mint-per-run assumption these numbers rest on. Each country needs its own mint and re-runs the full sweep, so an N-country run costs roughly Nร— a single-country run. Leaving it at [] keeps the numbers above unchanged.

โ“ FAQ

Does Booking.com have a public API? Not for accommodation search data. Booking's Affiliate Partner Program offers a search API, but it requires approval and is intended for booking integrations rather than data analysis. This actor gives you the same room-level data through the Apify API instead.

Is it legal to scrape Booking.com? Scraping publicly available data is generally legal, but personal data is protected by GDPR in the EU and equivalent laws elsewhere. This actor collects room rates, availability and property metadata - not guest reviews or personal data. If you're unsure whether your use case is legitimate, consult your lawyers.

Why does a property show no rooms when I know it has availability? Almost always because you probed too few nights. Set nights to the stay length the property actually sells, then check min_stay and calendar_available on the no_rooms record - a min_stay above your nights with calendar_available: true confirms it.

Can I scrape Booking.com prices for a whole year? Yes - daysAhead goes up to 365. For prices, stay in offers mode, turn on tieredCadence to thin far-future dates (roughly a 62% probe-count cut) and raise timeoutSecs. If you only need to know when a property is free and what its minimum stay is, use mode: "calendar" instead: a year is about 6 availability calls per hotel rather than 365 page fetches.

What's the cheapest way to monitor availability across a long window? mode: "calendar". It returns Booking's own per-date availability and minimum stay for the whole window from one availability call per 61 days, plus a single page fetch per hotel for the property details - so a 60-date sweep costs one fetch instead of sixty, and bills one result per date instead of one per offer. On a hotel selling ~34 rate plans a date that is roughly 34x fewer billed results. The trade-off is no room-level detail and no real prices: avg_price_display is an indicative magnitude, not a rate. Use offers when you need what a room actually costs.

What's the difference between price and price_primary? price is the stay total for the party you searched. price_primary is that price only on the standard 2-guest block, and null elsewhere - it's the occupancy-normalised field to use for competitor benchmarking.

Do I need to set up proxies? No. Apify's datacenter proxy is used by default and included. Only set proxyCountries if you need country-specific pricing, and check whether your plan's proxy groups cover that country.

Can AI agents use this actor? Yes. Through Apify's MCP server, AI agents and LLM applications can find this actor, start runs with structured input, and consume the dataset - no custom integration code. It also works as a plain REST call via the Apify API.

Can I get Genius or member rates? No, and neither can any logged-out scraper - Booking only serves member rates to a signed-in session. That means price is the public rate rather than an approximation of it.

Can I export to CSV or Excel? Yes - every Apify dataset exports to JSON, CSV, Excel, XML or HTML, and is available through the Apify API.

Can I run this on a schedule? Yes. Use Apify's scheduler for daily comp-set sweeps, or trigger runs via API, Zapier, Make or n8n.

๐Ÿ“ Changelog

0.1 - 2026-08-18

calendar_rooms now carries every field offers does. It was documented as "the same room-level data, grouped by date" and billed identically per rate, but the nested shape only ever carried six rate fields against the flat shape's thirty. That is fixed rather than re-documented: the mode was a worse deal at the same price, and a mode input should choose a shape, never a discount.

Room-type facts sit on the room (room_name_clean, room_size_sqm, room_view, has_private_bathroom, facilities, room_amenities), and everything that can differ between two rates of the same room type sits on the rate plan (priced_occupancy, price_primary, price_per_night, original_price, discount_percent, taxes_included, taxes_excluded, price_includes_taxes, price_by_room_count, rooms_left, stock_confidence, availability_status, bed_type, book_now_pay_later, free_cancellation_until, rate_options, rate_options_count). check_out_date and scrape_timestamp join the date level.

priced_occupancy is the one worth knowing about: a room type sold at 2, 3 and 4 guests appears once with three rate plans, and occupancy is what tells them apart. Previously the only way to recover it here was parsing block_id's third segment.

Purely additive - rooms_available and stock_confidence stay on the room as well as on each rate - so an existing integration keeps working unchanged.

New input: useMinimumStay. Probe every date at that date's own minimum stay instead of one fixed length for the whole run. Minimums are per-date and seasonal - Inyati is 2 nights in November 2026 and 3 in June 2027 - so a single nights value is right for one half of a season and wrong for the other, and you had to know the number before running. Now you don't.

Each date's minimum is read from the availability calendar already fetched once per hotel per run, so this costs nothing extra: same one page fetch per hotel-date, just asking a question that date can answer. Off by default.

Each record's nights carries the length actually probed, which is what price covers and what price_per_night divides by - check it before comparing prices across dates. Where the calendar gave no minimum, we fall back to your nights rather than assuming 1, because "we didn't find out" and "no minimum applies" are different facts and guessing the shortest stay would manufacture the exact false sell-out this option removes.

0.0 - 2026-08-18

Three output modes. A new mode input decides what one row is, and therefore what a run costs. offers is the default and unchanged: every rate plan for every date. calendar answers a hotel's whole window from one availability call plus a single page fetch - a 30-date window costs one fetch instead of thirty, measured at $0.0037 against $0.0081 - carrying Booking's own per-date availability and minimum stay, but no room detail. calendar_rooms is the same data as offers grouped by date, for rendering a calendar; it costs the same.

Property classification and arrival windows on discovery. property_type carries Booking's own class - HOTEL, LODGE, VILLA - so a comp set can be filtered to comparable properties rather than lumping a game lodge in with a city hotel. checkin_from/checkin_until/checkout_from/checkout_until carry arrival and departure windows, which matter most for exactly the lodges and villas this actor exists for. Read from the page already fetched, so no extra request.

New field: price_by_room_count. What the same rate costs at every room quantity the property will sell, read from Booking's own matrix rather than computed.

Full property metadata on discovery. Address, city, region, postal code, country, coordinates, star rating, review score and count, description and image - read from the page the room probe already fetched, so no extra request and no change to the cost model.

New field: room_amenities. A curated premium subset of facilities - balconies, private pools, views, terraces, lounge access - selected by Booking's facility ids rather than by matching English text, so it doesn't break under a different lang.

New fields: min_stay, calendar_available. Real per-date, seasonal minimum-stay data from Booking's own availability calendar: one extra request per hotel for the whole run, not per hotel-date. A calendar-fetch failure is non-fatal; both fields read null.

New field: price_includes_taxes. A controlled 3-country run found US egress quotes tax-exclusive prices while ZA and GB quote tax-inclusive. This field is the explicit signal, true/null and never a guessed false.

Destination mode. searchText or searchUrl as an alternative to hotelUrls, resolving over the same one-mint architecture. maxProperties (default 50, max 1000) is a ceiling approached rather than guaranteed; Booking's pagination repeats roughly 19% of rows within a session, so this actor dedupes rather than promising an exact count.

Geo-pricing, date range and tiered cadence. proxyCountries runs the sweep once per country, tagging every record with proxy_country. daysAhead ceiling raised from 60 to 365; the currency dropdown widened from 10 to 32 entries. tieredCadence thins far-future dates for roughly a 62% probe-count cut at daysAhead: 365.

Room metadata. facilities, room_size_sqm, room_view, has_private_bathroom and room_name_clean, read from the room-type header and forward-filled across every rate plan. room_size_sqm converts imperial readings to square metres.

Competitive-parity fields. original_price, discount_percent, taxes_included, taxes_excluded, is_refundable, is_breakfast_included, book_now_pay_later, free_cancellation_until, stock_confidence, price_primary, rate_options_count, check_out_date and scrape_timestamp.

Cost fixes. discovery records deduped to one per hotel per run instead of one per hotel-date, and memory right-sized from 4096 to 1024 MB from measured peak usage across five platform runs - a 3-hotel ร— 14-date sweep dropped from $0.03743 to $0.013334.

Pay-per-event pricing. $4.00 per 1,000 availability results, billed on room and no_rooms records alike. discovery and error records are always free. Charging is one batched call per dataset push, and a charging failure is logged and swallowed rather than allowed to sink a run.