๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now
Guides

How to Scrape Expedia Hotel Prices

Learn how to scrape Expedia hotel prices, rates, availability, and reviews: handle JS pricing, the per-night vs total trap, geo pricing, and PerimeterX.

S SparkProxy 8 21 min read
Share
How to Scrape Expedia Hotel Prices

To scrape Expedia hotel prices cleanly you have to beat four things a plain HTTP request never survives: a search page whose rates only appear after JavaScript hydrates, a lead price that is per-night-before-fees while the number a traveler actually pays hides on the detail page, prices that shift with the IP's country, the device, and your cookies, and PerimeterX, one of the strictest bot walls on the travel web. Miss any one of them and you ship a dataset that is empty, stale, or quietly wrong. This guide runs the full pipeline for public Expedia data (hotels, nightly rates, availability, and reviews) with working code against the SparkProxy Scraping API, and it keeps the whole job on the public, non-personal, Terms-of-Service-aware side of the line.

Key takeaways

  • Expedia rates hydrate with JavaScript, so render_js=true is required. A raw fetch returns a page shell with no prices.
  • The card price is the nightly rate, often before taxes and fees. The all-in total lives on the property page. Capture both or your numbers understate cost.
  • Currency follows the point-of-sale domain (expedia.com, expedia.co.uk, expedia.de), not a URL parameter. Match country_code to the site you scrape.
  • Expedia runs PerimeterX (now HUMAN Security) with a "Press & Hold" challenge. Residential IPs plus stealth=true plus human pacing get you through; datacenter IPs get flagged fast.
  • Anchor extraction on data-stid attributes, not CSS class names. Expedia rotates hashed classes on nearly every deploy but keeps its test IDs stable.
  • Collect only public, non-personal data, throttle, and respect robots.txt and the site's Terms.

What you can scrape: hotels, rates, availability, reviews

Every property card on a results page carries the same core fields. These are the ones worth targeting, plus the stable anchor to key on. Treat the selectors as starting points and confirm them in your browser's DevTools, because exact IDs shift over time.

FieldExample valueAnchor (starting point, verify in DevTools)Notes
Hotel name`The Standard, London``[data-stid="content-hotel-title"]`One per card.
Lead nightly price`$212``[data-stid="price-summary"]`The per-night rate, often pre-tax. See the price trap below.
Total / taxes line`$742 total`text inside `[data-stid="price-summary"]`Present only in some markets and toggle states.
Availability`Only 2 left` or no pricescarcity text, or absence of a rateNo rate for your dates means sold out.
Review score`9.0 Wonderful``[data-stid="content-hotel-reviewInfo"]`Out of 10; absent for unrated hotels.
Review count`1,204 reviews`next to the scoreSits beside the rating word.
Star rating`4.5 stars`star element / `aria-label`Property class, distinct from guest score.
Detail URL`/... .Hotel-Information``[data-stid="open-hotel-information"]` (href)Where the all-in total and reviews live.

Two of these you provide rather than extract. The check-in and check-out dates drive which rate returns, so record them next to every price. And "availability" is not a separate widget: if a property renders with no bookable rate for your dates, it is sold out for that stay. Capture the scarcity string (Only 2 left at this price) when it appears, because it tells you how thin the inventory is.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

How Expedia hotel-search URLs work

The results page is a GET request, and the parameters that shape the results live in the query string. You can build any search by assembling a URL, no form clicks required.

ParameterExampleWhat it controls
`destination``Paris, France`Free-text destination string.
`regionId``178276`Expedia's internal region ID. More precise than the text.
`startDate``2026-09-10`Check-in date, ISO `YYYY-MM-DD`.
`endDate``2026-09-12`Check-out date, ISO `YYYY-MM-DD`.
`rooms``1`Rooms requested.
`adults``2`Adults. Drives occupancy and the quoted rate.
`sort``PRICE_LOW_TO_HIGH`Sort order: `RECOMMENDED`, `PRICE_LOW_TO_HIGH`, `REVIEW`, `DISTANCE`.

A complete, buildable search URL looks like this:

