     [Blog](https://scrapfly.io/blog)   /  [python](https://scrapfly.io/blog/tag/python)   /  [How to Scrape Expedia Flights and Hotels (2026)](https://scrapfly.io/blog/posts/expedia-scraper)   # How to Scrape Expedia Flights and Hotels (2026)

 by [Mohab Yousry](https://scrapfly.io/blog/author/mohab-yousry-9396552a) Oct 30, 2026 21 min read [\#python](https://scrapfly.io/blog/tag/python) [\#scrapeguide](https://scrapfly.io/blog/tag/scrapeguide) 

 [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fexpedia-scraper "Share on LinkedIn") [  ](https://x.com/intent/tweet?url=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fexpedia-scraper&text=How%20to%20Scrape%20Expedia%20Flights%20and%20Hotels%20%282026%29 "Share on X") [  ](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fexpedia-scraper "Share on Facebook")    

 

 

Summarize this article with

 [  ](https://chat.openai.com/?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fexpedia-scraper) [  ](https://claude.ai/new?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fexpedia-scraper) [  ](https://x.com/i/grok?text=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fexpedia-scraper) [  ](https://www.perplexity.ai/search/new?q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fexpedia-scraper) [  ](https://www.google.com/search?udm=50&aep=11&q=Summarize%20this%20article%20and%20explain%20how%20Scrapfly%20helps%20me%20scrape%20any%20website%20at%20scale%20and%20bypass%20anti-bot%20systems%20for%20my%20use%20case%3A%20https%3A%2F%2Fscrapfly.io%2Fblog%2Fposts%2Fexpedia-scraper) 



         

Expedia is a platform that makes travelling easier. You can find plane tickets, rooms and everything you will need for your trip. Sending a direct request to it returns a CAPTCHA or a challenge page, so let's see how we can overcome that.

In this guide we will go through [Scrapfly Expedia scraper](https://github.com/scrapfly/scrapfly-scrapers/tree/main/expedia-scraper) understanding how it works and its setup.



[**Expedia Scraper**github.com/scrapfly/scrapfly-scrapers/tree/main/expedia-scraper](https://github.com/scrapfly/scrapfly-scrapers/tree/main/expedia-scraper)

## Key Takeaways

- The scraper does not parse results cards, it captures the target GraphQL request and parses its JSON response instead.
- Only the first request renders the search page; the rest replay the captured request.
- The scraper looks for two query names when searching for hotels: `PropertyListingQuery` or `RemainderListings`, for flights it looks for: `FlightsSearchResultsLoadedQuery`.
- For pagination the scraper replays the captured GraphQL request with the original session and headers then `build_next_page_config()` changes the pagination variables and switches off JavaScript rendering.
- The scraper allows duplicate records. If you need a unique list, add a deduplication step.

**Get web scraping tips in your inbox**Trusted by 100K+ developers and 30K+ enterprises. Unsubscribe anytime.







## What Expedia Hotel and Flight Data Can You Scrape?

The scraper here gives you two records: Hotel search records and flight search records. Some of them are extracted from the GraphQL response and some are taken from the search inputs that you pass in.

The fields are in a TypedDict format so that it's easier to pass it directly to `json.dumps()` without any extra steps. It may return duplicates as a snapshot we ran returned 297 rows for a hotels scrape having 286 unique `hotel_id` and 24 flights with 19 unique records.

Let's take a look at both dictionaries then dive deeper into each one:



python

```python
class Hotel(TypedDict):
    hotel_id: Optional[str]
    name: Optional[str]
    url: Optional[str]
    nightly_price: Optional[str]
    total_price: Optional[str]
    star_rating: Optional[float]
    review_score: Optional[float]
    review_count: Optional[int]
    location: Optional[str]
    market_country: Optional[str]
    captured_at: str


class Flight(TypedDict):
    origin: str
    destination: str
    airline: Optional[str]
    price: Optional[str]
    departure_time: Optional[str]
    arrival_time: Optional[str]
    duration: Optional[str]
    stops: Optional[str]
    cabin_class: str
    captured_at: str

```



### `Hotel` TypedDict

| Field | Type in repository | How the parser obtains it |
|---|---|---|
| `hotel_id` | `Optional[str]` | `LodgingCard.id` |
| `name` | `Optional[str]` | `headingSection.heading` |
| `url` | `Optional[str]` | `cardLink.resource.value` |
| `nightly_price` | `Optional[str]` | display line containing `nightly` |
| `total_price` | `Optional[str]` | display line containing `$` |
| `star_rating` | `Optional[float]` | analytics result keyed by hotel ID |
| `review_score` | `Optional[float]` | review graphic text converted to float |
| `review_count` | `Optional[int]` | digits parsed from review summary text |
| `location` | `Optional[str]` | first heading message text |
| `market_country` | `Optional[str]` | final comma-separated part of destination region name |
| `captured_at` | `str` | one UTC timestamp created for the scrape |

Take care that `total_price` here looks for a line having "$" in it, so prices in other currencies end up as `None`.

One example record from the hotels JSON file you will find in the results folder looks like:



json

```json
{
  "hotel_id": "17117062",
  "name": "PUBLIC, an Ian Schrager hotel",
  "nightly_price": "$496 nightly",
  "total_price": "$4,377 total",
  "star_rating": 4.0,
  "review_score": 8.8,
  "review_count": 3839,
  "location": "New York",
  "market_country": "United States of America",
  "captured_at": "2026-07-22T18:49:32.299978+00:00"
}
```



### `Flight` TypedDict

| Field | Type in repository | How the parser obtains it |
|---|---|---|
| `origin` | `str` | uppercased function input |
| `destination` | `str` | uppercased function input |
| `airline` | `Optional[str]` | final airline text item in the secondary section |
| `price` | `Optional[str]` | last populated `price.text` found in price-display rows |
| `departure_time` | `Optional[str]` | timeline start primary text |
| `arrival_time` | `Optional[str]` | timeline end primary text |
| `duration` | `Optional[str]` | text before the first `•` separator |
| `stops` | `Optional[str]` | normalized to `Nonstop`, `1 stop`, or plural stops |
| `cabin_class` | `str` | function input, default `economy`; not extracted from GraphQL |
| `captured_at` | `str` | one UTC timestamp created for the scrape |

Here is also an example for flights json file present at the same results folder:



json

```json
{
  "origin": "JFK",
  "destination": "LAX",
  "airline": "American Airlines operated by REPUBLIC AIRWAYS AS AMERICAN EAGLE and American Airlines",
  "price": "$1,226",
  "departure_time": "10:10pm",
  "arrival_time": "9:27am",
  "duration": "14h 17m",
  "stops": "1 stop",
  "cabin_class": "economy",
  "captured_at": "2026-07-22T18:50:12.531588+00:00"
}
```



## Project Setup

To run this scraper make sure you have Python `3.10`, Poetry, loguru `^0.7.0` and [scrapfly-sdk](https://scrapfly.io/docs/sdk/python) `^0.8.5` all installed. Now let's go through running it.

First clone the repo on your machine and open it:

shell

```shell
git clone https://github.com/scrapfly/scrapfly-scrapers.git
cd scrapfly-scrapers/expedia-scraper
```



After cloning, install the dependencies with Poetry and set up your API key:

shell

```shell
poetry install
export SCRAPFLY_KEY="YOUR SCRAPFLY KEY"
```



Now everything should be ready, so run your scraper:

shell

```shell
poetry run python run.py
```



You will find the records in the results folder inside two JSON files: hotels.json and flights.json

There are also some tests you could run to make sure that the scraper is working properly as Expedia can change its responses. Start by installing the required tools as pytest then run it using:

shell

```shell
poetry install --with dev
poetry run pytest test.py
```





## How Does the Python Expedia Scraper Capture GraphQL Data?

By thinking out of the box. Expedia does not load the data immediately. It sends a background (XHR) request to its GraphQL endpoint to get the data in JSON then it starts rendering the cards on the page. Our scraper here looks for that request and takes the JSON response instead of scraping each card on the page.

Here's the configuration used in our scraper:



python

```python
BASE_CONFIG = {
    "asp": True,
    "render_js": True,
    "proxy_pool": "public_residential_pool",
}

GRAPHQL_URL = "https://www.expedia.com/graphql"
PAGE_SIZE = 100
    
```



Everything starts from this config: `"asp": True` turns on [Unblocker (previously ASP)](https://scrapfly.io/products/unblocker), `"render_js": True`, uses a real browser to run the page and `"proxy_pool": "public_residential_pool"` uses residential IPs.

### Render Expedia and Wait for GraphQL XHR

We start by opening a browser and then wait for the GraphQL request we're looking for.



python

```python
session = f"expedia-{uuid.uuid4().hex}"
captured_at = datetime.now(timezone.utc).isoformat()

search_response = await SCRAPFLY.async_scrape(ScrapeConfig(
    url=build_hotel_search_url(destination, check_in, check_out, adults),
    session=session,
    wait_for_selector="xhr:graphql",
    rendering_wait="5000",
    auto_scroll=True,
    **BASE_CONFIG,
))
xhr_calls = search_response.scrape_result["browser_data"]["xhr_call"]
captured_call = _find_graphql_call(xhr_calls, "PropertyListingQuery")
```



Let's understand this function more starting by `wait_for_selector="xhr:graphql"` which tells scrapfly to wait for the graphql request, `rendering_wait="5000"` adds an additional 5 seconds to wait for the page. `auto_scroll=True` which scrolls the page to load its content. `session` keeps the session name to use in follow-up requests also.

Same thing with flights scraper with the difference in the URL builder and the query name.

### Select the Expedia GraphQL Query With `_find_graphql_call`

This function goes through all the requests until it finds the targeted one that is carrying the data.



python

```python
def _find_graphql_call(xhr_calls: List[Dict], query_name: str) -> Dict:
    """find a GraphQL XHR call by query name in captured search-page traffic"""
    for call in xhr_calls:
        if GRAPHQL_URL in call["url"] and query_name in (call.get("body") or ""):
            return call
    raise ValueError(f"could not find {query_name} call in captured browser traffic")

```



What exactly is it looking for? The target query name for hotels scrape is `PropertyListingQuery` and for flights it is `FlightsSearchResultsLoadedQuery`.

### Reuse the Expedia GraphQL Request for Pagination

We need to run in a browser in the first page only. After that this function replays the captured request for us.



python

```python
def build_next_page_config(
    captured_call: Dict,
    session: str,
    next_start_index: int,
    page_size: int,
    search_type: Literal["hotel", "flight"] = "hotel",
) -> ScrapeConfig:
    """build ScrapeConfig for the next GraphQL page reusing session and captured headers"""
    payload = json.loads(captured_call["body"])
    if search_type == "hotel":
        # RemainderListings sends the query as a batch of one
        variables = (payload[0] if isinstance(payload, list) else payload)["variables"]
        for count in variables["criteria"]["secondary"]["counts"]:
            if count["id"] == "resultsStartingIndex":
                count["value"] = next_start_index
            elif count["id"] == "resultsSize":
                count["value"] = page_size
    else:
        payload[0]["variables"]["searchPagination"] = {"size": page_size, "startingIndex": next_start_index}
    return ScrapeConfig(
        GRAPHQL_URL,
        method="POST",
        body=json.dumps(payload),
        headers=captured_call["headers"],
        session=session,
        **{**BASE_CONFIG, "render_js": False},
    )
```



The function above uses the same configuration to talk with the GraphQL endpoint, without the `render_js` option, as it is no longer needed: the replayed request is a plain HTTP request, and its JSON comes from `response.content`.



## How Do You Scrape Expedia Hotel Search Results?

We use `scrape_hotel_search` function for that. It takes the details of your stay, builds a search URL with it. As we mentioned earlier it captures the request with `PropertyListingQuery` and parses the `LodgingCard` results then replays that request to get the next batch of hotels changing only the starting point in the request.

Let's go through an example call to understand it in detail.



python

```python
hotels = await scrape_hotel_search("New York", "2026-11-20", "2026-11-23")
```



### Build an Expedia Hotel Search URL

The stay details are provided to the `build_hotel_search_url` function as parameters, so it can build the URL directly.



python

```python
def build_hotel_search_url(destination: str, check_in: str, check_out: str, adults: int = 2) -> str:
    return "https://www.expedia.com/Hotel-Search?" + urlencode({
        "destination": destination,
        "startDate": check_in,
        "endDate": check_out,
        "adults": adults,
    })


print(build_hotel_search_url("New York", "2026-11-20", "2026-11-23", 2))
```



This prints a URL like:

text

```text
https://www.expedia.com/Hotel-Search?destination=New+York&startDate=2026-11-20&endDate=2026-11-23&adults=2
```



We do not need to provide a page parameter here. It is only used in the GraphQL request, that is why we replay the whole request.

### Capture Expedia `PropertyListingQuery` Data

We look for the listings in the captured request.



python

```python
xhr_calls = search_response.scrape_result["browser_data"]["xhr_call"]
try:
    captured_call = _find_graphql_call(xhr_calls, "PropertyListingQuery")
except ValueError:
    # some search pages load the listings with a batched RemainderListings query instead
    captured_call = _find_graphql_call(xhr_calls, "RemainderListings")

page_data = _unbatch(json.loads(captured_call["response"]["body"]))
hotels = parse_hotels(page_data, captured_at)
```



As we discussed, this part looks for `PropertyListingQuery` to capture the hotel listings, and falls back to `RemainderListings` when it cannot find it.

We create the `captured_at` value with `datetime.now(timezone.utc).isoformat()` and give it to each record as a timestamp to make grouping easier.

### Parse Expedia `LodgingCard` Results With `parse_hotels()`

It takes the whole page returned from the request and turns it into a list of records.



python

```python
def parse_hotels(page_data: Dict, captured_at: str) -> List[Hotel]:
    """parse LodgingCard listings from a PropertyListingQuery response"""
    search = page_data["data"]["propertySearch"]
    region = search.get("criteria", {}).get("primary", {}).get("destination", {}).get("regionName", "")
    market_country = region.rsplit(", ", 1)[-1] if region else None

    analytics = (page_data.get("extensions") or {}).get("analytics") or [{}]
    hotel_results = (
        analytics[0].get("tealiumUtagData", {}).get("entity", {}).get("hotels", {}).get("results", {}).get("results") or []
    )
    star_ratings = {
        str(item["hotelId"]): item.get("starRating")
        for item in hotel_results
        if isinstance(item, dict) and "hotelId" in item
    }

    hotels = []
    for listing in search.get("propertySearchListings", []):
        if listing.get("__typename") != "LodgingCard":
            continue

        hotel_id = listing.get("id")
        heading = listing.get("headingSection") or {}
        summary = (listing.get("summarySections") or [{}])[0] or {}
        review = summary.get("reviewSummary") or {}
        review_text = (review.get("graphic") or {}).get("text")
        subtexts = review.get("subtexts") or [{}]
        review_count_text = ((subtexts[0] or {}).get("shoppingProductTitle") or {}).get("text", "")

        nightly_price = total_price = None
        for message in (listing.get("priceSection") or {}).get("priceSummary", {}).get("displayMessages") or []:
            for item in message.get("lineItems") or []:
                value = item.get("value") or (item.get("price") or {}).get("formatted")
                if not value:
                    continue
                if "nightly" in value.lower():
                    nightly_price = value
                elif "$" in value:
                    total_price = value

        messages = heading.get("messages") or [{}]
        hotels.append({
            "hotel_id": hotel_id,
            "name": heading.get("heading"),
            "url": ((listing.get("cardLink") or {}).get("resource") or {}).get("value"),
            "nightly_price": nightly_price,
            "total_price": total_price,
            "star_rating": star_ratings.get(hotel_id),
            "review_score": float(review_text) if review_text else None,
            "review_count": int(re.sub(r"\D", "", review_count_text)) if review_count_text else None,
            "location": (messages[0] or {}).get("text"),
            "market_country": market_country,
            "captured_at": captured_at,
        })
    return hotels
```



Let's go through how this function works. The listings and the search criteria are found in `page_data["data"]["propertySearch"]`, so it starts by opening it and then it derives `market_country` from the region. It then gets the star rating for each hotel, using the analytics block named `extensions.analytics` to build that rating lookup. It keeps the listings with `__typename` `LodgingCard` as there are other items that are not important to us.

The listings are read with `or {}` or `or [{}]` so that we don't hit an exception if there are any empty values.

If you are interested in hotel data, see also this guide here:

[How to Scrape Booking.com (2026 Update)Tutorial on how to scrape booking.com hotel and pricing data using Python. How to avoid blocking to web scrape data at scale and other tips.](https://scrapfly.io/blog/posts/how-to-scrape-bookingcom)

### Paginate Expedia Hotels With `build_next_page_config()`

The first page can hold 100 hotels. The function here checks if there are more.



python

```python
def _has_next_page(page_data: Dict | List[Dict], search_type: Literal["hotel", "flight"] = "hotel") -> bool:
    """check whether the server indicates more results are available"""
    if search_type == "hotel":
        pagination = page_data["data"]["propertySearch"].get("pagination") or {}
        return bool((pagination.get("subSets") or {}).get("nextSubSet"))
    return bool(page_data[0]["data"]["flightsSearch"]["listingResult"].get("moreListingsAvailable"))
```



This returns `True` if there is another page. It checks if `data.propertySearch.pagination.subSets.nextSubSet` exists. Here is the loop from `scrape_hotel_search()` that acts depending on the answer.



python

```python
page = 1
next_start_index = PAGE_SIZE
while _has_next_page(page_data, "hotel") and page < max_pages:
    response = await SCRAPFLY.async_scrape(build_next_page_config(
        captured_call, session, next_start_index, PAGE_SIZE, "hotel"
    ))
    page_data = _unbatch(json.loads(response.content))
    hotels.extend(parse_hotels(page_data, captured_at))
    page += 1
    log.info(f"expedia: fetched page {page}/{max_pages} (startingIndex={next_start_index})")
    next_start_index += PAGE_SIZE

return hotels
```



Scrapfly

#### Scale your web scraping effortlessly

Scrapfly handles proxies, browsers, and anti-bot bypass — so you can focus on data.

[Try Free →](https://scrapfly.io/register)It starts with `next_start_index = PAGE_SIZE`, which is set at the beginning of our scraper to 100, so the second request starts right after the first 100 results. According to this loop, some values change in the `build_next_page_config()`. `resultsStartingIndex` becomes the next offset and also `resultsSize` determines the page size.



python

```python
if search_type == "hotel":
    # RemainderListings sends the query as a batch of one
    variables = (payload[0] if isinstance(payload, list) else payload)["variables"]
    for count in variables["criteria"]["secondary"]["counts"]:
        if count["id"] == "resultsStartingIndex":
            count["value"] = next_start_index
        elif count["id"] == "resultsSize":
            count["value"] = page_size
```



The same flow is used between both hotels and flights, but flights have a different query, parser and a pagination variable.

## How Do You Scrape Expedia Flight Search Results?

By calling `scrape_flight_search`, which works the same way as the hotels one. It builds the flight search URL, captures `FlightsSearchResultsLoadedQuery` and parses each `FlightsStandardOffer` into a `Flight` record. Then, if there are more listings, it replays the request.

Here is an example call:



python

```python
flights = await scrape_flight_search("JFK", "LAX", "2026-11-20", return_date="2026-11-23")

```



### Build an Expedia Flight Search URL

It works the same way as in the hotels section, but here it needs a bit more formatting.



python

```python
def build_flight_search_url(
    origin: str, destination: str, departure_date: str, return_date: Optional[str] = None, adults: int = 1
) -> str:
    origin, destination = origin.upper(), destination.upper()
    departure = datetime.strptime(departure_date, "%Y-%m-%d").strftime("%m/%d/%Y")
    params = {
        "leg1": f"from:{origin},to:{destination},departure:{departure}TANYT",
        "passengers": f"adults:{adults}",
        "trip": "oneway",
        "mode": "search",
    }
    if return_date:
        arrival = datetime.strptime(return_date, "%Y-%m-%d").strftime("%m/%d/%Y")
        params["leg2"] = f"from:{destination},to:{origin},departure:{arrival}TANYT"
        params["trip"] = "roundtrip"
    return "https://www.expedia.com/Flights-Search?" + urlencode(params, safe=":,")

print(build_flight_search_url("jfk", "lax", "2026-11-20", "2026-11-23"))
```



The URL this example prints looks like this.

text

```text
https://www.expedia.com/Flights-Search?leg1=from:JFK,to:LAX,departure:11%2F20%2F2026TANYT&passengers=adults:1&trip=roundtrip&mode=search&leg2=from:LAX,to:JFK,departure:11%2F23%2F2026TANYT
```



I mentioned that it does more formatting, so let's walk through what it does exactly.



Multi-city trip search is not supported here.

### Capture Expedia `FlightsSearchResultsLoadedQuery` Data

We use the same configuration as the hotels one here. Only the URL and the query name are different.



python

```python
session = f"expedia-{uuid.uuid4().hex}"
captured_at = datetime.now(timezone.utc).isoformat()
origin, destination = origin.upper(), destination.upper()

search_response = await SCRAPFLY.async_scrape(ScrapeConfig(
    url=build_flight_search_url(origin, destination, departure_date, return_date, adults),
    session=session,
    auto_scroll=True,
    wait_for_selector="xhr:graphql",
    rendering_wait="5000",
    **BASE_CONFIG,
))
captured_call = _find_graphql_call(
    search_response.scrape_result["browser_data"]["xhr_call"], "FlightsSearchResultsLoadedQuery"
)

page_data = json.loads(captured_call["response"]["body"])
flights = parse_flights(page_data, origin, destination, cabin_class, captured_at)
```



There are two things to notice in this part. We get the flight response as a list instead of an object like the hotels. Here `FlightsSearchResultsLoadedQuery` is the query name. If it is not found, the scrape fails.

### Parse Expedia `FlightsStandardOffer` Results With `parse_flights()`

Let's take a look at `parse_flights()` and see what it does with the response.



python

```python
def parse_flights(page_data: List[Dict], origin: str, destination: str, cabin_class: str, captured_at: str) -> List[Flight]:
    """parse FlightsStandardOffer listings from a FlightsSearchResultsLoadedQuery response"""
    listing_result = page_data[0]["data"]["flightsSearch"]["listingResult"]

    flights = []
    for listing in listing_result.get("listings", []):
        if listing.get("__typename") != "FlightsStandardOffer":
            continue

        content = listing.get("flightsShoppingOfferContent") or {}
        secondary = content.get("secondarySection") or []
        timeline = (secondary[0] or {}) if secondary else {}
        stops_count = timeline.get("stops")
        if stops_count is None:
            stops = None
        elif stops_count == 0:
            stops = "Nonstop"
        else:
            stops = f"{stops_count} stop" + ("s" if stops_count > 1 else "")

        airline_contents = ((secondary[1] or {}).get("contents") or []) if len(secondary) > 1 else []
        airline = airline_contents[-1].get("text") if airline_contents else None

        tertiary = content.get("tertiarySection") or []
        duration_contents = ((tertiary[0] or {}).get("contents") or []) if tertiary else []
        duration_text = duration_contents[0].get("text") if duration_contents else None
        duration = duration_text.split(" • ")[0] if duration_text else None

        price = None
        for row in (listing.get("priceDisplay") or {}).get("rows") or []:
            for element in row.get("elements") or []:
                price_text = (element.get("price") or {}).get("text")
                if price_text:
                    price = price_text

        flights.append({
            "origin": origin,
            "destination": destination,
            "airline": airline,
            "price": price,
            "departure_time": (timeline.get("start") or {}).get("primary"),
            "arrival_time": (timeline.get("end") or {}).get("primary"),
            "duration": duration,
            "stops": stops,
            "cabin_class": cabin_class,
            "captured_at": captured_at,
        })
    return flights
```



### Paginate Expedia Flights With `searchPagination`

Here we check `listingResult.moreListingsAvailable`. If it's true, then there are more flights. Flights page `next_start_index` starts from however many results the first page gave us.



python

```python
page = 1
next_start_index = len(flights)
while _has_next_page(page_data, "flight") and page < max_pages:
    response = await SCRAPFLY.async_scrape(build_next_page_config(
        captured_call, session, next_start_index, PAGE_SIZE, "flight"
    ))
    page_data = json.loads(response.content)
    flights.extend(parse_flights(page_data, origin, destination, cabin_class, captured_at))
    page += 1
    log.info(f"expedia: fetched flight page {page}/{max_pages} (startingIndex={next_start_index})")
    next_start_index += PAGE_SIZE

return flights
```



As for `build_next_page_config()` we have just one line that updates `searchPagination` with the page size and where the next page starts.



python

```python
else:
    payload[0]["variables"]["searchPagination"] = {"size": page_size, "startingIndex": next_start_index}
```



We met some duplicates along the way, so if you need unique flights add a deduplication step.



## How Does Scrapfly Unblock Expedia Requests?

Scrapfly Unblocker `asp=True` and the residential proxy `"proxy_pool": "public_residential_pool"` are being used in every request. Without them Expedia will not respond with the data we're looking for.

We sent a request in September 2026 and Expedia responded with a 429 and a CAPTCHA page. The anti-bot it uses can't be named as the signals returned are tied to many providers, so we have to use `render_js=True`, `auto_scroll=True` and the `xhr:graphql` wait on our first request. Then all the following requests talk directly to the captured GraphQL endpoint with no need for rendering.

To understand more about each of these configurations, see [Scrapfly Unblocker docs](https://scrapfly.io/docs/scrape-api/unblocker) and [Web Scraping API](https://scrapfly.io/products/web-scraping-api).



### Recognize an Expedia Block Before Parsing

Check for these four failure cases before parsing:

- **HTTP 429 challenge.**
- **Missing named XHR.**
- **Changed GraphQL shape.**
- **Partial optional data.**



Check the response before parsing: detect CAPTCHA challenges, missing GraphQL requests, schema changes, and optional null fields so each failure is handled at the correct stage.### Keep the Expedia Browser Request and GraphQL Requests in One Session

In each scrape function, `scrape_hotel_search()` and `scrape_flight_search()`, a unique session is opened. That same session is used in all the requests built by `build_next_page_config()`, keeping the same IP and cookies to imitate a normal user searching for hotel or flight results.

To learn more about getting past anti-bots, see the guide below.

[How to Bypass Anti-Bot Protection When Web ScrapingLearn how anti-bot systems detect scrapers and 5 universal bypass techniques including proxy rotation, fingerprinting, and fortified headless browsers.](https://scrapfly.io/blog/posts/how-to-bypass-anti-bot-protection-when-web-scraping)



## Does Expedia Have an Official API?

Yes. Expedia Group offers several official partner APIs, and two of them cover hotels and flights: Travel Redirect API and Rapid API. You will need to get your account reviewed and approved to use them and also these are built for partners who show Expedia's hotels and flights to travelers and send them on to book.

As of September 28, 2026, new Travel Redirect API partner applications are paused according to their [getting-started page](https://developers.expediagroup.com/travel-redirect-api/api/start-guide/getting-started). Now let's go through each of them.

### Expedia Travel Redirect Listings APIs

[Travel redirect API](https://developers.expediagroup.com/travel-redirect-api/api) contains two endpoints. The [Lodging Listings API](https://developers.expediagroup.com/travel-redirect-api/api/shopping-apis/lodging-listings) to search for hotels and the [Flight Listings API](https://developers.expediagroup.com/travel-redirect-api/api/shopping-apis/flight-listings) for flights. They both need authorization and they redirect to complete the booking on Expedia website.

### Expedia Rapid API

[Rapid API](https://developers.expediagroup.com/rapid) is built to make your own booking experience. It doesn't take you back to Expedia to complete the booking.



## FAQ

Can You Scrape Expedia Without Parsing the DOM?Yes, the scraper only renders the search page, then captures the GraphQL request and talks directly with it after that, without rendering the results page in a browser.







Why Does a Direct Expedia Request Return HTTP 429?Because Expedia can return a CAPTCHA challenge page. Using the Unblocker with a public residential proxy solves this issue.







Does the Expedia Scraper Support Round-Trip Flights?Yes, just add a return date and `build_flight_search_url()` will switch the trip from `oneway` to `roundtrip`.







Does the Expedia Scraper Remove Duplicate Hotels or Flights?No, it does not remove duplicates. If you need unique results, build a deduplication step yourself.







Is the Expedia Scraper Code Available on GitHub?Yes, you can find it in [Expedia Scraper](https://github.com/scrapfly/scrapfly-scrapers/tree/main/expedia-scraper)









## Summary

In this guide we went through the [Scrapfly Expedia Scraper](https://github.com/scrapfly/scrapfly-scrapers/tree/main/expedia-scraper) understanding its flow from building the search URL, rendering it only the first time using Scrapfly, capturing the targeted graphql request, parsing it and replaying the request when needed. We also highlighted some important notes, such as adding a deduplication step if needed and the differences between the hotels and flights scraping flows.



### Web Scraping API

Scrape any website with our powerful API. Anti-bot bypass, JavaScript rendering, and rotating proxies built-in.



[Try Web Scraping API](https://scrapfly.io/docs/scrape-api/getting-started)



Legal Disclaimer and PrecautionsThis tutorial covers popular web scraping techniques for education. Interacting with public servers requires diligence and respect:

- Do not scrape at rates that could damage the website.
- Do not scrape data that's not available publicly.
- Do not store PII of EU citizens protected by GDPR.
- Do not repurpose *entire* public datasets which can be illegal in some countries.

Scrapfly does not offer legal advice but these are good general rules to follow. For more you should consult a lawyer.

 

   [  Add as a preferred source ](https://google.com/preferences/source?q=scrapfly.io) Table of Contents















 

  Table of Contents

- [Key Takeaways](#key-takeaways)
- [What Expedia Hotel and Flight Data Can You Scrape?](#what-expedia-hotel-and-flight-data-can-you-scrape)
- [Hotel TypedDict](#hotel-typeddict)
- [Flight TypedDict](#flight-typeddict)
- [Project Setup](#project-setup)
- [How Does the Python Expedia Scraper Capture GraphQL Data?](#how-does-the-python-expedia-scraper-capture-graphql-data)
- [Render Expedia and Wait for GraphQL XHR](#render-expedia-and-wait-for-graphql-xhr)
- [Select the Expedia GraphQL Query With \_find\_graphql\_call](#select-the-expedia-graphql-query-with-find-graphql-call)
- [Reuse the Expedia GraphQL Request for Pagination](#reuse-the-expedia-graphql-request-for-pagination)
- [How Do You Scrape Expedia Hotel Search Results?](#how-do-you-scrape-expedia-hotel-search-results)
- [Build an Expedia Hotel Search URL](#build-an-expedia-hotel-search-url)
- [Capture Expedia PropertyListingQuery Data](#capture-expedia-propertylistingquery-data)
- [Parse Expedia LodgingCard Results With parse\_hotels()](#parse-expedia-lodgingcard-results-with-parse-hotels)
- [Paginate Expedia Hotels With build\_next\_page\_config()](#paginate-expedia-hotels-with-build-next-page-config)
- [How Do You Scrape Expedia Flight Search Results?](#how-do-you-scrape-expedia-flight-search-results)
- [Build an Expedia Flight Search URL](#build-an-expedia-flight-search-url)
- [Capture Expedia FlightsSearchResultsLoadedQuery Data](#capture-expedia-flightssearchresultsloadedquery-data)
- [Parse Expedia FlightsStandardOffer Results With parse\_flights()](#parse-expedia-flightsstandardoffer-results-with-parse-flights)
- [Paginate Expedia Flights With searchPagination](#paginate-expedia-flights-with-searchpagination)
- [How Does Scrapfly Unblock Expedia Requests?](#how-does-scrapfly-unblock-expedia-requests)
- [Recognize an Expedia Block Before Parsing](#recognize-an-expedia-block-before-parsing)
- [Keep the Expedia Browser Request and GraphQL Requests in One Session](#keep-the-expedia-browser-request-and-graphql-requests-in-one-session)
- [Does Expedia Have an Official API?](#does-expedia-have-an-official-api)
- [Expedia Travel Redirect Listings APIs](#expedia-travel-redirect-listings-apis)
- [Expedia Rapid API](#expedia-rapid-api)
- [FAQ](#faq)
- [Summary](#summary)
 
    Join the Newsletter  Get monthly web scraping insights 

 

  



Scale Your Web Scraping

Anti-bot bypass, browser rendering, and rotating proxies, all in one API. Start with 1,000 free credits.

  No credit card required  1,000 free API credits  Anti-bot bypass included 

 [Start Free](https://scrapfly.io/register) [View Docs](https://scrapfly.io/docs/onboarding) 

 Not ready? Get our newsletter instead. 

 

 ## Related Articles

 [     

 python scrapeguide 

### How to Scrape Google Flights Data in 2026

Learn how to scrape Google Flights with Python and Scrapfly: extract prices, airlines, routes, stops, CO2 emissions, and...

 

 ](https://scrapfly.io/blog/posts/how-to-scrape-google-flights) [     

 python scrapeguide 

### How to Scrape Marriott Hotel Prices and Availability

Learn how to scrape Marriott hotel prices and room availability with Python in 2026, including search inputs, the JSON b...

 

 ](https://scrapfly.io/blog/posts/how-to-scrape-marriott) [     

 blocking 

### 5 Tools to Scrape Without Blocking and How it All Works

Tutorial on how to avoid web scraper blocking. What is javascript and TLS (JA3) fingerprinting and what role request hea...

 

 ](https://scrapfly.io/blog/posts/how-to-scrape-without-getting-blocked-tutorial) 

  



   



 Scale your web scraping effortlessly, **1,000 free credits** [Start Free](https://scrapfly.io/register)