🎯 A customizable, anti-detection cloud browser powered by self-developed Chromium designed for web crawlers and AI Agents.πŸ‘‰Try Now
Back to Blog

Build a Flight Price Tracker With Scrapeless Scraping API

Sophia Martinez
Sophia Martinez

Specialist in Anti-Bot Strategies

14-Jul-2026

TL;DR:

  • A flight watch is a route and a date, not a URL. Airfare has no single canonical page the way a retail SKU does β€” the same trip is a combination of departure_id, arrival_id, outbound_date, and (for a round trip) return_date. The pipeline below queries that combination directly through Scrapeless Scraping API's scraper.google.flights actor instead of rendering and scraping Google Flights' own interface.
  • Trip type is a first-class parameter, not a URL variant. data_type switches the same request between one-way, round trip, and multi-city, and multi-city takes its own multi_city_json array of legs. A retail price watch never needs this dimension; a flight watch does from day one.
  • Google Flights ships its own volatility signal. The response's price_insights object carries lowest_price and a price_level classification (low, typical, high) alongside the individual offers in best_flights and other_flights. That lets the pipeline alert on "this is a good fare right now," not only "this is cheaper than last time."
  • History is keyed by route and date, appended once per check. A JSONL log with one record per (departure_id, arrival_id, outbound_date, return_date) combination is enough to compute a running low and detect a drop β€” no database required until the watchlist grows past a handful of routes.
  • A drop, or a price_level flip to low, fires a webhook. The alert decision combines the numeric comparison with Google's own qualitative signal, then posts once to whatever endpoint receives it.
  • A scheduler runs the loop; nothing about it needs to stay running. Cron, Task Scheduler, or a serverless timer executes the check on a cadence and exits β€” the pipeline carries no long-lived process.
  • Free to start. New Scrapeless accounts include free Scraping API credits β€” sign up at app.scrapeless.com.

Introduction: a fare is a moving target with two dimensions, not one

A retail price is a single number attached to a single page. A flight fare is a function of a route and a date, and the same route can price differently for every departure date within the same search. That is the whole reason Google's own flight price tracking feature exists: airlines reprice seat inventory continuously against demand, and a shopper who checks once and books is guessing at timing. Google's tracker mails a summary when the tracked route moves; it does not hand the reader raw data, a webhook, or a way to combine that signal with anything else the reader is already running.

This post builds a small pipeline that does that job programmatically, on top of a specific route and date window instead of Google Flights' interface. Scrapeless's Scraping API ships a purpose-built actor for this surface β€” scraper.google.flights β€” that accepts a route and date combination and returns structured fare data: individual offers, an airline and duration per offer, and Google's own price-level read on the fare. The pipeline queries that actor, extracts the fare signals, appends them to a history log keyed by route and date, decides whether the reading is worth an alert, and fires a webhook when it is. A scheduler runs the check on a cadence.

The actor returned a live disabled actor response for the account used to research this post β€” a plan-tier gap, not a broken pipeline β€” and that gap is documented at the point it matters rather than glossed over. Every other stage below is exercised against real, executed Python.

What You Can Do With It

  • Personal fare watches. Track a specific route and date window and get notified only when the price actually moves in a useful direction.
  • Flexible-date shopping. Run the same route across a spread of candidate dates and compare price_insights.price_level across the spread to find the cheapest window, not just the cheapest single date.
  • Corporate travel monitoring. A travel desk can watch recurring commute or client routes and flag when a booked itinerary's route has since dropped, prompting a rebook.
  • Multi-city itinerary tracking. multi_city_json lets the same request represent a multi-leg trip, so an itinerary with three or four segments is one call instead of three or four separate one-way watches stitched together by hand.
  • Fare-history datasets. The append-only log doubles as a time series per route, useful for later trend analysis or for feeding a "book now or wait" heuristic.

Why Scrapeless Scraping API for this