https://www.expedia.com/Hotel-Search?destination=Paris%2C%20France&startDate=2026-09-10&endDate=2026-09-12&rooms=1&adults=2&sort=PRICE_LOW_TO_HIGH

Build it in code so you can loop over dates and destinations:

from urllib.parse import urlencode

def expedia_search_url(destination, start, end, adults=2, rooms=1,
                       sort="PRICE_LOW_TO_HIGH", pos="www.expedia.com"):
    params = {
        "destination": destination,
        "startDate": start,
        "endDate": end,
        "rooms": rooms,
        "adults": adults,
        "sort": sort,
    }
    return f"https://{pos}/Hotel-Search?" + urlencode(params)

print(expedia_search_url("Paris, France", "2026-09-10", "2026-09-12"))

Two things differ from other travel sites. Expedia has no selected_currency parameter: currency is bound to the point-of-sale domain, which is why pos is an argument above (more on that in the price trap). And the results do not paginate with an offset. Expedia lazy-loads more cards as you scroll and exposes a "Show more" control, so pulling the full list means scrolling the page rather than incrementing a page number. Capture the regionId once per destination and reuse it, since it disambiguates cities that share a name and returns a tighter set than the free-text string alone.

The per-night vs total-price trap

This is the mistake that silently corrupts hotel datasets, and it bites Expedia harder than most. The big number on a search card is the nightly rate, and depending on the market and the display toggle, it may exclude taxes, service fees, and resort fees. The amount a guest actually pays, taxes and mandatory fees included, resolves on the property page or at checkout. Two hotels can show the same $180 nightly card price and settle at $210 and $265 all-in because one adds a nightly resort fee and the other does not.

Store the card price as "the price" and you have built a dataset that mixes pre-fee and all-in numbers with no column telling you which is which. Every cross-hotel comparison after that is wrong.

The display is also a moving target right now, which is exactly why you cannot assume. Junk-fee regulation has pushed US lodging toward all-in pricing: California's SB 478 (the Honest Pricing Law) took effect on July 1, 2024 and requires advertised prices to include mandatory fees (California SB 478), and the FTC's Rule on Unfair or Deceptive Fees for hotels and live events took effect on May 12, 2025 (FTC announcement, Dec 2024). The upshot for a scraper: whether the lead price already includes fees now depends on the point-of-sale and the date, so you must detect it rather than guess.

The safe rule is to capture two numbers and label them:

import re

def parse_prices(card_text):
    """Pull the lead nightly rate and any total/taxes line separately."""
    nightly = re.search(r"\$[\d,]+", card_text)
    total = re.search(r"\$[\d,]+\s*total", card_text, re.IGNORECASE)
    includes_fees = "includes taxes" in card_text.lower() or bool(total)
    return {
        "nightly_price": nightly.group(0) if nightly else None,
        "total_price": total.group(0) if total else None,
        "fees_included": includes_fees,   # record which basis this row is on
    }

For a true all-in figure per property, follow the detail URL ([data-stid="open-hotel-information"]) and read the price summary on the room page, where taxes and resort fees are itemized. It costs one extra request per hotel, so reserve it for the properties you actually care about rather than the whole result set. The point stands either way: never treat the search-card number as the price without recording its basis.

Why Expedia is hard to scrape: PerimeterX and dynamic pricing

Two forces make Expedia harder than a generic e-commerce target.

PerimeterX bot defense. Expedia runs PerimeterX, now part of HUMAN Security. It drops cookies such as _px3, _pxvid, and pxhd, ships a sensor script that collects mouse, touch, and device telemetry, and scores each session. Fail the score and you get an HTTP 403 with the signature "Press & Hold" challenge, a button you must hold until the script clears you. A header-only requests.get() has no sensor data and no realistic fingerprint, so it is flagged on the first hit. A datacenter IP looping identical requests is flagged almost as fast. Getting through means a real browser (so the sensor script runs), a residential IP (so the network fingerprint looks like a person), and human-like pacing.

