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.

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=trueis 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. Matchcountry_codeto the site you scrape.- Expedia runs PerimeterX (now HUMAN Security) with a "Press & Hold" challenge. Residential IPs plus
stealth=trueplus human pacing get you through; datacenter IPs get flagged fast.- Anchor extraction on
data-stidattributes, 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.
Is it legal and ethical to scrape Expedia?
Sort the framing out before you write code, because "the prices are public" only answers half the question.
In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (9th Cir. 2022) held that scraping data anyone can view without logging in generally does not violate the Computer Fraud and Abuse Act (EFF summary, 2022). That decision is about unauthorized access, not a blanket license. Expedia's Terms of Use separately restrict automated collection, so scraping can be a breach of contract even where it is not a CFAA violation. Those are two different legal questions, and only one was settled by hiQ.
Keep an Expedia scraper on defensible ground:
- Public data only. Search results and property pages are public. Anything behind a login, a booking flow, or a saved-traveler profile is off limits.
- No personal data. Collect the hotel, the rate, availability, and the aggregate review score. Reviewer names and profile details are personal data that pull you into GDPR and CCPA territory, so leave them out.
- Respect robots.txt and the Terms. Read
https://www.expedia.com/robots.txt, honor its disallow rules, and use the data for price intelligence, market research, and academic study rather than republishing inventory. - Throttle. A few requests per minute spread across rotating IPs looks nothing like an attack, and it is the single easiest way to stay welcome.
Building a customer-facing product on this data is a different risk profile. For that, talk to legal counsel or use Expedia Group's official Rapid (partner) API. Scraping is the right tool for internal rate monitoring and research, not for cloning a competitor's catalog. The same public-data, non-personal discipline runs through our guide on travel fare aggregation with proxies.
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.
| Field | Example value | Anchor (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 price | scarcity text, or absence of a rate | No 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 score | Sits 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.
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.
| Parameter | Example | What 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.ukfrom a UK IP,expedia.defrom 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.
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
Related articles

How to Scrape Mercado Libre Product Data (API First)
Scrape Mercado Libre product data properly: start with the api.mercadolibre.com REST API, then fill the gaps by site ID, currency, shipping and language.

How to Scrape Kayak Flight Prices Without Losing Fares
Scrape Kayak flight prices correctly: handle progressive metasearch results, poll for search completion, and pull a full fare set with the SparkProxy API.

How to Scrape IndiaMART Supplier Data: B2B Market Intel
Scrape IndiaMART supplier data for B2B market research: parse lakh and crore prices, MOQ and units, map supplier geography, and stay inside DPDP Act limits.