Scrapeless Scraping API exposes site-specific actors β€” scraper.<site> β€” that return structured JSON for a given target instead of a rendered page a caller has to parse. For flight data specifically, that means:

  • A route-and-date request, not a browser session. The call is one authenticated POST with a JSON body; there is no page to render, no selector to anchor, and no session to keep alive.
  • Trip type built into the request shape. data_type covers one-way (2), round trip (1), and multi-city (3, with multi_city_json) as documented parameters, not as separate scraping logic per case.
  • A pricing classification the target site itself computes. price_insights.price_level is Google's own judgment on whether the fare is low, typical, or high for the route β€” the pipeline can lean on that instead of guessing a threshold from scratch.
  • One request instead of a distribution stack. Airline fare data otherwise reaches a caller through a patchwork of GDSes and airline-direct feeds β€” the kind of fragmented distribution IATA's New Distribution Capability standard exists to standardize on the airline-industry side. A single structured actor call sidesteps that stack entirely for a read-only price watch.

Get a free-plan API key at app.scrapeless.com. The Scraping API product page covers the actor family this pipeline calls, and the request shape for this specific actor is walked through in the Google Flights API request guide.

Prerequisites

  • Python 3.10 or newer, plus the requests package (pip install requests).
  • A Scrapeless account and API key, read from the SCRAPELESS_API_KEY environment variable. Confirm the scraper.google.flights actor is enabled for the account's plan before pointing production traffic at it β€” see the note under Fetch, below.
  • A webhook endpoint to receive alerts (Slack, Discord, or any relay that accepts a JSON POST).
  • A cron-capable host, Windows Task Scheduler, or a serverless scheduler to run the check on a cadence.

Pipeline at a Glance

The pipeline is six stages wired in a straight line: fetch β†’ discover β†’ extract β†’ transform β†’ store β†’ decide β†’ alert, with a scheduler driving the whole thing on a cadence.

  1. Fetch β€” POST a route, a date (or date window), and a trip type to scraper.google.flights.
  2. Discover β€” read the trip-type envelope back: which of best_flights, other_flights, and price_insights the response actually populated.
  3. Extract β€” pull the cheapest offer's price and Google's own price_level classification out of that envelope.
  4. Transform β€” normalize the reading into one canonical record keyed by route and date.
  5. Store β€” append the record to a history log.
  6. Decide and alert β€” compare against history, and fire a webhook when the reading is worth surfacing.

Fetch: Query a Route and a Date Window

The request is a single authenticated POST. The actor name goes in actor; the route and date parameters go in input. data_type is 1 for a round trip and 2 for one-way; a round trip also carries return_date.

python Copy
import os
import requests

API_URL = "https://api.scrapeless.com/api/v1/scraper/request"


def fetch_route(departure_id: str, arrival_id: str, outbound_date: str,
                 return_date: str | None = None, gl: str = "us", hl: str = "en") -> dict:
    payload = {
        "actor": "scraper.google.flights",
        "input": {
            "departure_id": departure_id,
            "arrival_id": arrival_id,
            "data_type": 1 if return_date else 2,   # 1 = round trip, 2 = one-way
            "outbound_date": outbound_date,
            "gl": gl,
            "hl": hl,
        },
    }
    if return_date:
        payload["input"]["return_date"] = return_date

    headers = {"Content-Type": "application/json", "x-api-token": os.environ["SCRAPELESS_API_KEY"]}
    response = requests.post(API_URL, headers=headers, json=payload, timeout=60)
    response.raise_for_status()
    return response.json()

outbound_date and return_date follow the plain YYYY-MM-DD calendar-date form RFC 3339 defines for internet date-time exchange β€” no time component, since a fare search is keyed to a departure day, not a departure instant. A multi-city itinerary replaces the single-leg fields with data_type: 3 and a multi_city_json array, one object per leg, each carrying its own departure_id, arrival_id, and date.