Dynamic pricing. The rate Expedia shows is not a fixed property of the hotel. It moves with several inputs:

  • Geography. The point-of-sale domain sets the currency and market, and the IP's country influences the rates and even which properties appear. Request expedia.co.uk from a UK IP, expedia.de from a German IP.
  • Device. Expedia runs mobile-only and app-only rates, so a mobile fingerprint can see different numbers than desktop.
  • Login and loyalty. Signed-in One Key members see "Member Prices" that logged-out visitors do not. For clean, comparable public data you want the logged-out price, which also keeps you on the public-data side of the ethics line.
  • Cookies and session. A long-lived session can land you in a personalization or A/B bucket, so the same URL returns a different number than a fresh session would.

The practical consequence: to collect stable, comparable public prices you use a clean session (fresh cookies), a consistent desktop fingerprint, a logged-out state, and a residential IP whose country matches the point-of-sale. Then you store the market and country next to every price. This geo-consistent approach is the same foundation behind datacenter proxies for price comparison websites, where a mismatch between IP location and requested market silently corrupts the comparison.

Wiring a browser, a residential pool, and stealth by hand with Playwright works, but it is a lot of moving parts to keep alive against a target that actively fights back. A managed Scraping API folds them into request parameters. If you are weighing the trade-off, this breakdown of a web scraping API versus self-managed proxies covers where each one wins. Expedia's sibling OTA has a different anti-bot stack and a different URL scheme; if you also track it, see how to scrape Booking.com hotel prices.

Scrape Expedia with the SparkProxy Scraping API

The SparkProxy Scraping API takes a target URL plus parameters, runs the page in a headless Chromium behind a proxy, and returns the rendered HTML. The base endpoint is https://scrape.sparkproxy.io/api/v1, and you authenticate with your key in the X-API-Key header. Sign up at app.sparkproxy.io for 1,000 free credits to test with.

Start with the minimum viable request: render the JavaScript and wait for the results section before capture.

import requests

API_KEY = "sk-your-key-here"
target = expedia_search_url("Paris, France", "2026-09-10", "2026-09-12")

resp = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": API_KEY},
    params={
        "url": target,
        "render_js": "true",                      # run the page's JavaScript
        "wait_for": '[data-stid="section-results"]',  # hold until cards exist
    },
    timeout=120,
)
print(resp.status_code, len(resp.text))

render_js=true costs 5 credits and gives you a hydrated page. wait_for holds the capture until the results container is in the DOM, so you never read the page before the prices land.

For a target as defended as Expedia, add a residential IP, pin the country to the point-of-sale, and turn on stealth. This is the request you will actually run in production:

resp = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": API_KEY},
    params={
        "url": target,
        "render_js": "true",
        "premium_proxy": "true",   # residential IP, much harder to block
        "country_code": "US",      # match the expedia.com point-of-sale
        "wait_for": '[data-stid="price-summary"]',
        "stealth": "true",         # extra fingerprint hardening for PerimeterX
    },
    timeout=120,
)

premium_proxy=true routes through the residential pool, which real ISP addresses make far harder to detect than datacenter ranges. With render_js, that request costs 25 credits, country_code adds 5, and stealth adds 5, so budget about 35 credits per fully hardened, geo-pinned request. stealth=true is the parameter that matters most against the PerimeterX sensor: it hardens the browser fingerprint and paces interaction so the "Press & Hold" wall stays down.

If a cookie or consent banner covers the results, dismiss it before capture with a small js_scenario:

import json

resp = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": API_KEY},
    params={
        "url": target,
        "render_js": "true",
        "premium_proxy": "true",
        "country_code": "US",
        "js_scenario": json.dumps({
            "instructions": [
                {"wait_for": '[data-stid="section-results"]'},
                {"click": '[data-stid="cookie-banner-accept"]'},
                {"wait": 800},
            ]
        }),
    },
    timeout=120,
)

Extract hotels, rates, and availability

Request the HTML and parse it with BeautifulSoup. Anchor on data-stid, iterate the cards, and pair each rate with the dates you searched:

from bs4 import BeautifulSoup

