How to Scrape Google Flights Data Without an API
Learn to scrape Google Flights data (fares, routes, dates, price calendar): decode the tfs token, render JavaScript, and geo-target with a SparkProxy example.

If you want to scrape Google Flights for fares, routes, and a price calendar, the first thing to accept is that there is no public API to call. Google killed the official one in 2018, so the live results page is the only programmatic surface left. That page is a JavaScript app that packs your entire search into one encoded token and fetches fares over an internal RPC, which is exactly why a plain HTTP request comes back empty. This guide walks the real mechanics: decoding the tfs token, rendering the page, geo-targeting the currency and market, and pulling the price calendar, with working SparkProxy code.
Why Google Flights has no public API
Google did once sell flight data through an official interface. The QPX Express API came out of its acquisition of ITA Software, a deal announced in 2010 and valued around $700 million. QPX Express returned structured fares and itineraries over HTTP, and travel sites like Orbitz and CheapTickets built on it. Google shut it down on April 10, 2018, citing low partner interest. QPX for enterprise stayed alive under private contracts, but the public feed was gone.
ITA's fare engine still powers a public tool, the ITA Matrix, which many analysts use to hunt fares. Matrix is genuinely useful and free, but it sells no tickets and ships no documented API, so it is a research UI, not a data source you can call.
The practical result: as of 2026 there is no first-party Google Flights API. If you need the data programmatically, you either license an airline or GDS feed (more on that at the end) or you scrape the public results page. Everything below is about the second path. The broader market-monitoring angle for travel data lives in datacenter proxies for travel fare aggregation.
How the results page actually loads data
Open a Google Flights results URL, view source, and search for a price. You won't find one. The initial HTML is an application shell. Fares arrive a moment later through Google's internal RPC transport, batchexecute, the same framework that powers Gmail, Maps, and most modern Google web apps.
Here's the flow on a normal page load:
- The browser loads
/travel/flightsand reads the search from thetfsURL parameter. - Client-side JavaScript POSTs an
f.reqpayload to abatchexecuteendpoint (roughlyhttps://www.google.com/_/TravelFrontendUi/data/batchexecute). - The RPC responds with a nested-array payload (prefixed with the anti-hijacking
)]}'marker) that holds the itineraries and prices. - JavaScript renders that payload into the result cards you see.
Reverse-engineered clients skip the DOM and call that RPC directly. You'll see the flight-results call referred to as GetShoppingResults in some community scrapers. It works, but the rpcids, the app path, and the response shape are undocumented and rotate without notice, and the request needs a valid session token (at) lifted from a page load. That is a brittle contract to maintain. Calling an internal batchexecute RPC is the same discipline covered in how to scrape hidden JSON API endpoints, just with a harder token dance. For most teams, rendering the page is the more durable choice, so that's the route this guide takes.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Decode the tfs parameter (base64 protobuf)
Every Google Flights search collapses into one field: tfs (think "travel flight search"). It is a URL-safe base64 string wrapping a Protocol Buffers message. Build the right tfs and the results page renders your query without ever touching the search form.
Peek at the raw bytes to see the structure:
import base64
tfs = "CBwQAhoeEgoyMDI2LTA5LTE1agcIARIDSkZLcgcIARIDTEhSGAFwAYIBCwj___________8BQAFIAZgBAQ"
# tfs is URL-safe base64, often stripped of '=' padding; add it back before decoding
padded = tfs + "=" * (-len(tfs) % 4)
raw = base64.urlsafe_b64decode(padded)
print(raw) # protobuf wire bytes: field tags + airport codes + ISO dates in plain view
The message uses a fixed field layout that the community has mapped:
| Protobuf field | Meaning | Example |
|---|---|---|
| `2` (repeated) | One entry per flight leg | outbound leg, return leg |
| leg `2` | Departure date (`YYYY-MM-DD`) | `2026-09-15` |
| leg `13` | Origin airport (IATA) | `JFK` |
| leg `14` | Destination airport (IATA) | `LHR` |
| `5` | Cabin class | `1` economy, `2` premium, `3` business, `4` first |
| `8` | Passengers | adults, children, infants counts |
| `9` | Trip type | `1` one-way, `2` round-trip, `3` multi-city |
Hand-rolling protobuf bytes is fiddly and easy to get wrong. A maintained encoder saves the trouble. The open-source fast-flights package builds the token for you:
from fast_flights import FlightData, Passengers, create_filter
filter = create_filter(
flight_data=[FlightData(date="2026-09-15", from_airport="JFK", to_airport="LHR")],
trip="one-way",
seat="economy",
passengers=Passengers(adults=1),
)
tfs = filter.as_b64().decode("utf-8") # the ready-to-use tfs token
Pin the version. These libraries reverse-engineer an undocumented format, and the API surface shifts between releases (the project moved off bundled Playwright between its 2.x and 3.x lines). Treat the encoder as a dependency you test, not a black box you trust forever.
Build the Google Flights search URL
With a tfs token in hand, the URL is short. Four parameters do the work:
| Parameter | Purpose | Example |
|---|---|---|
| `tfs` | Base64 protobuf trip definition (required) | `tfs=CBwQAhoe...` |
| `hl` | Interface language | `hl=en` |
| `gl` | Point-of-sale country (ISO alpha-2) | `gl=us` |
| `curr` | Currency (ISO 4217) | `curr=USD` |
A complete search URL for a US point of sale in dollars:
https://www.google.com/travel/flights/search?tfs=CBwQAhoe...&hl=en&gl=us&curr=USD
There is a second, separate token you'll meet at the price-calendar stage: tfu. It carries flexible-date and price-graph UI state. Keep tfs for the itinerary itself and reach for tfu only when you request the date views.
Why you can't scrape Google Flights without a browser
This is the point most tutorials skip. Google Flights is a single-page app, and the fares are injected by client-side JavaScript after the shell loads. A requests.get() against the search URL returns markup with zero prices in it. There is nothing to parse.
So JavaScript rendering is not optional on this target, the way it sometimes is for a static SERP. You need a real browser to execute the batchexecute fetch and hydrate the cards. The general pattern for these SPA targets is covered in how to scrape dynamic JavaScript websites; Google Flights is a strict case of it.
The second wrinkle is the anti-bot stack. A vanilla headless Chromium gets dropped on the first request from a datacenter IP, because Google reads the browser fingerprint (TLS handshake, client hints, automation flags) and the IP reputation together. Rendering alone isn't enough. You need a browser that looks real and an IP that looks residential. Running and hardening that yourself is a project; the trade against a managed API is broken down in web scraping API vs self-managed proxies.
Fetch flights with the SparkProxy Scraping API
Rather than assemble a stealth browser, a residential pool, and fingerprint patches by hand, send one request to the SparkProxy Scraping API. It renders the page, rotates the proxy, and hands back the hydrated HTML. You pass the Google Flights URL as url and authenticate with the X-API-Key header.
A cURL request for a rendered, residential-routed, US-market search:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.google.com/travel/flights/search?tfs=CBwQAhoe...&hl=en&gl=us&curr=USD" \
--data-urlencode "render_js=true" \
--data-urlencode "country_code=us" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "stealth=true" \
--data-urlencode "wait=5"
The same call in Python, wrapped so you can reuse it:
import requests
def fetch_flights(flights_url, country_code="us"):
resp = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": flights_url,
"render_js": "true", # SPA target: no JS, no fares
"country_code": country_code, # exit IP in the point-of-sale country
"premium_proxy": "true", # residential pool survives the anti-bot stack
"stealth": "true", # pre-warm, forced referrer, idle delays
"wait": "5", # give the batchexecute results time to hydrate
},
timeout=120,
)
resp.raise_for_status()
return resp.text
Each parameter answers a specific obstacle on this target:
| Obstacle | Parameter | Why it helps |
|---|---|---|
| Fares render in JavaScript | `render_js=true` | Runs Chromium so the RPC fetch fires and cards hydrate |
| Headless dropped on sight | `premium_proxy=true` + `stealth=true` | Residential IP plus fingerprint and referrer hardening |
| Wrong market or currency | `country_code` aligned with `gl`/`curr`/`hl` | Consistent point of sale |
| Slow hydration | `wait` (or `wait_for` a selector) | Waits for the result list to populate |
Mind the credit math, because it stacks. premium_proxy with render_js is 25 credits, country_code adds 5, and stealth adds 5, so a fully loaded flight search runs about 35 credits. If a given route survives without stealth, drop it and save the 5. If Google is quiet that day, a lighter request works; on hard days, keep all four on.
Parse fares, routes, and legs
Once you have hydrated HTML, resist the urge to hard-code Google's CSS classes. They are obfuscated and rotate often, so a selector like .pIav2d will break within weeks. The durable anchor on this page is the aria-label attribute: each result row carries a full human-readable summary that Google maintains for screen readers, and it contains the price, the airline, the stop count, the duration, and the times.
from bs4 import BeautifulSoup
import re
def parse_flights(html):
soup = BeautifulSoup(html, "html.parser")
out = []
for li in soup.select("li[aria-label]"):
label = li["aria-label"]
price = re.search(r"(?:US)?\$([\d,]+)", label)
if not price:
continue # rows without a fare are UI chrome, skip them
out.append({
"summary": label.strip(), # full readable itinerary line
"price": int(price.group(1).replace(",", "")),
})
return out
Here's what you can pull, and where each field lives:
| Field | Where it is | Notes |
|---|---|---|
| Price | `aria-label`, fare card | In the currency set by `curr` |
| Airline(s) | `aria-label`, carrier row | Marketing carrier; may be operated by another |
| Stops | `aria-label` ("Nonstop", "1 stop") | Zero-stop is the cleanest signal |
| Duration | `aria-label` ("7 hr 25 min") | Total elapsed, not flight time |
| Departure / arrival | `aria-label` | Local times at each airport |
| Layover airports | expanded leg panel | Needs a click to reveal full detail |
| Emissions | `aria-label` ("... kg CO2e") | Google's estimate, not the airline's |
If you'd rather not maintain a parser, offload it to the API with extract_rules, which returns JSON instead of raw HTML:
params["extract_rules"] = '{"rows": {"selector": "li[aria-label]", "type": "list"}}'
The trade is the usual one: keep a parser for full control, or hand it off and accept the API's structure. Either way, store the raw aria-label too, so you can re-parse historical pulls when a field you ignored suddenly matters.
Scrape the price calendar and date grid
The "fares by date" views are a different data source, not a slice of the results DOM. Google Flights offers two: the price graph (lowest fare per departure date across a window, often two months) and the date grid (a matrix of outbound-versus-return date pairs). Both come from their own batchexecute fetch, keyed by that tfu token from earlier, and neither shows up in the itinerary cards.
You have two ways to collect a Google Flights price calendar.
The reliable, boring way is to iterate the departure date in tfs and record the cheapest fare per day. It costs one rendered request per date, so a 60-day window is 60 requests, but it reuses the exact fetch and parser you already have:
from datetime import date, timedelta
def price_calendar(origin, dest, start, days=60, gl="us", curr="USD"):
calendar = {}
for offset in range(days):
day = start + timedelta(days=offset)
tfs = build_tfs(origin, dest, day.isoformat()) # your tfs encoder from earlier
url = (
"https://www.google.com/travel/flights/search?"
f"tfs={tfs}&hl=en&gl={gl}&curr={curr}"
)
fares = [f["price"] for f in parse_flights(fetch_flights(url, country_code=gl))]
calendar[day.isoformat()] = min(fares) if fares else None
human_delay(base=20) # pace between requests, see below
return calendar
The faster way is to render the price-graph view once and read the whole window from a single response, or to capture that batchexecute reply directly. It is cheaper on credits but pins you to the undocumented RPC shape, so it breaks more often. Start with the date-iteration method, and only graduate to the RPC when the request volume makes the credits hurt.
Regional fares: currency and country targeting
Airfares are set by point of sale. The same JFK to LHR itinerary can price differently for a shopper in New York, London, or Mumbai, because carriers file different fares per market and Google reflects that. If you want accurate local prices, two things have to agree:
- The URL's market signals. Set
currto the currency,glto the country, andhlto the language. - The exit IP. Route through a proxy in that same country with
country_code, so Google sees local traffic.
Mismatch them and you get a blended result that matches no real customer. A US exit IP asking for curr=EUR is not the fare a German traveler sees. Keep the layers aligned:
MARKETS = [
{"country_code": "us", "gl": "us", "hl": "en", "curr": "USD"},
{"country_code": "gb", "gl": "gb", "hl": "en", "curr": "GBP"},
{"country_code": "de", "gl": "de", "hl": "de", "curr": "EUR"},
{"country_code": "in", "gl": "in", "hl": "en", "curr": "INR"},
]
def compare_markets(origin, dest, day):
rows = {}
for m in MARKETS:
tfs = build_tfs(origin, dest, day)
url = (
"https://www.google.com/travel/flights/search?"
f"tfs={tfs}&hl={m['hl']}&gl={m['gl']}&curr={m['curr']}"
)
fares = [f["price"] for f in parse_flights(fetch_flights(url, m["country_code"]))]
rows[m["curr"]] = min(fares) if fares else None
human_delay(base=20)
return rows # cheapest fare per market, for arbitrage or monitoring
This point-of-sale discipline is the same one behind cross-border price monitoring in general, which the travel fare aggregation piece covers from the business side. Hotels behave the same way, which is why scraping Booking.com hotel prices uses an identical currency-plus-geo setup.
Handle blocks, consent walls, and stale prices
Even a clean request runs into friction on this target. Detect the failure modes explicitly, because most of them return HTTP 200 with no fares rather than an honest error.
def is_blocked(status, html):
if status in (403, 429):
return True
low = html.lower()
markers = ["/sorry/", "unusual traffic", "recaptcha",
"consent.google.com", "before you continue"]
return any(m in low for m in markers)
Three failure modes matter here:
- The consent wall. From an EU exit IP, Google often serves a "Before you continue to Google" consent interstitial instead of results. That reads as an empty page to a naive parser. Either request a non-EU point of sale for that route, or inject a consent cookie so the results render.
- Soft blocks. A
/sorry/redirect or "unusual traffic" screen means slow down. Honor anyRetry-After, raise your base delay, and rotate the IP. With the SparkProxy API a fresh IP comes on the next request; with your own pool you discard the burned one. Pace requests with jitter, never a fixed interval:
import random, time
def human_delay(base=20, jitter=0.5):
"""Sleep base seconds +/- 50%, drawn uniformly. Never a fixed beat."""
time.sleep(random.uniform(base * (1 - jitter), base * (1 + jitter)))
- Stale and estimated prices. Google labels many fares as tracked estimates, and airfares move by the hour. A price you scraped this morning may not be bookable this afternoon. Timestamp every record, treat fares older than a few hours as indicative, and re-pull anything you're about to act on. The same cadence and back-off rules from how to scrape Google search results apply directly, since both are Google properties watching request rate.
Cap retries at two or three per query. Hammering a blocked route burns credits and deepens the block.
Ethics, Terms of Service, and airline APIs
Set your guardrails before you scale a job like this.
- Collect only public data. Query fares as an anonymous shopper sees them. Don't touch logged-in state, saved trips, or anything behind an account.
- Know the Terms of Service. Google's ToS restrict automated access outside its own interface. In the US, the hiQ v. LinkedIn line of cases narrowed the Computer Fraud and Abuse Act as applied to public data, so scraping public fares is usually a contractual matter rather than a criminal one. It is still a real risk, so weigh it for your use case and jurisdiction.
- Don't relabel fares as live inventory. Google's prices are aggregated and sometimes estimated. Presenting them as your own guaranteed, bookable feed misleads users and invites trouble. Treat them as market signal, not a booking source.
- Throttle and cache. Most fare-monitoring needs a daily or hourly refresh, not a per-minute one. Cache aggressively and refresh only as often as decisions actually change.
If your goal is to actually book or resell flights, scraping is the wrong tool. Look at real airfare APIs instead: Amadeus and Sabre on the GDS side, Duffel and Kiwi's Tequila for modern booking flows, and aggregator programs like Travelpayouts for affiliate data. They cost money and paperwork, but they give you bookable inventory and a contract, which a scrape never will. Scrape Google Flights for research, monitoring, and analysis; license an API when money changes hands.
Frequently asked questions
FAQ
Not a public one. Google shut down its official QPX Express API on April 10, 2018, and ITA's Matrix tool ships no documented API. As of 2026 the only programmatic access to Google Flights data is scraping the public results page. For bookable inventory, use an airline or GDS API such as Amadeus, Duffel, or Kiwi's Tequila instead.
Scraping publicly visible fares is generally not a criminal act in most jurisdictions, and the hiQ v. LinkedIn rulings narrowed how the US Computer Fraud and Abuse Act applies to public data. It can still breach Google's Terms of Service, which restrict automated access outside Google's interface, so it is a contractual risk rather than a criminal one. Collect only public data, throttle your requests, and check the rules for your jurisdiction.
The tfs parameter is a URL-safe base64 string wrapping a Protocol Buffers message that encodes the whole search: the flight legs (origin, destination, and date per leg), the cabin class, the passenger counts, and the trip type. Build a valid tfs and the results page renders your query without you ever using the search form.
Yes. Google Flights is a single-page app that fetches fares over an internal batchexecute RPC after the initial HTML loads, so a plain HTTP request returns a shell with no prices. You need a real browser to hydrate the data, plus a residential IP and fingerprint hardening, because a vanilla headless browser gets dropped on the first request. With the SparkProxy API you set render_js=true and premium_proxy=true.
Set the curr parameter to an ISO 4217 code (for example curr=EUR), and align gl (country) and hl (language) to the same market. Then route the request through an exit IP in that country using country_code. If the IP and the URL parameters disagree, Google returns a blended fare that no real local shopper would see, so keep all of them consistent.
The price calendar (Google's price graph and date grid) is a separate data fetch, not part of the itinerary cards. The reliable method is to iterate the departure date inside the tfs token and record the cheapest fare per day, one rendered request per date. The faster method is to render the price-graph view or capture its batchexecute response directly, which returns the whole window at once but depends on an undocumented RPC shape.
Get 50% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon โ claim it before it's gone
Related articles

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.

How to Scrape Baidu Search Results Accurately
How to scrape Baidu search results: the pn parameter, GBK encoding traps, resolving baidu.com/link redirects, and parsing Baijiahao and Zhidao blocks.
