I run a visa agency in Bali, and the flight question I get asked most is not "what is the fare" but "is it going to drop". Clients book Denpasar–Singapore for a visa run and they want to know whether today's $65 is a good $65.
The Actor I keep pointed at that question is the tracker door of my Google Flights scraper: Flight Price Tracker. It is deliberately small. You give it a route and a date, it gives you the cheapest itineraries plus one summary row per route, and that summary row is the part I actually schedule:
{ "type": "search", "origin": "DPS", "destination": "SIN", "departureDate": "2027-03-15", "flightsFound": 163, "flightsReturned": 20, "lowestPrice": 65, "cheapestAirline": "Jetstar", "cheapestFlightNumbers": ["JQ86"], "priceLevel": "typical", "priceLevelCode": 4, "typicalPrice": 45, "typicalPriceLow": 40, "typicalPriceHigh": 70, "priceHistory": [ { "date": "2026-09-01", "price": 55 }, { "date": "2026-09-02", "price": 55 }, ... { "date": "2026-11-01", "price": 65 } ] } priceHistory is the reason this row is worth more than a fare quote. Sixty-odd daily points, already there on your very first run — two months of backfill you did not have to collect. Note what the dates are: every point describes the same departure (15 March 2027) seen on a different day. It is a history of observations, not a calendar of departure dates. People mix those two up constantly, usually because they met a fare calendar first.
I had the same search frozen in a daily schedule anyway, so I went back and read what the history actually did over four weeks: 29 runs that carried a price-insight block, 6 October to 1 November, one frozen input. Twenty-seven of them are the route below; I added one more two-route read this morning.
{ "origin": "DPS", "destination": "SIN", "departureDate": "2027-03-15", "maxResults": 20, "includeSearchSummary": true, "currency": "USD" } The last point is not history, it is now
First thing I checked, and it held without a single exception: in all 29 runs, the final element of priceHistory carried exactly the same number as that run's own lowestPrice.
29 of 29 runs: priceHistory[-1].price == lowestPrice So the array does not stop at yesterday — it ends with your reading. Append your own observation to it and you have duplicated today; diff lowestPrice against priceHistory[-1] looking for a drop and you are diffing a number against itself. Yesterday is priceHistory[-2].
The date on it is not your date
The value is reliable. The stamp is not.
runs whose last point is dated the run's own UTC date 7 of 30 runs whose last point is dated the day before 23 of 30 I went looking for the rollover hour and did not find one. Two runs a minute apart, on different days, fell on opposite sides:
2026-10-30 13:11 UTC last point dated 2026-10-30 same day 2026-11-02 13:12 UTC last point dated 2026-11-01 day before Every 00:xx UTC run was dated the day before, every 20:xx run same-day, a 21:55 run not. I would not model this as a timezone: whatever bins these points is Google's clock and Google's batch, and you cannot reproduce it from yours.
Does the mislabelling matter? For my series it was wrong on 2 of the 21 days I had my own reading for — and those two days were the only two where the price moved:
day what my run saw what Google's history says for that day 2026-10-24 60 60 2026-10-25 — 60 2026-10-26 60 59 2026-10-27 59 65 2026-10-28 65 65 A 7% error rate overall, and a 100% error rate on the days anybody cares about. The drop to $59 and the jump to $65 both land on the wrong date if you join Google's series to your own timestamps.
The fix is one line, and it is to stop using your clock at all: treat priceHistory[-1].date as "now" and count backwards from it.
hist = search_row["priceHistory"] today = hist[-1] # == lowestPrice, by Google's reckoning prev = hist[-2] if prev["price"] - today["price"] >= 10: alert(f'{today["price"]} on {today["date"]}, was {prev["price"]}') The past points can be edited after publication
I expected a settled series. Across the 27 snapshots of this route I had 87 distinct dates covered, and four of them changed value after they were first published:
2026-09-22 61 -> 60 changed in the 17 Oct snapshot 2026-09-23 60 -> 61 changed in the 17 Oct snapshot 2026-09-25 60 -> 59 changed in the 20 Oct snapshot 2026-09-30 83 -> 82 changed in the 23 Oct snapshot The first two are interesting: the $61 day moved from the 22nd to the 23rd while everything around it stayed put. I checked whether the whole series had slid a day — it had not, a zero-day alignment fits best. Google simply re-dated one point three weeks later.
Four edits in 87 dates, a dollar each, is small — and still enough to kill two designs. Do not checksum the array to detect change, and do not audit anything against an old point. Keep your own series: one row per day from your own runs, with Google's history for backfill and sanity-checking.
priceLevel is the wrong alert trigger, and the history is why
The same 27 snapshots said this, every single time:
priceLevel = "typical" 27 of 27 typicalPriceLow = 40 typicalPriceHigh = 70 typicalPrice = 45 (46 before 27 Oct) Meanwhile the history underneath it went 55 → 60 → 82 → 83 → 60 → 59 → 65. The $83 stretch of 30 September to 3 October was a 38% spike over the baseline, and the verdict never moved off "typical". It is a verdict about the search as a whole, not a quality score for the number you got — I measured that separately on the full scraper and it behaved the same way.
The band is just as blunt: 58 of the 62 points in the latest array sit inside 40–70, and the band's "typical" midpoint of $45 is $15 below the series median of $60. If your alert fires on priceLevel == "low" you will wait a long time on a route like this. Fire on the series.
And budget for the field being absent entirely. One run in my sample, SIN→HKT on 1 December, returned a perfectly good cheapest fare of $306 with priceLevel: null, no band, and priceHistory empty. No error, no warning. Branch on it.
Running it for several routes at a cent
The thing I would have liked spelled out: routes batch, and each gets its own summary row with its own history.
curl -s -X POST \ "https://api.apify.com/v2/acts/lergassy~flight-price-tracker/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"routes":[{"origin":"DPS","destination":"SIN","departureDate":"2027-03-15"}, {"origin":"CGK","destination":"SIN","departureDate":"2027-03-15"}], "maxResults":1,"includeSearchSummary":true,"currency":"USD"}' That run, this morning:
FLIGHT DPS SIN 65 Jetstar JQ86 rank 1 SEARCH DPS SIN lowest 65 band 40-70 typical 45 history 61 pts 163 found FLIGHT CGK SIN 76 Garuda Indonesia GA832 rank 1 SEARCH CGK SIN lowest 76 band 30-105 typical 46 history 61 pts 133 found notice Run summary: 4 row(s) delivered, 0 free; charged flight ×2 = $0.0004. Two routes, two fares, 122 history points, two tenths of a cent. Only the flight rows are charged; summary and notice rows are free, which is what makes maxResults: 1 the right setting for a tracker — you pay for one row per route per day and the history comes along with it. Jakarta's band is worth a second look: 30–105 on CGK–SIN against 40–70 on DPS–SIN. Verdicts across routes are not comparable.
When this is the wrong tool
If you want the whole itinerary list with layovers, aircraft, legroom and booking tokens, use the full scraper — this door caps results on purpose. If you want prices across many departure dates rather than many observation days, that is a fare calendar, a different shape of answer (calendarDays on the same input). If you need intraday movement, there is nothing here: the series is daily and self-reported, and I have watched a route move $59 → $65 inside one afternoon with both numbers landing on one point.
What it is good for is the boring job it was built for: a cron entry, a handful of routes, one row each, and a history column you did not have to spend two months collecting. Just read the last element as today, the one before it as yesterday, and never trust the date your own server thinks it is.