def parse_cards(html, start, end, pos):
    soup = BeautifulSoup(html, "html.parser")
    hotels = []
    for card in soup.select('[data-stid="lodging-card-responsive"]'):
        name = card.select_one('[data-stid="content-hotel-title"]')
        price = card.select_one('[data-stid="price-summary"]')
        score = card.select_one('[data-stid="content-hotel-reviewInfo"]')
        price_text = price.get_text(" ", strip=True) if price else ""
        hotels.append({
            "name": name.get_text(strip=True) if name else None,
            **parse_prices(price_text),                 # nightly + total + basis
            "available": bool(price_text),              # no rate means sold out
            "review_score": score.get_text(" ", strip=True) if score else None,
            "checkin": start,
            "checkout": end,
            "point_of_sale": pos,
        })
    return hotels

rows = parse_cards(resp.text, "2026-09-10", "2026-09-12", "www.expedia.com")

Here is the insight most tutorials miss: anchor on data-stid, never on class names. Expedia ships obfuscated, build-hashed CSS classes like uitk-card_._1a2b3c that change on nearly every deploy, so a scraper keyed on classes breaks within days. The data-stid attributes exist for Expedia's own automated tests and stay stable across deploys, which makes them the durable extraction target. When markup does shift, data-stid changes far less often and more predictably than a hashed class.

If you would rather hand the raw page to an LLM or a downstream parser, ask the API for Markdown instead of HTML by adding "format": "md", which strips the page to clean text and links. For structured JSON straight from the API, the object-based extraction options are documented in the parameter reference. Whichever route you take, keep the nightly_price, total_price, and fees_included fields separate. Collapsing them back into one column throws away the exact distinction the price trap section warns about.

Scrape Expedia reviews with pagination

Guest reviews live on the property page, not the search results, and they load in pages of roughly ten. The reviews hydrate through an XHR call after the page renders, and a "Next" control fetches each subsequent page, so you need render_js plus a js_scenario that clicks through. Sort is worth pinning: Most recent gives you a time series, Most relevant gives you the reviews Expedia surfaces first.

import json

def scrape_reviews(hotel_url, pages=3, country="US"):
    steps = [{"wait_for": '[data-stid="reviews-container"]'}]
    for _ in range(pages - 1):
        steps.append({"click": '[data-stid="reviews-pagination-next"]'})
        steps.append({"wait": 1200})   # let the next page hydrate
    resp = requests.get(
        "https://scrape.sparkproxy.io/api/v1",
        headers={"X-API-Key": API_KEY},
        params={
            "url": hotel_url,
            "render_js": "true",
            "premium_proxy": "true",
            "country_code": country,
            "js_scenario": json.dumps({"instructions": steps}),
        },
        timeout=180,
    )
    return resp.text

Parse the review cards for the fields that carry signal, and stop before you reach personal data:

def parse_reviews(html):
    soup = BeautifulSoup(html, "html.parser")
    out = []
    for item in soup.select('[data-stid="review-item"]'):
        score = item.select_one('[itemprop="ratingValue"], .review-score')
        body = item.select_one('[data-stid="review-text"]')
        out.append({
            "score": score.get_text(strip=True) if score else None,   # x / 10
            "text": body.get_text(" ", strip=True) if body else None,
            "trip_type": None,   # e.g. "Traveled with family", when present
            # deliberately NOT collecting reviewer name or profile
        })
    return out

Two habits keep reviews clean and lawful. Store the rating, the text, the trip type, and the stay date, and skip the reviewer's name and profile, which are personal data under GDPR and CCPA. And normalize the score to a number out of 10 at parse time so a later sentiment pass is not fighting strings like 9.0 Wonderful. The same pagination-plus-PII discipline applies to other review sources; our guide to scraping TripAdvisor reviews walks the offset mechanics and the GDPR framing in more depth.

Scale politely without getting blocked

Expedia results lazy-load, so "more results" means scrolling, not an offset. Drive the scroll with a js_scenario and let the cards fill in before capture:

def scrape_full_page(target, country="US", scrolls=4):
    steps = [{"wait_for": '[data-stid="section-results"]'}]
    for _ in range(scrolls):
        steps.append({"evaluate": "window.scrollTo(0, document.body.scrollHeight)"})
        steps.append({"wait": 1500})   # let the next batch of cards hydrate
    return requests.get(
        "https://scrape.sparkproxy.io/api/v1",
        headers={"X-API-Key": API_KEY},
        params={
            "url": target,
            "render_js": "true",
            "premium_proxy": "true",
            "country_code": country,
            "stealth": "true",
            "js_scenario": json.dumps({"instructions": steps}),
        },
        timeout=180,
    )

A few habits keep a large job sustainable and considerate of the target:

  • Space your requests. A short pause between pages, plus the API's own delays, keeps your footprint light. There is no prize for finishing a crawl in ten seconds.
  • Rotate identity per request. The premium pool hands you a fresh residential IP on each call, so no single address piles up a suspicious volume. If you run your own pool, the same discipline applies. Our guide on how to avoid getting your proxy blocked covers rotation, header hygiene, and challenge handling.
  • Retry with backoff, not brute force. On a 403 or a Press & Hold page, wait and rotate rather than hammering the same IP. Pounding a block turns a temporary flag into a durable one.
  • Cache what you already have. Rates for fixed dates do not change minute to minute. Re-scrape on a schedule that matches how fast the data actually moves, not as fast as your loop can run.
  • Keep the market with the price. Every row should carry its point-of-sale, country, currency, and price basis. Without those columns a multi-market pull is not comparable.

Frequently asked questions

FAQ

Scraping public, non-personal data is broadly permitted, and US courts have held that accessing publicly available web data does not by itself violate the CFAA. Stay on the safe side by collecting only public rate and availability data, avoiding anything behind a login, honoring robots.txt and Expedia's Terms of Use, and throttling your requests. For a customer-facing product, consult legal counsel or use Expedia Group's official Rapid partner API.

Because Expedia rates hydrate through JavaScript after the initial HTML loads, so a plain fetch returns a shell with no prices, and because Expedia runs PerimeterX bot defense that flags header-only and datacenter requests almost immediately. Setting render_js=true runs a real browser so the prices populate, and premium_proxy=true with stealth=true presents a residential fingerprint that clears the "Press & Hold" challenge.

Because Expedia's price is dynamic and the card number is usually the nightly rate before taxes and fees. Displayed rates move with the point-of-sale domain, the IP's country, the device, your cookies, and whether you are a signed-in One Key member seeing Member Prices. For comparable public data, scrape logged out with a clean session and a residential IP whose country matches the site, and capture the all-in total from the property page.

Reviews sit on the property page and load about ten at a time through an XHR call, so render the page and use a js_scenario that clicks the "Next" control once per additional page, waiting a second or so between clicks for each page to hydrate. Parse the rating, review text, trip type, and stay date, and deliberately skip reviewer names and profiles, which are personal data under GDPR and CCPA.

Currency follows the point-of-sale domain rather than a URL parameter, so scrape the regional Expedia site you want (expedia.com for USD, expedia.co.uk for GBP, expedia.de for EUR) and set the Scraping API country_code to match. A mismatch between the site and the IP's country can return partially localized or inconsistent pricing, so store the point-of-sale and country next to every rate.

Use residential IPs via premium_proxy=true, add stealth=true, rotate the IP on every request, space requests a couple of seconds apart, and retry with backoff instead of repeating a blocked call. PerimeterX scores behavior and fingerprint, so a slow, varied, residential, fully rendered request pattern gets through where a fast datacenter loop triggers the hold-to-verify wall.

Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

This guide was written by the SparkProxy Technical Team. SparkProxy builds proxy and web-scraping infrastructure used for price monitoring, travel data scraping, market research, and large-scale data collection. Our products include datacenter proxies, residential proxies, and a managed Scraping API that handles JavaScript rendering, geo-targeting, and anti-bot evasion behind a single request. We write these guides from production experience running collection jobs against exactly the kind of defended, JavaScript-heavy travel targets covered here. Full API documentation lives at https://www.sparkproxy.io/docs/scraping-api/.

Keep reading

Related articles