TL;DR
The official Booking.com APIs and public-page extraction are separate workflows. Use the Demand API for an approved customer-facing travel product and Connectivity APIs for property operations. The tested Python example below reads public search pages only. On July 29, 2026, it validated 10 hotel rows across Rome and Florence, then caught a price change and a semantic mapping error during source review.
What is Booking.com scraping?
Here, Booking.com scraping means extracting facts that are visibly published on Booking.com pages: property names, displayed prices, review scores, room labels, cancellation text, and availability messages. A useful result also records the search dates, guest count, currency, source URL, and retrieval time. Without that context, a hotel price is nearly meaningless.
There is an official route too. The Demand API serves approved travel partners that want accommodation, car rental, or attraction inventory in their products. Connectivity APIs serve accommodation providers and connectivity partners that manage rates, availability, reservations, and property content. Neither product is the same as collecting a limited set of facts from a public result page.
Mixing those paths leads to bad designs. A Booking.com scraper does not create API entitlement, expose authenticated inventory, or provide a booking-grade contract. It can answer a narrower question: what did an accessible public page display for this search at this time?
The code in this guide uses ScrapeGraphAI against two public search URLs. It never signs in, reads guest records, calls private Booking.com endpoints, or attempts to bypass a refusal. Official product details come from Booking.com's documentation; only the ScrapeGraphAI workflow was executed.
Demand API, Connectivity APIs, and public pages
Two developer platforms serve different customers.
The Demand API is for managed affiliate partners building travel experiences. Booking.com currently documents accommodations, car rentals, and attractions. Its overview describes four integration levels, from content-only access through search and redirect, the booking journey, and post-booking order management.
The Connectivity APIs point the other way. They help properties and connectivity providers send rates and availability, receive reservations, manage content, and work with operational data after a property grants the required connection permissions.
| Path | Built for | Use it when |
|---|---|---|
| Demand API | Approved travel affiliates | A customer-facing product needs contracted search or booking data |
| Connectivity APIs | Properties and connectivity partners | A property needs to manage rates, availability, content, or reservations |
| Public-page extraction | Permitted public-source research | A small dataset needs visible facts with source URLs and timestamps |
If your product needs live search, booking, cancellations, or contracted service levels, start with the official API. If you need a small, source-linked research snapshot from a public page, extraction may fit. The hotel scraping guide covers the broader source-selection problem, while the KAYAK API guide explains a different partner-gated travel API.
What hotel data can you extract?
A search result card can expose property name, neighborhood or distance, review score, review count, room type, displayed price, taxes, cancellation terms, urgency text, and a property URL. Which fields appear depends on dates, occupancy, locale, device, experiments, and inventory.
Keep source text before normalizing it. € 190 is safer than immediately storing 190.00 because the surrounding card may clarify whether the value is nightly, total, discounted, or subject to additional charges. Likewise, Free cancellation should remain text until you know the deadline and room rate it belongs to.
The query inputs belong in every row:
- destination;
- check-in and checkout dates;
- adults, children, and rooms;
- display currency and locale;
- filters or sort order that affect the list;
- source URL and UTC retrieval time.
Avoid collecting reviewer names, account data, payment details, or content behind sign-in. Aggregate review scores and counts are usually enough for market research. If a project genuinely needs personal data, pause for legal and privacy review before designing the dataset.
Official API access, keys, sandbox, limits, and cost
Access starts with the Demand API prerequisites: registration as a Managed Affiliate Partner. After the contract is signed, the Partner Centre provides an API key token and affiliate ID. Current Demand API documentation uses a Bearer token plus X-Affiliate-Id for version 3 and later.

Demand API documentation from Booking.com, captured July 29, 2026. View the live Demand API overview.
For testing, there is a sandbox environment with test inventory. Its documented limit is 50 requests per minute. Production rate limits are partner-specific, so the rate-limit page tells partners to check with their account manager. A 429 response means the applicable limit has been exceeded.

