I run a visa agency in Bali. The other half of my week goes into Apify Actors, and the one I use most myself reads Google Flights over HTTP: send a route and a date, get back the itinerary list Google's own results endpoint returns — price, airline, flight numbers, layovers, aircraft, legroom, CO₂, a booking token.
The field that makes it worth more than a fare quote is priceLevel. Google tells you whether today's price is low, typical or high for that route and date, with a typical range and 60 days of history behind the judgement. Airlines do not publish that. You cannot derive it from one search either — it needs a history you do not have.
So I measured it. One frozen input, one route, run again and again for a month:
{ "origin": "DPS", "destination": "SIN", "departureDate": "2027-03-15", "maxResults": 100 }
Bali to Singapore, departing in four and a half months. Twenty-one runs between 29 September and 28 October, 2,017 itinerary rows, never deleted. Here is what the column says across all of them:
priceLevel = "typical" 2017 of 2017 rows
typicalPriceLow = 40 USD 2017 of 2017 rows
typicalPriceHigh = 70 USD 2017 of 2017 rows
Not one variation in a month. And yet:
rows priced inside 40–70 USD 21
rows priced above 70 USD 1,960
rows with no price at all 36
1,960 rows are labelled "typical" while sitting above the top of the typical range. The maximum was $288 — more than four times the band it was declared typical for.
The 21 exceptions tell you what the field is for
The interesting number is not 1,960, it is 21. Twenty-one runs, twenty-one rows inside the band — exactly one per run, and always the same seat:
date in-band row three cheapest in the run
2026-10-18 Jetstar $60 rank 1 60, 80, 82
2026-10-19 Jetstar $60 rank 1 60, 80, 82
...
2026-10-26 Jetstar $60 rank 1 60, 80, 82
2026-10-27 Jetstar $59 rank 1 59, 80, 82
2026-10-28 Jetstar $65 rank 1 65, 80, 82
JQ86, the 14:55 out of Denpasar, $60 for eighteen consecutive runs, then $59, then $65. Every day it is the only row in the dataset the band actually describes, because the second-cheapest itinerary on this route is $80 and the band stops at $70.
That is the whole lesson. Google's price insight is a verdict on the search, not on an itinerary: the cheapest fare we can find today is inside the range this route normally sells for. My Actor copies it onto every row for convenience, and that convenience is a trap if you read it as a per-row quality score. Group by priceLevel, filter priceLevel == "typical", and you have selected nothing — you have selected everything.
Turn the search summary on and the field moves to where it belongs:
{
"type": "search",
"flightsFound": 167,
"flightsReturned": 100,
"lowestPrice": 65,
"googleLowestPrice": 65,
"priceLevel": "typical",
"priceLevelCode": 4,
"typicalPrice": 45,
"typicalPriceLow": 40,
"typicalPriceHigh": 70
}
lowestPrice is the number the verdict is about. Compare that to the band and the arithmetic is sane: $65 sits inside $40–$70, so prices are typical. The correct test is one row per search, not one per itinerary.
It does vary — just not within a search
Nothing above means the field is broken. Three other searches from the same month, each with one verdict stamped on every row it returned:
| Search | priceLevel | band | prices returned | rows inside band |
|---|---|---|---|---|
| DPS→SIN, one way, economy | typical | $40–70 | 65–288 | 1 of 98 |
| LHR→JFK, return, business | high | $2,400–3,550 | 3,676–4,484 | 0 of 10 |
| SIN→BKK, nonstop, SGD | low | S$125–260 | 166–388 | 5 of 10 |
The business-class search is the honest one: Google says high, and even the cheapest itinerary is above the top of the band. The nonstop Bangkok search says low — its cheapest is below the band's floor. The verdict tracks the cheapest row and ignores the tail, in all three.
Underneath, Google sends an integer, and my decoder maps it:
const level = { 1: 'low', 2: 'low', 3: 'typical', 4: 'typical', 5: 'high' }[code] || null;
So priceLevelCode is finer than the label. The one-way search above came back as 4, the same route as a round trip came back as 3; both print "typical". If you are building alerts, key them on the code, not the string.
The free row is the valuable one
flightsFound: 167 versus flightsReturned: 100 is the other thing only the summary row knows. My cap of 100 silently dropped 67 itineraries, and no field on a flight row says so. If you dedupe or diff datasets across days, that cap is where phantom appearances and disappearances come from.
The summary row also carries priceHistory — 62 daily points, straight from Google, for the cost of nothing. The run summary from today's 100-row pull reads:
Run summary: 101 row(s) delivered, 0 free; charged flight ×100 = $0.02.
101 rows delivered, 100 charged. The search row — insights, history and all — came along free.
And it checks out against my own measurements, which is the part I did not expect. Google's history for this route, next to the cheapest price my runs actually recorded that day:
mine Google
2026-10-18 60 60
2026-10-23 60 60
2026-10-26 60 59
2026-10-27 59 65
2026-10-28 65 65
Nineteen of twenty-one days agree to the dollar. The two that do not are both inside the 48 hours when the fare moved 60 → 59 → 65: it is one snapshot a day, so a change lands on whichever side of the snapshot your run falls. The series also covers an $82–$83 spike between 30 September and 3 October that my own runs missed completely, because I had a gap between the 29th and the 6th. Twenty-one paid runs gave me 21 points; one free row gave me 62.
Two more things that look like bugs and are not
A missing price is an airline, not an error. Across the month 36 rows arrived with price: null — TransNusa 20, Singapore Airlines 10, Saudia 6. These carriers do not sell the fare through Google on this route; the itinerary is real, the number is withheld. Drop the rows if you need prices, or set fillMissingPricesFromKayak and pay for a second lookup. Also: checkedBagsIncluded was null on 2,017 of 2,017 rows while carryOnBagsIncluded was 1 on 1,755 — the results endpoint lists the cabin allowance and stays silent on hold luggage. Not a parser gap, just absent upstream.
One run in 21 came from somewhere else. resultsSource was api on 2,000 rows and page on 17 — a single run on 7 October where the results endpoint refused the residential IP and the Actor fell back to reading the page. That run returned 17 rows instead of 100, 9 of them unpriced. Gate your day-over-day comparisons on resultsSource == "api" and the churn disappears: of 121 distinct flight-number combinations in the 20 API runs, 75 appeared in all 20 and only 4 appeared once.
Calling it
One POST, rows back in the response, 100 rows in under 8 seconds in 20 of my 21 runs:
curl -X POST "https://api.apify.com/v2/acts/lergassy~google-flights-api/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"origin":"DPS","destination":"SIN","departureDate":"2027-03-15",
"maxResults":100,"includeSearchSummary":true}'
That is Google Flights API. Charging is per row and there is no fee per request, which is why I run the same search twenty-one times without thinking about it. Pass routes instead of origin/destination to sweep many routes in one call; calendarDays gets you Google's date grid as rows; resolveBookingOptions adds who sells each itinerary and for how much.
When it is the wrong tool
If you need bookable inventory — fare rules, seat maps, ancillaries, a price you can hold — this is the wrong layer entirely; that lives in a GDS or an airline NDC API, and Google's number is a shop window. If you need per-itinerary price history, it does not exist: the history is one series per search, attached to the cheapest fare, and a specific flight number has no past of its own here. And if your route is dominated by carriers that withhold prices from Google, check the null rate before building on it — or read the same route from a metasearch that shows agency fares, which is what my KAYAK Flights Scraper is for.
The general version of this, which I keep relearning: when a scraper flattens a nested response, every field inherits the shape of the row it lands on. A verdict about 167 itineraries, written onto 100 of them, is still a verdict about 167 itineraries. Keep the summary row.
Top comments (0)