# Booking.com Hotel Room Price, Availability & Min-Stay Scraper (`aurith_labs/booking-availability`) Actor

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.

- **URL**: https://apify.com/aurith\_labs/booking-availability.md
- **Developed by:** [Aurith Labs](https://apify.com/aurith_labs) (community)
- **Categories:** Travel, Automation, E-commerce
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 1 bookmarks
- **User rating**: 5.00 out of 5 stars

## Pricing

from $4.00 / 1,000 availability results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

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

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](./docs/dataset-output.png)

<sub>Real output, unedited — dataset `E3sCeyKN5AZha6tA0`, build 0.0.15, ZA
residential egress so prices are tax-inclusive. Note `min_stay` differs by
property: Inyati requires 2 nights, The Table Bay 1. That column is the one most
Booking.com scrapers cannot give you.</sub>

### 📊 What data can you extract from Booking.com?

| | |
|---|---|
| **Room rates** | Total stay price, price per night, occupancy-normalised price, original price, discount % |
| **Availability** | Per-room, per-date availability, rooms left, stock confidence, sold-out confirmation |
| **Minimum stay** | Booking's own **per-date** minimum stay length, plus its calendar availability signal |
| **Rate terms** | Refundable or not, breakfast included, meal plan, free-cancellation date, pay-later policy |
| **Taxes** | Charges folded into the price, and an explicit flag for whether the price includes tax |
| **Room detail** | Bed type, max guests, room size in m², view, private bathroom, facilities, premium amenities |
| **Property data** | Name, full address, city, region, country, coordinates, star rating, review score, review count, property type, check-in/check-out windows, description, image |
| **Group pricing** | The 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.

