Google Flights Search
Purpose
Given an origin, a destination, one date (one-way) or two dates (round-trip), and a passenger / cabin config, return the cheapest itineraries with airline, flight numbers, total duration, stops, depart/arrive times, and a booking link. Read-only — never click Select flight, Book, or any continuation that leaves the results screen.
When to Use
- "What's the cheapest flight from SFO to JFK on 2026-06-15?"
- Comparing fares across dates / airports for a trip-planner agent.
- Bulk price monitoring on a fixed route (driven by the same
tfs=URL day-over-day). - Any flow that needs a price + itinerary snapshot. Booking is a separate skill — Google Flights routes the actual purchase to airline-direct or OTA partners, none of which this skill touches.
Workflow
Google does not publish a consumer flight search API. The official Travel Partner API (developers.google.com/travel/flights) is for airlines/OTAs to push inventory to Google — it does not return search results. The reliable path is to drive the consumer site https://www.google.com/travel/flights with a deep-link URL that pre-applies all search parameters via the tfs= (base64-protobuf) query string, so no form interaction is required.
A residential-proxy + Browserbase-Verified session is mandatory. From data-center IPs Google serves the /sorry/index 429 interstitial ("Our systems have detected unusual traffic from your computer network") within 1–2 requests on /travel/flights* — verified at runtime (HTTP 429, Content-Type: text/html, captcha widget served inline).
1. Build the tfs deep-link
Construct the URL:
https://www.google.com/travel/flights?tfs=<BASE64_PROTOBUF>&hl=en&curr=USD&gl=us&tfu=EgQIABABIgA
The tfs value is a base64-URL-encoded protobuf with this schema (verified against open-source decoders):
message Airport { string airport = 2; } // 3-letter IATA, uppercase
message FlightData { string date = 2; // YYYY-MM-DD
Airport from_flight = 13;
Airport to_flight = 14;
optional int32 max_stops = 5; // 0 = nonstop
repeated string airlines = 6; } // 2-letter IATA or alliance
enum Seat { ECONOMY=1; PREMIUM_ECONOMY=2; BUSINESS=3; FIRST=4; }
enum Trip { ROUND_TRIP=1; ONE_WAY=2; MULTI_CITY=3; }
enum Passenger { ADULT=1; CHILD=2; INFANT_IN_SEAT=3; INFANT_ON_LAP=4; }
message Info { repeated FlightData data = 3; // 1 leg for one-way, 2 for round-trip
Seat seat = 9;
repeated Passenger passengers = 8; // one enum entry per passenger
Trip trip = 19; }
Wire-format rule: passengers is repeated and unpacked — three adults + one infant-on-lap = four entries [ADULT, ADULT, ADULT, INFANT_ON_LAP], not a single scalar count. Total passengers must be ≤ 9. infants_on_lap must be ≤ adults.
The trailing constants — hl=en, curr=USD, gl=us, tfu=EgQIABABIgA — are required. Drop any of them and Google often redirects to a "popular destinations" landing page that ignores the tfs payload.
Easier fallback when you don't want to encode protobuf: use the natural-language q= deep-link. The intent-parser handles ~95% of routes cleanly:
https://www.google.com/travel/flights?q=Flights+from+SFO+to+JFK+on+2026-06-15+through+2026-06-22&hl=en&curr=USD&gl=us
Verify after navigation that the resolved URL contains tfs= (intent-parser succeeded) — if it lands on /explore instead, the parser couldn't disambiguate and you must fall back to building the tfs directly.
2. Open in a stealth + residential-proxy session
sid=$(browse cloud sessions create --keep-alive --verified --proxies | jq -r .id)
export BROWSE_SESSION="$sid"
browse open "$URL" --remote
browse wait load --remote
browse wait timeout 3500 --remote # itinerary cards stream in 2–4s after `load`
Anything short of --verified --proxies gets either the /sorry/index 429 or an empty itinerary list with a "Something went wrong" toast.
3. Dismiss the consent / cookie banner if present
On a fresh session (no SOCS cookie) Google overlays a consent dialog that swallows clicks on result rows.
browse snapshot --remote
# Look for a `button: "Reject all"` or `button: "Accept all"` ref — click it.
browse click "<ref>" --remote
browse wait timeout 1000 --remote
If you reuse a persisted Browserbase context, you can pre-seed the consent state once (CONSENT=PENDING+987; SOCS=<base64>) and skip this step on every subsequent run.
4. Switch the sort to "Cheapest" (the prompt explicitly asks for cheapest)
The default landing tab is "Best", which is Google's price-vs-duration heuristic, not strictly the cheapest. Click the "Cheapest" tab:
browse snapshot --remote
# Find: tab "Cheapest" → click its ref
browse click "<ref>" --remote
browse wait timeout 2500 --remote # the list re-sorts in place; no navigation
Equivalent URL-only path: append &sort=2 (SerpAPI-style sort codes — 1=top, 2=price, 3=depart, 4=arrive, 5=duration, 6=emissions). Google honors this param on the consumer page.
5. Read the itinerary cards
browse get html body --remote > /tmp/flights.html
The result list is a sequence of <li> items inside two parent containers (one per tab/section):
div[jsname="IWWDBc"]— "Best flights" / top sectiondiv[jsname="YdtKid"]— "Other flights" section
Within each ul.Rk10dc li row:
| Field | Selector |
|---|---|
| Airline name | div.sSHqwe.tPgKwe.ogfYpf span (first) |
| Depart / arrive times | span.mv1WYe div (nodes 0 and 1) |
Next-day arrival flag (+1, +2) | span.bOzv6.bOzv6 next to arrival time |
| Total duration | div.gvkrdb.AdWm1c.tPgKwe.ogfYpf ("5 hr 32 min") |
| Stops | div.EfT7Ae.AdWm1c.tPgKwe span.ogfYpf ("Nonstop" / "1 stop") |
| Layover detail | div.c8rWCd.tPgKwe.ogfYpf ("1 hr 45 min LAX") |
| Price | div.YMlIz.FpEdX span ("$412") |
| CO₂e | div.O7CXue.tPgKwe ("312 kg CO2e") |
| Card "details" expander | button[aria-label*="more details"] |
Flight numbers (e.g. DL 412) are not in the card-collapsed view. You have to expand each card — click button[aria-label*="more details"] — which renders an inner detail panel containing each segment's Airline + flight number + aircraft + seat pitch + on-time stats. Read span.sSHqwe span:nth-of-type(2) inside each segment block to get the AA 1234-style identifier. Budget 1.5–3 seconds per card for the detail-render to settle before the next snapshot.
6. Construct booking links
There is no clean per-itinerary "deep link to airline" available before the user picks a card — Google routes booking through /travel/flights/booking which is Disallow: in robots.txt and serves Akamai-blocked responses to non-browser HTTP. The two practical options:
- Canonical Google Flights detail URL — keep the current page URL and append the itinerary's
itinerary-keyparameter fromdata-iidattribute on the row (<li data-iid="...">). Format:https://www.google.com/travel/flights/booking?tfs=<original>&iid=<row data-iid>. Reaches the booking-redirect page when followed in-browser. Use this when you need a single URL the user can paste. - Click + capture the outbound URL — click
button[aria-label*="Select flight"]on the card, thenbrowse get urlimmediately after navigation. Google bounces to either an airline-direct URL or a Google-hosted broker page. Carries an?gclid=tracking param. Higher-fidelity but costs one navigation per card.
Default to option (1) for read-only price-comparison flows. Use option (2) only when the consumer explicitly needs the merchant-direct URL.
7. Pull deeper itinerary detail via the embedded JS blob (optional, faster than per-card expansion)
The full structured itinerary list (including flight numbers, aircraft types, layover cities, codeshares) is embedded in the initial HTML as a nested-list blob inside <script class="ds:1">…AF_initDataCallback({…, data: [ …deeply nested arrays… ], …})…</script>. Regex: /^.*?\{.*?data:(\[.*\]).*}/. Parse the captured group as JSON, then index by the positional paths below (verified against open-source decoders):
- Itinerary root list:
data[0]is "best",data[1]is "other" - Per itinerary:
flights[],layovers[],travel_time(minutes),departure_airport,arrival_airport,itinerary_summary.flights(e.g."DL 412, DL 1735"),itinerary_summary.price(dollars, integer) - Per flight segment (
itinerary.flights[i]):operator[2],departure_airport[3],arrival_airport[5],departure_time[8]([hour, minute]),arrival_time[10]([hour, minute]),travel_time[11](minutes),seat_pitch_short[14],codeshares[15],aircraft[17],departure_date[20],arrival_date[21],airline_code[22][0],airline_name[22][3],flight_number[22][1]
Reading the blob is one extraction per page vs. N expand-clicks in the DOM path, but the indices are positional and Google has rotated them at least once historically — always pin the parser against the latest version of wooldox/google-flights-ts/src/decoder.ts (or the upstream Python AWeirdDev/flights) before trusting them blindly.
8. Release the session
browse cloud sessions update "$sid" --status REQUEST_RELEASE
Fast-path fallback: direct HTTP, no browser
If you have a clean residential IP and just need a quick price snapshot, you can sometimes skip the browser entirely:
curl -sS "https://www.google.com/travel/flights?tfs=<b64>&hl=en&curr=USD&tfu=EgQIABABIgA" \
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..." \
-H "Cookie: CONSENT=PENDING+987; SOCS=CAESHAgBEhJnd3NfMjAyMzA4MTAtMF9SQzIaAmRlIAEaBgiAo_CmBg" \
-H "Accept-Language: en-US,en;q=0.5"
The 200-OK HTML body contains both the cheerio-parsable cards (selectors above) and the script.ds:1 JS blob. Expect this path to fail most of the time from cloud IPs — Google returns /sorry/index 429 or a redirect to a generic landing page. Use only as an opportunistic optimization with a graceful fallback to the browser path on any non-200 response or any response containing <title>Sorry...</title>.
Site-Specific Gotchas
- READ-ONLY. Never click
Select flight,Book, or any time-slot ref on the result card. The skill terminates at the results screen. --verified --proxiesis mandatory. Bare and--proxies-only sessions get the/sorry/index429 captcha interstitial within 1–2 requests on/travel/flights*. Verified at iteration time via directbrowse cloud fetchof/sorry/indexreturning HTTP 429 with the canonical "Our systems have detected unusual traffic" body.- The page is >1 MB of HTML — confirmed:
browse cloud fetch https://www.google.com/travel/flightsreturns502 The response body exceeded the maximum allowed size of 1MB. Use a browser session to handle large responses.Don't try to use Browserbase's Fetch API for the search page; you must drive a session. robots.txtexplicitly disallows the booking funnel —Disallow: /travel/flights/booking,/travel/flights/search,/travel/flights/s/,/travel/search,/travel/clk. The main/travel/flightslanding is the only allowed entry point. Skill stays on/travel/flights?tfs=...and never navigates into aDisallow-ed subpath.- No public consumer search API.
developers.google.com/travel/flightsis a partner API for airlines/OTAs to push inventory to Google. It does not return search results. Don't waste time looking for a JSON endpoint — the consumer site is the only surface. tfsis base64-URL-encoded protobuf, not JSON. Decoders that try to JSON-parse it fail silently and produce blank pages. Verified protobuf schema in step 1; reference encoders:wooldox/google-flights-ts(TS, hand-rolled),AWeirdDev/flights(Python, upstream).passengersis repeated-unpacked, not a count. Two adults =[ADULT, ADULT], not2. Mis-encoding shows up as "1 traveler" on the rendered page regardless of intent.- Default tab is "Best", not "Cheapest". The prompt asks for cheapest — explicitly click the "Cheapest" tab (or append
&sort=2to the URL) before reading rows, otherwise the top results are Google's "best value" heuristic, which can be $50–$200 above true cheapest on a US transcon. - Flight numbers are not in the collapsed card. Either expand each card (1.5–3s wait per expand) or parse the
script.ds:1JS blob (one parse per page). The blob path is ~10× faster but positionally indexed — see step 7 caveat. - Next-day-arrival marker is easy to miss. The arrival cell shows
11:50 PMwith a tiny+1superscript when the flight lands the next day. Selector:span.bOzv6.bOzv6. Drop this and you'll mis-computetotal_duration_minutesby 1440. - Consent dialog is a click-trap. Fresh sessions get the EU-style "Before you continue" overlay (even on US IPs). It captures clicks on row buttons silently. Always check for and dismiss it before clicking anything else.
q=intent-parser can rewrite the URL. Useful (you can skip protobuf encoding) but unpredictable — ambiguous airport names or odd date phrasings can land on/exploreinstead of running a search. Always inspect the post-navigation URL fortfs=; fall back to manually building the protobuf when it's missing.- Prices are cached, not live. A given
tfspayload can return a stale price for hours. For high-fidelity pricing, append&curr=USD&hl=en(forces currency / locale) and treat the result as "Google's last-seen price", not "current airline price". Surface the page's<title>"Updated …" timestamp when emitting output. - HTML selectors are obfuscated and rotate quarterly.
jsname="IWWDBc"andul.Rk10dcare stable across at least 18 months of open-source community usage but have changed in the past. When a parse drops to zero rows, refresh selectors fromwooldox/google-flights-ts/src/core.ts(parseResponse) or the upstreamAWeirdDev/flightslibrary — both track Google's churn. - Booking page is Akamai-blocked from cookieless HTTP. Don't try to follow
/travel/flights/bookingURLs out-of-band — they only render in-session after aSelect flightclick. For consumer-facing booking URLs, prefer attaching the canonical Google Flights URL with theiid(see step 6 option 1). - Trace capture from Vercel Sandbox is unavailable. The sandbox's DNS resolver returns
REFUSEDforconnect.usw2.browserbase.comand all otherconnect.*.browserbase.comhosts (verified across us-west-2, us-east-1, eu-central-1, ap-southeast-1). The Browserbase Fetch API atapi.browserbase.comworks, but CDP/Playwright sessions cannot be driven from the sandbox itself. The skill is designed to run from a Browserbase-connected client (laptop, CI runner, or a host whose egress is unfiltered) — this caveat is environment-specific, not a property of Google Flights.
Expected Output
Two outcome shapes:
// Success
{
"success": true,
"origin": "SFO",
"destination": "JFK",
"depart_date": "2026-06-15",
"return_date": "2026-06-22",
"trip_type": "round-trip",
"passengers": { "adults": 1, "children": 0, "infants_in_seat": 0, "infants_on_lap": 0 },
"cabin": "economy",
"sort": "cheapest",
"price_currency": "USD",
"results": [
{
"rank": 1,
"price": 412,
"airlines": ["Delta"],
"flight_numbers": ["DL 412", "DL 1735"],
"stops": 0,
"total_duration_minutes": 332,
"outbound": {
"depart_time_local": "2026-06-15T07:30-07:00",
"arrive_time_local": "2026-06-15T15:50-04:00",
"depart_airport": "SFO",
"arrive_airport": "JFK",
"duration_minutes": 320,
"segments": [
{ "flight_number": "DL 412", "depart": "07:30", "arrive": "15:50",
"depart_airport": "SFO", "arrive_airport": "JFK",
"aircraft": "Boeing 757", "operator": "Delta Air Lines", "duration_minutes": 320, "layover_minutes": 0 }
]
},
"return": {
"depart_time_local": "2026-06-22T18:10-04:00",
"arrive_time_local": "2026-06-22T21:35-07:00",
"depart_airport": "JFK",
"arrive_airport": "SFO",
"duration_minutes": 385,
"segments": [ /* same shape */ ]
},
"co2e_kg": 312,
"co2e_vs_typical_pct": -8,
"booking_url": "https://www.google.com/travel/flights/booking?tfs=<original>&iid=<row data-iid>",
"last_updated": "2026-05-18T18:42:00Z"
}
]
}
// Anti-bot wall (Sorry page) — emit when the post-navigation URL contains /sorry/ or the page title starts with "Sorry"
{
"success": false,
"error_reasoning": "Google served the /sorry/index 429 anti-bot interstitial. Retry with a fresh --verified --proxies session, or rotate the proxy pool. The IP currently in use is on Google's traffic-anomaly list."
}
If the route has no flights on the requested date — valid empty result, not a failure: { "success": true, ..., "results": [] } plus the page's empty-state text in an info field ("No flights available for these dates. Try nearby dates.").