Prerequisite gap: running the call above against a real route returned a live HTTP 400 for the account used to verify this pipeline: {"code": 14002, "message": "disabled actor: scraper.google.flights"}. That message is distinct from the error an unrecognized actor name returns ("invalid actor: <name>" β€” confirmed by deliberately requesting a few made-up variants), which means the actor itself is real and catalogued; it is gated off the free plan used here rather than missing. The account's underlying request mechanics are not in question β€” the sibling scraper.google.search actor returned a real HTTP 200 with organic results on the same key and the same endpoint during this research. Confirm the actor is enabled for the target plan (GET /api/v1/me reports plan and credit balance) before building production traffic on it, and treat everything from Discover onward as running against the actor's documented response shape rather than a page captured live in this session.

Discover: Reading the Trip-Type Envelope

A round-trip or one-way search returns two arrays of offers β€” best_flights and other_flights β€” plus a price_insights object. A multi-city search returns the equivalent structure per itinerary rather than per single leg. Before extracting anything, the pipeline just needs to know which of those keys the response actually populated, since a narrow route or an odd date can come back with other_flights empty and only best_flights carrying offers.

python Copy
def envelope_shape(payload: dict) -> dict:
    return {
        "has_best": bool(payload.get("best_flights")),
        "has_other": bool(payload.get("other_flights")),
        "has_insights": bool(payload.get("price_insights")),
    }


if __name__ == "__main__":
    sample = {"best_flights": [{"price": 312}], "other_flights": [], "price_insights": {}}
    print(envelope_shape(sample))

Run against a narrow response that only populated best_flights, envelope_shape returns {'has_best': True, 'has_other': False, 'has_insights': False} β€” exactly the case the extract stage below has to tolerate.

This is a one-line sanity check, but it matters: extracting a "cheapest offer" from an envelope that never populated best_flights produces a None rather than a wrong number, provided the extract stage checks both arrays rather than assuming one is always present.

Extract: Pull the Fare Signals Out of the Response

The extract stage pulls the cheapest offer across both arrays and the price_insights fields alongside it. The fixture below mirrors the response shape documented for this actor family β€” best_flights and other_flights as arrays of {flights: [...], price, layovers} objects, and price_insights carrying lowest_price, price_level, and a typical_price_range β€” since the live call is the gap noted above rather than a page this session captured.

python Copy
# Illustrative fixture matching the documented response shape (see the
# prerequisite-gap note under Fetch) β€” not a live capture.
FIXTURE_RESPONSE = {
    "best_flights": [
        {
            "flights": [{
                "departure_airport": {"id": "JFK", "time": "2026-08-10 08:00"},
                "arrival_airport": {"id": "LAX", "time": "2026-08-10 11:24"},
                "airline": "Example Air",
                "flight_number": "EX 101",
                "duration": 384,
                "travel_class": "Economy",
            }],
            "price": 312,
            "layovers": 0,
        }
    ],
    "other_flights": [
        {"flights": [{"airline": "Sample Airways", "flight_number": "SA 220"}], "price": 349, "layovers": 1}
    ],
    "price_insights": {
        "lowest_price": 289,
        "price_level": "low",
        "typical_price_range": [295, 430],
    },
}


def extract_fares(payload: dict) -> dict:
    best = payload.get("best_flights", [])
    other = payload.get("other_flights", [])
    insights = payload.get("price_insights", {})

    cheapest_offer = min([*best, *other], key=lambda offer: offer["price"], default=None)

    return {
        "cheapest_price": cheapest_offer["price"] if cheapest_offer else None,
        "cheapest_layovers": cheapest_offer["layovers"] if cheapest_offer else None,
        "offer_count": len(best) + len(other),
        "price_insights_lowest": insights.get("lowest_price"),
        "price_level": insights.get("price_level"),
        "typical_price_range": insights.get("typical_price_range"),
    }


if __name__ == "__main__":
    print(extract_fares(FIXTURE_RESPONSE))

Run against the fixture, extract_fares returns:

text Copy
{'cheapest_price': 312, 'cheapest_layovers': 0, 'offer_count': 2,
 'price_insights_lowest': 289, 'price_level': 'low', 'typical_price_range': [295, 430]}