```json
{
  "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:

```json
{
  "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:

```json
{
  "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.

<sub>Real output, dataset `zoFgnXS9rkduNj2GE`. Contrast a genuine sell-out,
which reads `calendar_available: false` with `min_stay: 1`.</sub>

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

```json
{
  "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.

<sub>Real output, dataset `cpEtuuXBlg87Oqf20`, from a deliberately invalid
hotel slug. The message is abridged; the run itself reported SUCCEEDED, with
the other two properties in it unaffected.</sub>

##### `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.

```json
{
  "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.

```json
{
  "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.

<sub>Recorded for our own traceability, build 0.0.15 on 2026-08-18: `offers` and `discovery` from
dataset `tZ1wziFp42w3iKCya` (The Table Bay, residential ZA egress - which is why
`taxes_included` is populated); `calendar` from `EUrf2cqraK4GEkkCd` (Inyati, 30
dates); `calendar_rooms` regenerated through the shipped code from the captured
Table Bay page in `tests/fixtures/` when the nested shape reached field parity
with `offers`, which is why its prices are tax-inclusive where the earlier
`lXbEkkngQ2YB3r6Vz` run's were not.</sub>

### 🎯 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:

| Probe | Result |
|---|---|
| 1 night | No room table rendered - a 1-night-only scraper reports this as sold out |
| 2 nights | A 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:

| Date | `min_stay` | We probe at |
|---|---|---|
| 2026-11-14 | 2 | 2 nights |
| 2027-06-14 | 3 | 3 nights |
| A date the calendar didn't answer | unknown | your `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

| Field | Type | Default | Notes |
|---|---|---|---|
| `hotelUrls` | array of `{url, method}` | - | Booking.com hotel page URLs - exactly one of `hotelUrls`/`searchText`/`searchUrl` is required |
| `searchText` | string | - | A free-text destination, e.g. `"Cape Town"` |
| `searchUrl` | string | - | A pasted Booking.com search-results URL |
| `maxProperties` | integer, 1–1000 | 50 | Destination mode only - a ceiling approached, not a count guaranteed |
| `minReviewScore` | number, 0–10 | - | Destination mode only |
| `maxReviewScore` | number, 0–10 | - | Destination mode only - client-side filter |
| `propertyType` | string | - | Destination mode only, e.g. `"Lodges"`, `"Hotels"` |
| `startDate` | string (`YYYY-MM-DD`) | today | First check-in date to probe |
| `daysAhead` | integer, 1–365 | 7 | Consecutive check-in dates from `startDate` |
| **`nights`** | integer, 1–7 | **1** | **Stay length per probe - set this to the length the property actually sells** |
| **`useMinimumStay`** | boolean | `false` | **Probe 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** |
| **`mode`** | enum | **`offers`** | **What 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** |
| `currency` | string | `USD` | 32-currency dropdown incl. `ZAR`, `INR`, `SGD`, `HKD`, `IDR`, `COP` |
| `adults` | integer, 1–30 | 2 | Adults per room |
| `rooms` | integer, 1–30 | 1 | Rooms requested |
| `children` | integer, 0–10 | 0 | **Requires `childrenAges`** |
| `childrenAges` | array of integers, 0–17 | `[]` | One age per child - **required whenever `children` > 0** |
| `includeSoldOut` | boolean | `true` | Does not change record count |
| `tieredCadence` | boolean | `false` | Probe every date near check-in, thin far-future dates |
| `proxyConfiguration` | object | `{useApifyProxy: true}` | Datacenter by default |
| `proxyCountries` | array of 2-letter codes | `[]` | Run the sweep once per country for geo-pricing |
| `timeoutSecs` | integer | 900 | Stops 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)* | `calendar` | `calendar_rooms` |
|---|---|---|---|
| One row is | a room rate | a date | a date, rooms nested |
| Room detail | ✅ full | ❌ none | ✅ full |
| Real prices | ✅ | ❌ indicative only | ✅ |
| Per-date `min_stay` | ✅ | ✅ | ✅ |
| Page fetches per hotel, N dates | N | **1** | N |
| Rows per hotel-date | one per rate | 1 | 1 |
| **Billed results** per hotel-date | one per rate | **1** | one 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**.

| Field | Type | What it is |
|---|---|---|
| `block_id` | str | **The unique key for one rate** |
| `room_id` | int | The room *type*; not unique per rate |
| `room_name` | str | As Booking displays it |
| `room_name_clean` | str | Equal to `room_name` today |
| `check_in_date` | str | ISO date, first night of the stay |
| `check_out_date` | str | ISO date, derived from `nights` |
| `nights` | int | Stay length this probe asked for |
| `bed_type` | str | null | e.g. "1 king bed" |
| `max_guests` | int | null | The room's true capacity |
| `priced_occupancy` | int | null | The occupancy *this rate* is priced for |
| `currency` | str | null | Currency of every price on this record |
| **`price`** | float | null | **The total for the whole stay** - not per night |
| **`price_per_night`** | float | null | `price` divided by `nights` |
| **`price_primary`** | float | null | `price` for standard 2-guest occupancy; `null` on other variants |
| `original_price` | float | null | Pre-discount total |
| `discount_percent` | float | null | e.g. `47.70` |
| `price_by_room_count` | list | This rate's total at every room quantity sold |
| `taxes_included` | list | Charges already folded into `price` |
| `taxes_excluded` | null | Always `null` |
| `price_includes_taxes` | bool | null | Never a guessed `false` |
| `rooms_left` | int | null | Booking's own stock figure |
| `stock_confidence` | str | How far to trust `rooms_left` |
| `availability_status` | str | Derived from `rooms_left` |
| `is_refundable` | bool | null | From the cancellation type |
| `is_breakfast_included` | bool | null | From the meal plan |
| `meal_plan` | str | null | Booking's own meal-plan name |
| `book_now_pay_later` | str | null | From the prepayment policy text |
| `free_cancellation_until` | str | null | ISO date, from the cancellation policy text |
| `rate_options` / `rate_options_count` | list / int | Rate-plan flags |
| `min_stay` | int | null | Booking's real minimum stay **for this date** |
| `calendar_available` | bool | null | Whether Booking's calendar calls this date bookable |
| `facilities` | list | Every highlighted room badge |
| `room_amenities` | list | The curated premium subset of `facilities` |
| `room_size_sqm` | float | null | Square metres; imperial converted |
| `room_view` | list of str | e.g. `["Mountain view"]` |
| `has_private_bathroom` | bool | null | |
| `scrape_timestamp` | str | ISO 8601, UTC |

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

| Field | Type | Note |
|---|---|---|
| `hotel_name` | str | null | Clean, without the page title's marketing suffixes |
| `address` | str | null | The **full formatted** address |
| `city` / `region` / `postal_code` | str | null | e.g. "Western Cape" |
| `country` / `country_code` | str | null | Full name, plus lowercase ISO-2 |
| `latitude` / `longitude` | float | null | Full precision |
| `star_rating` | int | null | Booking's star classification |
| `property_type` | str | null | Booking's own class - `HOTEL`, `LODGE`, `VILLA` - verbatim |
| `checkin_from` / `checkin_until` | str | null | Arrival window, as Booking writes it |
| `checkout_from` / `checkout_until` | str | null | Departure window |
| `rating_value` / `rating_scale` | float / int | Guest review score, e.g. `8.4` out of `10` |
| `review_count` | int | null | A live count; it drifts between runs |
| `description` | str | null | Booking's own teaser |
| `image_url` | str | null | Primary 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)*

| Field | Type | What it is |
|---|---|---|
| `check_in_date` | str | ISO date this row answers for |
| `calendar_available` | bool | null | Whether Booking's calendar calls this date bookable |
| `min_stay` | int | null | The property's real minimum stay **for this date** |
| `bookable_at_requested_nights` | bool | null | `calendar_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_display` | str | null | Indicative nightly magnitude - **a display string, never a number** |
| `adults` / `rooms` / `children` | int | The occupancy this answer reflects |
| `nights` | int | The 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.

| Field | Type | What it is |
|---|---|---|
| `availability` | list | Room 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

| Field | Type | What it is |
|---|---|---|
| `record_type` | str | Which shape this row is |
| `hotel_url` | str | **The demux key.** Group a hotel's records on this alone, never on `(hotel_url, check_in_date)` |
| `input_index` | int | This hotel's position in your input list, so you can map results back to what you asked for |
| `nights` | int | The stay length this run probed at |
| `include_sold_out` | bool | Your `includeSoldOut` input, stamped for downstream filtering. It never changes how many records you get |
| `proxy_country` | str | null | The `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.

| Use | Field | Why |
|---|---|---|
| Competitor benchmarking | **`price_primary`** | Occupancy-normalised; the apples-to-apples number |
| Showing a guest what they'll pay | `price` | The actual stay total for the searched party |
| Comparing across different `nights` | `price_per_night` | The only field that normalises stay length |
| Measuring discounting | `original_price`, `discount_percent` | Both `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_status` | Meaning |
|---|---|
| `available` | More than 5 left, or no stock signal at all |
| `low_availability` | 2–5 rooms left |
| `low_availability_urgent` | 1 room left |
| `sold_out` | Not currently reachable - never guessed. If you see it, treat it as a bug report |

| `stock_confidence` | Meaning |
|---|---|
| `exact` | Booking displayed its own "X left" badge |
| `capped` | No badge, and the figure sits at this page's maximum - **true stock may be higher** |
| `hidden` | No badge and no cap reached; Booking's stock field alone |
| `conflicting` | Badge 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_stay` | `calendar_available` | Reading |
|---|---|---|
| `3` | `true` | Bookable but needs 3 nights. A 1- or 2-night probe returning nothing is **correct** |
| `1` | `true` | Open, no restriction |
| any | `false` | Booking's calendar says the date isn't bookable at all |
| `null` | any | No 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 carries | What it means | What we emit |
|---|---|---|
| Array present, populated | Rooms are on offer | `room` records |
| Array **present but empty** | Genuinely no availability | a `no_rooms` shell |
| Array **absent** | The page was degraded or blocked | an `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):

| Country | `room` records | `taxes_included` populated | Price vs. ZA |
|---|---|---|---|
| ZA | 46 | 46 | 1.0000 |
| GB | 48 | 48 | 1.0000 |
| FR | 48 | 48 | 1.0000 |
| AE | 48 | 48 | 1.0000 |
| AU | 48 | 48 | 1.0000 |
| **US** | 48 | **0** | **0.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 get | Emitted as | Billed | Price per 1,000 |
|---|---|---|---|
| A priced, bookable room rate | `room` (`offers` mode) | 1 per rate | **$4.00** |
| The same rates, grouped by date | `calendar_rooms` | **1 per nested rate** | **$4.00** |
| A hotel-date confirmed to have no availability | `no_rooms`, or an empty `calendar_rooms` | 1 per date | **$4.00** |
| A date's availability and minimum stay | `calendar` | 1 per date | **$4.00** |
| Property metadata | `discovery` | — | free |
| A failed fetch or unparseable page | `error` | — | 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.

# Actor input Schema

## `hotelUrls` (type: `array`):

Booking.com hotel page URLs, e.g. https://www.booking.com/hotel/za/inyati-game-lodge-sabi-sand-game-reserve.html. Alternative to 'Destination (search text)' and 'Search URL' below: exactly one of the three must be supplied. This is the field used by the actor's own default input, below — leave 'Destination (search text)' and 'Search URL' empty when using this one.

## `searchText` (type: `string`):

Free-text destination to search, e.g. "Cape Town" or "Sabi Sand" — resolved the same way Booking.com's own search box does, then every matching property (up to 'Max properties' below) is swept the same way a manually-listed hotel URL would be. Alternative to 'Hotel URLs' and 'Search URL': exactly one of the three must be supplied.

## `searchUrl` (type: `string`):

A Booking.com search-results URL copied straight from your browser (must include dest\_id and dest\_type in its query string) — used instead of resolving 'Destination (search text)' via autocomplete, e.g. if you already have a specific Booking.com search you want reproduced exactly. Alternative to 'Hotel URLs' and 'Destination (search text)': exactly one of the three must be supplied.

## `maxProperties` (type: `integer`):

Destination mode only ('Destination (search text)' / 'Search URL'). Upper bound on how many distinct properties to discover and then sweep. This is a ceiling approached, not a count guaranteed: Booking's own result pagination is not perfectly stable (roughly 19% duplicate results observed across a full 1,000-property pagination sweep in testing), so a run may resolve fewer than this many distinct hotels even when more exist for the destination. Cost multiplies with 'Days ahead' and 'Proxy countries' — maxProperties: 50 x daysAhead: 14 is 700 hotel-date probes from this one field alone, before any proxy-country multiplier, so raise it deliberately, not by default.

## `minReviewScore` (type: `number`):

Destination mode only. Only discover properties with a review score of at least this, on Booking's own 0-10 scale (e.g. 8.0 for an "8+"-rated property). Applied as a coarse server-side prefilter first (Booking's own filter only offers 6/7/8/9 floor buckets, so this is rounded down to the nearest one to avoid excluding a qualifying property), then re-checked exactly against each property's real score at no extra fetch cost.

## `maxReviewScore` (type: `number`):

Destination mode only. Only discover properties with a review score of at most this, on Booking's own 0-10 scale. Booking.com has no server-side filter for an upper bound, so this is applied entirely client-side against each property's real score, already present in the search response at no extra fetch cost — but unlike 'Minimum review score', it cannot reduce how many results are actually fetched from Booking.

## `propertyType` (type: `string`):

Destination mode only. Restrict discovery to one Booking.com property category, applied server-side.

## `startDate` (type: `string`):

First check-in date to probe, as YYYY-MM-DD. Defaults to today when left empty.

## `daysAhead` (type: `integer`):

How many consecutive check-in dates to probe, starting at the start date. Up to 365 (a full year out). A large value multiplied by several hotels can be thousands of probes — at the default 'Run timeout (seconds)' this may stop before every date is attempted, and every unattempted probe still becomes an explicit error record rather than being silently dropped. Raise 'Run timeout (seconds)' to match, or turn on 'Tiered cadence' to probe far-out dates less often and cut both time and cost.

## `tieredCadence` (type: `boolean`):

Probe every date near check-in, but thin out far-future dates: daily for the next 2 weeks, every 2nd day for the next ~6 weeks, every 3rd day beyond that. Far-out prices move the least, so this cuts probe count (and cost) substantially on a long 'Days ahead' window without losing near-term granularity. Off by default, which probes every date.

## `nights` (type: `integer`):

Stay length for each probe. Minimum-stay properties (safari lodges, villas, resorts) return no rooms at 1 night even when they are bookable at 2 or more. Set this to the stay length the property actually sells.

## `useMinimumStay` (type: `boolean`):

Ask every date for the stay length that date actually sells, instead of one fixed length for the whole run. We read each date's minimum stay from Booking's own availability calendar first, then price that date at that length. Turn this on when you do not know a property's minimum stay, or when it varies across the season - a min-stay-3 date probed at 1 night comes back with nothing bookable, which looks identical to a sell-out. 'Nights per stay' above becomes the fallback, used only on dates where the calendar did not tell us a minimum. Costs nothing extra: the same one fetch per hotel-date, just asking a different question. Each record's 'nights' field carries the length that record's price actually covers, so check it before comparing prices across dates.

## `mode` (type: `string`):

What one dataset row represents. Billing is per offer or per answered date, never per row — so the same data costs the same whichever shape you pick.

• 'One row per room rate' (default) — the full room-level result: every rate plan a property is selling for every date, with prices, taxes, cancellation terms and amenities. One page fetch per hotel-date. Rows per hotel-date vary by property: a single-room lodge yields one or two, a large hotel ~34.

• 'One row per date (availability calendar)' — Booking's own per-date availability and minimum stay across the whole window, plus one property record per hotel. Costs ONE page fetch per hotel plus one availability call per 61 days, and bills one result per date. On a large hotel that is roughly 34x fewer billed results than the default; on a single-room lodge, about the same. Carries no room detail and no real prices.

• 'One row per date, rooms nested' — the same room-level data as the default, grouped by date. Same fetches and the SAME bill as the default: a nested row bills one result per rate plan inside it. Only the JSON shape differs.

'Days ahead' multiplies results in every mode.

## `currency` (type: `string`):

Currency for returned prices. This dropdown lists the currencies subscribers have actually asked for; the underlying validation accepts any non-empty Booking.com currency code, including ones not in this list — type one in via the API if you need it.

## `adults` (type: `integer`):

Number of adults per room in the availability search.

## `rooms` (type: `integer`):

Number of rooms requested in the availability search.

## `children` (type: `integer`):

Number of children per room in the search. Leave at 0 for an adults-only search. If you set this above 0, you MUST also fill in 'Children ages' below with exactly that many ages — Booking.com silently ignores a child with no age attached (verified against a live property: a search sent as 1 child with no age comes back from Booking's own response as 0 children), so an age-less child count would quietly search as if it were 0.

## `childrenAges` (type: `array`):

One age (0-17) per child counted in 'Children' above, in the same order, e.g. \["5", "10"] for two children aged 5 and 10. Required whenever 'Children' is greater than 0, and must contain exactly that many ages — Booking.com drops any child search parameter that has no matching age.

## `includeSoldOut` (type: `boolean`):

This does not change how many records you get: a record for every date is always produced, sold out or not, because absence and emptiness are different facts and we never destroy that distinction at source. This flag is stamped onto every record as 'include\_sold\_out' so you can filter the sold-out shells out yourself downstream; turning it off does not shrink the dataset.

## `delayBetweenRequests` (type: `number`):

Pause between hotel-date probes, for politeness and to reduce blocking risk. We fetch over plain HTTP rather than loading a browser page per hotel-date like the incumbent scraper, so a given delay costs us far less than it costs them — we can afford to be a slower, more considerate client without taking their cost penalty for it. The default (1s) still sweeps faster than the incumbent's own 3-4s anti-blocking pause. Set to 0 to disable.

## `proxyConfiguration` (type: `object`):

Proxy used for all requests. Datacenter is sufficient in testing; escalate to residential only if you observe blocking. If 'Proxy countries' below is set, each of its countries overrides the country here (if any) for that country's own requests.

## `proxyCountries` (type: `array`):

Booking.com prices the same room differently depending on the visitor's country. Add one or more two-letter country codes (e.g. "ID" for Indonesia) to run the entire sweep once per country and get one set of country-specific prices per hotel-date, each record tagged with its 'proxy\_country'. Leave empty (default) to run once with whatever the proxy configuration above gives you — on Apify Proxy that is typically US egress, and a live measurement found US-egress prices come back tax-exclusive with 'taxes\_included'/'price\_includes\_taxes' both empty, roughly 15% below every other country tested (South Africa's VAT). If you want the price a guest in your source market actually pays, set this to that market, e.g. \["ZA"]. Each country requires its own browser session mint, so this multiplies both run time and platform cost by the number of countries listed — see the actor's SPEC.md and README for the measured cost model, including the datacenter-vs-residential cost difference for reaching a country your proxy plan doesn't already cover.

## `timeoutSecs` (type: `integer`):

Stop the run after this many seconds so a hung fetch cannot bill unbounded. The minimum is set above the one-time browser mint's own worst case (~75s), so a run always has time left to attempt at least one hotel-date probe before stopping. A run that hits this timeout stops cleanly (not a failure) and writes an explicit error record for every probe it never got to — raise this if 'Days ahead', the hotel count, or 'Proxy countries' push the sweep past what the default 900s can finish; each proxy country multiplies both the mint cost and the total probe count.

## Actor input object example

```json
{
  "hotelUrls": [
    {
      "url": "https://www.booking.com/hotel/za/the-table-bay.html",
      "method": "GET"
    }
  ],
  "searchText": "Cape Town",
  "maxProperties": 50,
  "startDate": "2026-10-08",
  "daysAhead": 3,
  "tieredCadence": false,
  "nights": 1,
  "useMinimumStay": false,
  "mode": "offers",
  "currency": "USD",
  "adults": 2,
  "rooms": 1,
  "children": 0,
  "childrenAges": [],
  "includeSoldOut": true,
  "delayBetweenRequests": 1,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "proxyCountries": [],
  "timeoutSecs": 900
}
```

# Actor output Schema

## `all_results` (type: `string`):

Every record this run produced - room rates, properties, sold-out dates and any errors - with all fields.

## `rate_fields` (type: `string`):

The same records, narrowed to the rate columns: room name, price, taxes, cancellation terms, minimum stay. Selects columns, not rows - non-rate records still appear, with these fields empty.

## `property_fields` (type: `string`):

Narrowed to the property columns: name, address, coordinates, star rating, review score, check-in window. Selects columns, not rows.

## `csv_export` (type: `string`):

The full result set as a spreadsheet.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "hotelUrls": [
        {
            "url": "https://www.booking.com/hotel/za/the-table-bay.html",
            "method": "GET"
        }
    ],
    "daysAhead": 3
};

// Run the Actor and wait for it to finish
const run = await client.actor("aurith_labs/booking-availability").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "hotelUrls": [{
            "url": "https://www.booking.com/hotel/za/the-table-bay.html",
            "method": "GET",
        }],
    "daysAhead": 3,
}

# Run the Actor and wait for it to finish
run = client.actor("aurith_labs/booking-availability").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "hotelUrls": [
    {
      "url": "https://www.booking.com/hotel/za/the-table-bay.html",
      "method": "GET"
    }
  ],
  "daysAhead": 3
}' |
apify call aurith_labs/booking-availability --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,aurith_labs/booking-availability"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/AwlYJfb25SkaPRZBT/builds/eYcjgmoTsLXlqzlEt/openapi.json