Partner access prerequisites from Booking.com, captured July 29, 2026. View the live access requirements.
There is no fixed public Demand API price table in the documentation checked for this guide. Do not treat the sandbox limit as a production allowance or invent a per-request cost. Ask Booking.com which integration level, markets, fields, traffic allowance, attribution rules, support, and commercial terms apply to your contract.
The official accommodation search endpoint returns documented inventory for an approved integration. The Python code below does not imitate that request. Public documentation cannot tell us what your account may access, and a fabricated official request would be worse than no example.
The current Demand API reference describes REST endpoints over HTTPS with JSON requests and responses. The accommodation search guide says a search can return the accommodation ID, best matching product, availability, price information, policies, and extra-charge context. Those are documented product capabilities, not permission to call the service without credentials.
Price fields need their own review. Booking.com distinguishes base, book, total, and extra-charge concepts in its accommodation pricing documentation. A customer-facing integration has to display the correct combination for the market and stage of the booking journey. A public result card may compress that detail into one price line plus tax text. Preserve both strings rather than claiming that a page scrape recreates the official pricing model.
Before signing a contract, ask practical questions that change the implementation estimate:
- Which API version, integration level, and collections will the account receive?
- Are payments and order management part of the approved journey, or does the product redirect?
- Which markets, currencies, languages, and traveler residency rules apply?
- Which price components must be displayed at search, details, and checkout?
- What production rate, timeout, retry, cache, storage, and attribution rules apply?
- How are credential rotation, incident support, and version retirement handled?
Answers from the provisioned documentation and account team outrank an example found in a blog. The public reference is useful for evaluating the product shape, but your contract defines the usable surface.
When public-page extraction makes sense
Public extraction works best for bounded questions: compare a handful of destinations, monitor a small competitor set, verify how a rate is displayed, or build a source-linked research snapshot. It is a poor foundation for a booking engine, exhaustive inventory mirror, or high-frequency price feed.
Use the official Demand API when users will search and book inside your product, when field stability matters, or when you need rights and support stated in a contract. Use public extraction only when the page is accessible, the use is permitted, and occasional layout changes or access failures are acceptable.
ScrapeGraphAI does not make Booking.com controls disappear. If the page returns a challenge, consent wall, empty state, or refusal, the workflow should stop. The single JavaScript retry below is for rendering a public page, not for cycling identities until a request succeeds.
Design the dataset before writing the prompt
Start with the decision the rows will support. A competitor-rate check might need property, room label, displayed price, tax text, cancellation text, dates, and occupancy. A neighborhood study may care more about location, rating, review count, and property URL. Combining both into an enormous prompt makes validation harder and usually creates more ambiguous fields.
Choose a stable unit of observation. In this guide it is one visible hotel offer for one destination search, with explicit dates, two adults, one room, and EUR display currency. A property can appear more than once when Booking.com shows several rooms or rates, so a production key may also need room type, meal plan, cancellation terms, and a source-specific product identifier.
Decide what must be present before accepting a row. Here, property name and displayed price are required. Review data and policy text are optional because the card may omit them. The pipeline discards known placeholder text, but it does not replace missing values with guesses. That difference keeps an incomplete observation from becoming a false one.
Finally, define the review path. Automated checks handle types, ranges, required text, source URL, and timestamp. Semantic checks compare a small sample with the live page and quarantine suspicious rows, such as a neighborhood mapped into the property name. Both layers are necessary.
Set up the tested Python workflow
The July 29 test used Python 3.12.12, scrapegraph-py==2.1.0, Pydantic 2.13.4, and pandas 2.3.3 in a clean environment. The SDK reads SGAI_API_KEY from the process environment.
python -m pip install "scrapegraph-py==2.1.0" "pydantic==2.13.4" "pandas==2.3.3"
export SGAI_API_KEY="your-key"The schema keeps displayed prices as strings and optional page fields as nullable values. Known placeholder strings are converted to None. Rows missing a usable property name or displayed price are rejected before validation.
import json
import time
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, Field, ValidationError, field_validator
from scrapegraph_py import FetchConfig, ScrapeGraphAI
MISSING_TEXT = {"N/A", "No content available", "XXX"}
class HotelOffer(BaseModel):
property_name: str = Field(min_length=1)
location: str | None = None
review_score: float | None = Field(default=None, ge=0, le=10)
review_count: int | None = Field(default=None, ge=0)
room_type: str | None = None
displayed_price: str = Field(min_length=1)
taxes_and_fees: str | None = None
cancellation_terms: str | None = None
availability_note: str | None = None
property_url: str | None = None
@field_validator(
"location",
"room_type",
"taxes_and_fees",
"cancellation_terms",
"availability_note",
"property_url",
mode="before",
)
@classmethod
def clean_missing_text(cls, value: Any) -> Any:
if isinstance(value, str) and value.strip() in MISSING_TEXT:
return None
return value
class HotelSearchResults(BaseModel):
currency: str = Field(min_length=3, max_length=3)
offers: list[HotelOffer] = Field(min_length=1, max_length=40)
destination: str = ""
checkin: str = ""
checkout: str = ""
adults: int = 0
rooms: int = 0
source_url: str = ""
retrieved_at: datetime | None = NoneProvenance comes from the input and UTC clock, not the model. The helper removes those fields from the extraction schema, keeps at most five usable offers, and retries once with JavaScript only after a request or validation failure.
def extraction_schema() -> dict[str, Any]:
schema = HotelSearchResults.model_json_schema()
provenance = {
"destination", "checkin", "checkout", "adults",
"rooms", "source_url", "retrieved_at",
}
for field in provenance:
schema["properties"].pop(field, None)
schema["required"] = [
field for field in schema.get("required", []) if field not in provenance
]
return schema
def parse_json_data(response: Any) -> Any:
data = response.data.json_data
return json.loads(data) if isinstance(data, str) else data
def keep_usable_offers(payload: Any) -> Any:
if not isinstance(payload, dict) or not isinstance(payload.get("offers"), list):
return payload
usable: list[dict[str, Any]] = []
for offer in payload["offers"]:
if not isinstance(offer, dict):
continue
required = (offer.get("property_name"), offer.get("displayed_price"))
if any(
not isinstance(value, str)
or not value.strip()
or value.strip() in MISSING_TEXT
for value in required
):
continue
usable.append(offer)
return {**payload, "offers": usable[:5]}
def extract_hotels(
sgai: ScrapeGraphAI,
*,
url: str,
destination: str,
checkin: str,
checkout: str,
adults: int = 2,
rooms: int = 1,
) -> tuple[HotelSearchResults, dict[str, Any]]:
prompt = (
f"Extract up to five hotel offers visibly listed for {destination}, "
f"check-in {checkin}, check-out {checkout}, {adults} adults and {rooms} room. "
"For each offer copy the property name, visible location, review score, "
"review count, room type, complete displayed price text, taxes and fees text, "
"cancellation terms, availability note, and property URL when shown. "
"Return the three-letter display currency. Do not infer missing values."
)
errors: list[str] = []
for attempt in range(2):
started = time.perf_counter()
options: dict[str, Any] = {"url": url, "schema": extraction_schema()}
if attempt == 1:
options["fetch_config"] = FetchConfig(
mode="js", stealth=True, wait=2000
)
response = sgai.extract(prompt, **options)
elapsed = round(time.perf_counter() - started, 2)
try:
if response.status != "success":
raise RuntimeError(response.error or response.status)
results = HotelSearchResults.model_validate(
keep_usable_offers(parse_json_data(response))
)
if results.currency.upper() != "EUR":
raise ValueError(f"expected EUR, got {results.currency}")
verified = results.model_copy(
update={
"currency": results.currency.upper(),
"destination": destination,
"checkin": checkin,
"checkout": checkout,
"adults": adults,
"rooms": rooms,
"source_url": url,
"retrieved_at": datetime.now(timezone.utc),
}
)
return verified, {
"attempts": attempt + 1,
"javascript_retry": attempt == 1,
"elapsed_seconds": elapsed,
"errors_before_success": errors,
}
except (RuntimeError, ValidationError, ValueError, TypeError) as error:
errors.append(f"{type(error).__name__}: {error}")
if attempt == 1:
raise RuntimeError(
f"Extraction failed after two attempts: {errors}"
) from error
raise RuntimeError("unreachable")Extract one Rome hotel search
This search asks for one night in Rome for two adults. Dates, occupancy, currency, and locale are explicit in the URL and function arguments.
rome_url = (
"https://www.booking.com/searchresults.en-gb.html?ss=Rome%2C+Italy"
"&checkin=2026-09-15&checkout=2026-09-16&group_adults=2"
"&no_rooms=1&group_children=0&selected_currency=EUR"
)
sgai = ScrapeGraphAI()
rome, rome_meta = extract_hotels(
sgai,
url=rome_url,
destination="Rome, Italy",
checkin="2026-09-15",
checkout="2026-09-16",
)
print(rome.model_dump(mode="json"))
print(rome_meta)The normal attempt returned placeholder content and an empty offer list. Pydantic rejected it. The JavaScript retry validated five usable rows. One was A Trastevere da M.E. with a 9.5 score, 553 reviews, and a displayed price of € 190 at 15:56 UTC on July 29, 2026.
During the browser source check a few minutes later, the name, score, and review count still matched, but the displayed price was € 200. That is not a reason to correct the CSV after the fact. It is evidence that a price row is an observation tied to its retrieval time.
Track Rome and Florence and export CSV
Reuse the validated Rome result and run one Florence extraction. This keeps the example to two searches rather than calling Rome twice.
florence_url = (
"https://www.booking.com/searchresults.en-gb.html?ss=Florence%2C+Italy"
"&checkin=2026-09-15&checkout=2026-09-16&group_adults=2"
"&no_rooms=1&group_children=0&selected_currency=EUR"
)
florence, florence_meta = extract_hotels(
sgai,
url=florence_url,
destination="Florence, Italy",
checkin="2026-09-15",
checkout="2026-09-16",
)
searches = [rome, florence]The Florence normal attempt also failed required-field validation, then the JavaScript retry produced five usable rows. Across both destinations the run made four calls, used two JavaScript retries, and kept 10 rows.
Build the CSV from validated models. The assertions check destinations, required text, source ownership, retrieval timestamps, and a clean readback.
import pandas as pd
rows = []
for search in searches:
for offer in search.offers:
rows.append(
{
"destination": search.destination,
"checkin": search.checkin,
"checkout": search.checkout,
"adults": search.adults,
"rooms": search.rooms,
"currency": search.currency,
**offer.model_dump(),
"source_url": search.source_url,
"retrieved_at": search.retrieved_at.isoformat(),
}
)
frame = pd.DataFrame(rows)
csv_path = "booking_hotel_prices.csv"
frame.to_csv(csv_path, index=False)
assert len(frame) == 10
assert set(frame["destination"]) == {"Rome, Italy", "Florence, Italy"}
assert frame["property_name"].str.len().gt(0).all()
assert frame["displayed_price"].str.len().gt(0).all()
assert frame["source_url"].str.startswith("https://www.booking.com/").all()
assert frame["retrieved_at"].notna().all()
assert pd.read_csv(csv_path).shape == frame.shapeThe CSV is reproducible as a dated extraction, not a promise that the same properties or prices will appear on the next run. A price scraping workflow should retain old observations and append new ones instead of overwriting history.
What a valid schema can still get wrong
Pydantic validates structure. It does not know whether a nearby label was mapped to the right field.
The final Florence output contained a row whose property_name was Santo Spirito, Florence, while its property URL pointed to pontevecchio-relais-apartment. Santo Spirito is a neighborhood, not a property name. The row had the correct Python types and passed every shape check, yet its meaning was wrong.
That row should be quarantined for review, not silently corrected from a guess. A production pipeline can add semantic guards such as rejecting names that equal the location, flagging duplicate property URLs with different names, and comparing a sample against the source on every release.
Other hotel-specific traps include:
- a crossed-out price mistaken for the current price;
- nightly price confused with stay total;
- taxes attached to the wrong room;
- a review score read as a star rating;
- urgency text treated as guaranteed inventory;
- cancellation text detached from its deadline;
- a Genius or signed-in rate assumed to be public.
The answer is not a larger schema. Keep raw display text, validate semantics against the page, and reject ambiguous rows. The same discipline applies to any web scraping API.
Production checks and safe operating boundaries
Hotel prices move with dates, occupancy, currency, locale, device, promotions, and availability. Store every one of those inputs. If a later search changes guest count or currency, start a separate series rather than comparing unlike rows.
Run a small source sample after each parser, prompt, or schema change. Confirm property name, room type, price basis, taxes, review data, and property URL. Tests built only from old JSON can stay green while a live layout change moves the meaning to an adjacent label.
Schedule conservatively. A small destination set checked at a business-relevant interval is easier to defend and maintain than an attempt to mirror the site. Use a fixed retry limit, log the source failure, and stop on repeated challenges. Do not rotate identities to evade a refusal.
Separate failures in your logs. A timeout, consent screen, empty inventory result, access challenge, schema error, and semantic mismatch require different responses. Keep the last known good row rather than replacing it with an empty value. Alert a human when a source repeatedly fails.
Read Booking.com's current terms and robots rules before collection. Limit the dataset to what the project actually needs, set a retention period, and avoid personal or account data. The web scraping legality guide explains the broader questions, but it is not legal advice and does not grant permission.
Public extraction stops at the booking boundary. It cannot guarantee a room, lock a price, apply property policies, manage a reservation, or process payment. Those jobs belong to the approved Demand API, Connectivity APIs, or another booking-grade supplier.
Run it in Google Colab
The Booking.com public-web Colab contains the pinned installation, hidden key input, schemas, guarded retry helper, Rome and Florence searches, CSV assertions, download cell, semantic-review notes, and production limitations.
The notebook stores no credential and has no saved output. It uses getpass so the key is not echoed. Run the cells in order and review the live source before using any row downstream. The notebook is shared as anyone-with-link viewer and its anonymous download was verified after publication.
Does Booking.com have an API?
Yes. The Demand API is for approved travel affiliates, while Connectivity APIs support properties and connectivity providers. Neither is an unrestricted public API key that every developer can activate without a partner agreement.
Can I get a Booking.com API key for free?
Contracted Managed Affiliate Partners can receive sandbox credentials. The sandbox has test inventory and a documented 50-request-per-minute limit. Public documentation does not promise anonymous self-serve access or publish a universal production price.
Is scraping Booking.com legal?
Legality depends on the data, jurisdiction, access method, contract, and use. Review the site's terms and robots rules, avoid authenticated and personal data, collect only what you need, and get legal advice for a commercial project. A technical workflow is not permission.
How often should hotel prices be scraped?
Match frequency to the decision. A short-term rate monitor may need more frequent checks than a destination research dataset. Start small, measure volatility, respect access constraints, and never use retry pressure to force a blocked source.
Why did my Booking.com scraper return an empty result?
Common causes include JavaScript rendering, no inventory for the exact dates, a consent or challenge page, locale changes, and a layout update. Log the returned page type, validate required fields, try one justified rendering retry, and stop if the source still does not expose hotel cards.