Note the gap between cheapest_price (the lowest single offer actually returned) and price_insights_lowest (Google's own floor for the route). The two rarely match exactly β€” price_insights reflects a broader read on the route than the specific offers in this one response β€” so keep both fields rather than collapsing them into one number.

Transform: Normalize Into a Route-Date Record

A flight watch's identity is the tuple of (departure_id, arrival_id, outbound_date, return_date), not a URL. The transform stage builds that key alongside a canonical record shape so storage and comparison never have to re-derive it.

python Copy
from datetime import datetime, timezone


def to_record(route: dict, fares: dict) -> dict:
    return {
        "departure_id": route["departure_id"],
        "arrival_id": route["arrival_id"],
        "outbound_date": route["outbound_date"],
        "return_date": route.get("return_date"),
        "trip_type": "round_trip" if route.get("return_date") else "one_way",
        "price": fares["cheapest_price"],
        "price_level": fares["price_level"],
        "currency": "USD",
        "checked_at": datetime.now(timezone.utc).strftime("%d-%b-%Y %H:%M UTC"),
    }


def route_key(record: dict) -> str:
    return f"{record['departure_id']}-{record['arrival_id']}:{record['outbound_date']}:{record.get('return_date')}"


if __name__ == "__main__":
    sample_route = {"departure_id": "JFK", "arrival_id": "LAX",
                     "outbound_date": "2026-08-10", "return_date": "2026-08-17"}
    sample_fares = {"cheapest_price": 312, "price_level": "low"}
    record = to_record(sample_route, sample_fares)
    print(record)
    print(route_key(record))

Run against the fixture route (JFK to LAX, 10 Aug outbound, 17 Aug return) and the extracted fares above, to_record returns a record with price: 312, price_level: 'low', trip_type: 'round_trip', and route_key returns JFK-LAX:2026-08-10:2026-08-17. A one-way watch on the same route and outbound date produces a distinct key the moment return_date is absent, which is the point: two trip types on the same route are two different watches, not one.

Store: An Append-Only Log Keyed by Route and Date

Storage is the same append-only JSONL pattern a retail price watch uses, with one difference: the lookup filters on the route-date key instead of a URL.

python Copy
import json

HISTORY_FILE = "flight_history.jsonl"


def append_history(record: dict) -> dict:
    with open(HISTORY_FILE, "a", encoding="utf-8") as f:
        f.write(json.dumps(record) + "\n")
    return record


def load_history(key: str) -> list[dict]:
    rows = []
    try:
        with open(HISTORY_FILE, encoding="utf-8") as f:
            for line in f:
                row = json.loads(line)
                if route_key(row) == key:
                    rows.append(row)
    except FileNotFoundError:
        pass
    return rows


if __name__ == "__main__":
    open(HISTORY_FILE, "w").close()  # start this demo from an empty log
    sample_record = {"departure_id": "JFK", "arrival_id": "LAX", "outbound_date": "2026-08-10",
                      "return_date": "2026-08-17", "trip_type": "round_trip",
                      "price": 312, "price_level": "low", "currency": "USD",
                      "checked_at": "13-Jul-2026 14:28 UTC"}
    append_history(sample_record)
    append_history(sample_record)
    with open(HISTORY_FILE, encoding="utf-8") as f:
        print(f"{len(f.readlines())} line(s) in {HISTORY_FILE} after two checks")

Appending the same record twice and reloading by key returns both rows in insertion order, confirming the log accumulates rather than overwrites β€” the same guarantee an append-only file gives a URL-keyed watch, just filtered on a different key. Past a handful of routes, swap the file for a small SQLite table with (departure_id, arrival_id, outbound_date, return_date) as a composite key; the read and write shape stays the same.

Decide: Compare Against History and Google's Own Price Signal

A retail price watch has one decision rule: is the new price below the lowest one seen so far. A fare watch has a second signal available β€” price_level β€” so the decision stage checks both: a genuine numeric drop below the running low, or a first reading that already lands on price_level: "low".

python Copy
def is_price_drop(history: list[dict], current_price: float, current_level: str) -> dict:
    prior_prices = [r["price"] for r in history if r.get("price") is not None]
    previous_low = min(prior_prices) if prior_prices else None

    numeric_drop = (
        current_price is not None
        and previous_low is not None
        and current_price < previous_low
    )
    level_signal = current_level == "low"

    return {
        "dropped": numeric_drop,
        "worth_alerting": numeric_drop or (level_signal and previous_low is None),
        "current": current_price,
        "previous_low": previous_low,
        "price_level": current_level,
    }


if __name__ == "__main__":
    history = [{"price": 349}, {"price": 340}]
    print(is_price_drop(history, current_price=312, current_level="low"))

Against two prior readings of 349 and 340, a new reading of 312 with price_level: "low" returns {'dropped': True, 'worth_alerting': True, 'current': 312, 'previous_low': 340, 'price_level': 'low'}. The worth_alerting field is deliberately broader than dropped: it also fires on a route's very first reading if Google's own classification already calls it low, since a running-low comparison has nothing to compare against yet but the qualitative signal does. That distinction reflects something research on offline dynamic pricing in airline ticket markets treats as a structural feature of the category: airline seat inventory reprices against demand and remaining capacity, not against a fixed sale calendar, so a single snapshot can already be informative without a price history to compare it to.

Alert: Fire a Webhook on a Drop

The alert mechanic is the same single requests.post a retail watch uses β€” one HTTP call, no queue, no broker β€” with the message built from the route-and-date identity instead of a product name.

python Copy
def send_alert(route_label: str, decision: dict, webhook_url: str) -> int:
    message = (
        f"Fare watch: {route_label}\n"
        f"Now ${decision['current']} (was ${decision['previous_low']}), "
        f"Google price_level={decision['price_level']}"
    )
    response = requests.post(webhook_url, json={"text": message}, timeout=15)
    response.raise_for_status()
    return response.status_code

raise_for_status() surfaces a broken webhook endpoint immediately instead of letting a misconfigured relay swallow every alert silently. The {"text": message} body matches the common Slack/Discord incoming-webhook shape; adjust the JSON to whatever the receiving endpoint expects.

Schedule: Poll on a Cadence Bounded by the Trip

Wiring fetch through alert into one function gives a single check_route() call a scheduler can run:

python Copy
def check_route(departure_id, arrival_id, outbound_date, return_date=None, webhook_url=None):
    payload = fetch_route(departure_id, arrival_id, outbound_date, return_date)
    fares = extract_fares(payload)
    route = {"departure_id": departure_id, "arrival_id": arrival_id,
             "outbound_date": outbound_date, "return_date": return_date}
    record = append_history(to_record(route, fares))
    decision = is_price_drop(load_history(route_key(record)), record["price"], record["price_level"])
    if decision["worth_alerting"] and webhook_url:
        send_alert(f"{departure_id}-{arrival_id} {outbound_date}", decision, webhook_url)
    return record, decision

A daily run is enough for most personal watches; a corporate travel desk tracking several recurring routes can run more often. Unlike a retail SKU, a flight watch also has a natural end: once the outbound date passes, the route stops being a live search and the history for it is done. A crontab entry is the simplest driver on Linux or macOS, using the same five-field POSIX crontab time specification any scheduled job on the platform uses:

bash Copy
# crontab -e β€” check twice daily, 07:00 and 19:00
0 7,19 * * * cd /opt/fare-watch && /usr/bin/python3 check.py >> watch.log 2>&1

Windows Task Scheduler runs the same command on an equivalent trigger, and a serverless timer works too β€” the script holds no state beyond the history file, so a stateless invocation suits it. For a watchlist of several routes, loop check_route() over the list and keep concurrency modest so the API key's rate limits stay comfortable.

What You Get Back

Each check appends one record to the history log, keyed by route and date rather than by URL:

json Copy
// Schema reflects exactly what to_record()/append_history() write.
// Field values are illustrative, matching the fixture used above β€” not a live reading.
{
  "departure_id": "JFK",
  "arrival_id": "LAX",
  "outbound_date": "2026-08-10",
  "return_date": "2026-08-17",
  "trip_type": "round_trip",
  "price": 312,
  "price_level": "low",
  "currency": "USD",
  "checked_at": "13-Jul-2026 15:01 UTC"
}

A few points worth knowing before running this at scale:

  • price and price_insights_lowest can diverge. The former is the cheapest offer this specific response returned; the latter is Google's broader read on the route. Store both if the downstream use case cares about the difference.
  • The stored figure should be the whole fare, not a base rate. US carriers advertising a fare are bound by the federal full-fare advertising rule to quote the entire price a passenger pays, not a stripped-down base rate β€” treat any field the pipeline stores as price the same way, so a comparison across checks is never a base fare on one day and an all-in total on another.
  • Multi-city changes the shape, not the pipeline. data_type: 3 and multi_city_json produce an itinerary-level response instead of a single-leg one; extend to_record to fold each leg's price into a total rather than treating the itinerary as one flat fare.
  • gl and hl affect currency and language, not just wording. Pin both per watch the same way a retail watch pins proxy_country, so a comparison across checks stays on the same market.
  • A nullable price is not a zero. Treat a missing cheapest_price as None, exactly as the extract stage already does β€” an occasional odd response should never inject a false zero into the running low.

Conclusion: one route, one date, one decision

The pipeline reduces to the same shape as any other price watch β€” fetch, extract, store, decide, alert, schedule β€” with the identity of a "watch" redefined for the category: a route and a date pair instead of a URL, a trip-type parameter instead of a single request shape, and a second, target-supplied signal (price_level) available alongside the plain numeric comparison. Extending the watchlist to more routes, more dates, or a multi-city itinerary reuses every stage unchanged; only the parameters passed into Fetch change.

Ready to Build Your AI-Powered Data Pipeline?

Join the community to trade notes with developers building data pipelines on Scrapeless: Discord Β· Telegram.

Sign up at app.scrapeless.com for free Scraping API credits, confirm the scraper.google.flights actor is enabled on the account's plan, and adapt the stages above to the routes the watchlist needs. See pricing for plan details, and the Scraping API docs for the current actor reference.

FAQ

Q: Is scraper.google.flights a real, documented actor?
Yes. A deliberately made-up actor name returns "invalid actor: <name>"; requesting scraper.google.flights returns "disabled actor: scraper.google.flights" instead β€” a distinct error that only applies to a recognized, catalogued actor. It is gated off some plans rather than nonexistent; confirm it is enabled for the target account's plan before centering production traffic on it.

Q: Why key history by route and date instead of a URL, the way a retail watch does?
Because a flight fare has no single canonical page the way a retail SKU does. The same route prices differently by outbound date, return date, and trip type, so the identity of a "watch" has to be that combination, not one URL.

Q: What does price_insights.price_level add over a plain "cheaper than last time" comparison?
It is Google's own classification of whether the current fare is low, typical, or high for the route, computed from data beyond the single response the pipeline just received. Combining it with the running-low comparison lets a route's very first reading be actionable, not just its second and later ones.

Q: Can this handle a multi-city itinerary?
Yes, at the request level β€” data_type: 3 with a multi_city_json array of legs is a documented parameter shape for this actor family. Extending to_record to fold a multi-leg itinerary into one comparable total is the one piece of extra logic a multi-city watch needs beyond what this post builds.

Q: How often should the check run?
A daily or twice-daily run suits most personal fare watches. A flight watch also has a natural stop: once the outbound date passes, the route is no longer a live search, unlike a retail SKU that can be watched indefinitely.

Q: Can this run without an AI agent?
Yes. The Python in Fetch through Schedule runs end to end on its own β€” call, extract, store, decide, alert, and let a scheduler drive the cadence. An agent is a convenient way to drive route and date selection in natural language, but the pipeline itself needs nothing more than Python and a scheduler.

At Scrapeless, we only access publicly available data while strictly complying with applicable laws, regulations, and website privacy policies. The content in this blog is for demonstration purposes only and does not involve any illegal or infringing activities. We make no guarantees and disclaim all liability for the use of information from this blog or third-party links. Before engaging in any scraping activities, consult your legal advisor and review the target website's terms of service or obtain the necessary permissions.

Most Popular Articles

Catalogue