# Changelog of Flight Fare History API: is today a good day to book this? (`frameprobe/flight-fare-history-api`) Actor

- **URL**: https://apify.com/frameprobe/flight-fare-history-api/changelog.md
- **Full Actor documentation**: https://apify.com/frameprobe/flight-fare-history-api.md

## Changelog

### 2026-09-29

- **Running it with just a route now builds a history.** With no departure dates it used to watch "60 days from today", which was a different flight every day, so a daily schedule never got past its first answer. It now picks one departure 45 to 72 days out and keeps it for 28 days, so from the fourth daily run it scores today's fare against the fares it saw on the same flight before. The first three runs on each date still price 6 nearby dates as a yardstick.

### 2026-09-23 (listing, not the Actor)

Nothing about how this Actor runs changed.

- **The page now opens with what this Actor takes, what it returns and what it costs**, with a working example input you can copy. It used to open by repeating its own title back to you.
- **The price is on the page**, not only on the Pricing tab.
- **There is a ready-made example in the Examples tab**, so you can run a real configuration without filling anything in.

### 0.1 - 2026-09-05

First build. Watch a route and departure dates; get a book-now / wait verdict scored against the
fare history accumulated in the buyer's own key-value store.

- Series identity carries every price-changing input (route, date, cabin, passengers, stops,
  carrier filter), so two different products can never share one history.
- Verdicts: `BASELINE`, `INSUFFICIENT_HISTORY`, `BOOK_NOW`, `GOOD_PRICE`, `TYPICAL`, `WAIT`,
  with a separate `trend` and a `confidence` that reflects how much history exists.
- `BOOK_NOW` requires the price to be at the observed floor AND materially below the median AND
  in the cheapest quarter. An earlier percentile-only rule fired on any dip below a flat median,
  which made the strongest verdict meaningless on stable routes.
- Access is left to an upstream scraper run as a sub-run and billed to the buyer, so this Actor
  competes on interpretation rather than on staying unblocked.
- Charging: `actor-start` only after validation passes; `fare-observation` only when an itinerary
  produced a price, a stored history entry and a verdict.

### 0.2 - 2026-09-05

Solves the cold start. v0.1 returned `BASELINE` on the first run of any itinerary and
`INSUFFICIENT_HISTORY` on the next two, which meant a first-time buyer paid and got nothing they
could act on - the likeliest way this Actor dies on the shelf.

- A verdict now names the `basis` it was computed from: `history`, `route`, `upstream_typical`
  or `none`. `confidence` is capped by that basis, so a cross-section of dates read in one minute
  can never report `high` and be mistaken for a month of watching.
- New `route` basis: the same route across a spread of departure dates, all priced in the same
  run. It answers "of the dates you could fly, is this one cheap", which is answerable on run one.
- New `routeBaselineDates` input (default 6, max 12, 0 to disable). When a run's itineraries have
  too little history to score, it prices that many extra departure dates - whole weeks either side
  of the watched date, so day of week is held constant. Spending stops once the run has priced 7
  dates in total, which is the width a date needs to be scored at `medium` given that a date is
  never counted in its own baseline; listing 7 `departDates` makes the cross-section free. Nothing
  is bought when the history already answers, when a `datasetId` was supplied, or when the budget
  is 0. A one-date first run therefore costs 7 upstream searches instead of 1.
- Route-baseline dates come back as `rowType: "routeSample"` rows with their prices, so the
  cross-date median is checkable by hand. They are never stored to a series and never charged.
- `BOOK_NOW` remains reachable only from `history`. A set of prices read in the same minute has
  no time axis, so it cannot establish the floor that verdict asserts.
- The upstream's own typical-price figure is read when a scraper publishes one, as the weakest
  basis and at `low` confidence. It costs no extra search. Verified against a live
  `memo23/google-flights-scraper` run: the default upstream publishes no such field, so on it the
  ladder is `history` -> `route` -> `none`. The tier is kept because it costs nothing when absent.
- Fixed: the carrier was read from the first segment because `carrier-name` was missing from the
  field list. On a connecting itinerary flown by two airlines that named only the first leg. Found
  by checking the extractor against a real upstream row rather than an invented one.
- New `tests/test_upstream_shape.py` pins the default upstream's real output shape as a drift
  canary, so a renamed column fails here instead of on a buyer's paid run.
- `maxItineraries` default drops from 30 to 20. The upstream bills the buyer per result returned,
  so this is the largest lever on what a run actually costs, and the old default contradicted this
  Actor's own documented advice that raising it rarely changes the minimum. Measured on LAX-JFK
  2026-11-02: results 10-29 contained nothing below the $205 minimum already present in the first
  ten. Not lowered further because missing the true minimum would corrupt the series, which is a
  worse failure than a slightly larger bill.
- README now states what a run costs on BOTH halves of the bill, with three worked examples. The
  Store's pricing widget shows only this Actor's events, and on a cold-start run those are about a
  twelfth of the all-in cost.
- `BASELINE` and `INSUFFICIENT_HISTORY` are now the last resort rather than the first answer, and
  their reason text says how to get a verdict today rather than in a fortnight.
- Orchestration is two passes (price every date, then score every date), because a date cannot be
  compared with the others in its run until they exist. Every upstream fetch is cached in the
  run's own key-value store, so a migration mid-run does not re-buy searches the buyer already
  paid for - the protection the old push-as-you-go loop gave for free.

### 0.3 - 2026-09-05

From the first real runs on the platform, and from what the publish dialog revealed.

- **The default input no longer contains a date.** `departDates` had a hardcoded `2026-11-02`
  prefill. Apify auto-tests a published Actor against its default input every day and marks the
  listing "under maintenance" after three consecutive days without a non-empty dataset, so that
  literal was a timer: the day it slipped into the past this Actor's own validation would reject
  it, the run would fail, and the listing would publicly flag itself as broken. With no dates
  supplied the Actor now watches one departure `DEFAULT_LEAD_DAYS` (60) out, computed at run time.
  This also makes `origin` and `destination` genuinely the only required fields, which is point 2
  of the -API spec and was not previously true.

- **`UPSTREAM_TIMEOUT` drops 240s -> 90s.** A search of the default upstream measures 25-28s. At
  240s a single hung sub-run plus six healthy ones is 408 seconds, which blows the five-minute
  auto-test ceiling on its own.

- **New wall-clock budget on the route-baseline phase** (`BASELINE_BUDGET_SECONDS`, 175s). No new
  baseline search is started past it. Sized so it never bites on a normal day - the last of six
  probes starts around 168s - so the spread is not quietly reduced for everyone. On a slow day
  probing stops, the cross-section narrows, and confidence drops to match, which the verdict layer
  already reports honestly. `routeBaselineStoppedEarly` says so on the summary row.

- **Memory default drops 1024MB -> 256MB (min 128, max 2048).** Two live runs peaked at 57MB
  against a 1GB allocation while averaging 1.33% CPU: the run is bound by waiting on upstream
  sub-runs, not by compute. Platform cost per run falls from $0.010 to about $0.0025. On a
  cold-start run that moves our profit from $0.002 to $0.0095, because at 1GB the compute cost was
  eating five sixths of what we keep. Buyers can still raise it; the ceiling is unchanged.

- Recorded for the monetization setup, not a code change: the `apify-actor-start` synthetic event
  is charged by Apify automatically and MUST NOT be charged from Actor code, so this Actor keeps
  its own `actor-start` custom event and the synthetic one is removed in Console. The synthetic
  `apify-default-dataset-item` event is removed too: it bills per row written to the default
  dataset, and one watched itinerary writes eight rows (one observation, six route samples, one
  summary), which would charge a buyer eight times for one itinerary and contradict the pricing
  promise in the README.

### 0.4 - 2026-09-07

Listing honesty. The Store description, the README and the `fare-observation` event description all
claimed something the measurement does not support, on a live listing.

- **The claim "run one is not wasted: it prices a spread of nearby dates and tells you where yours
  sits" was true about a third of the time.** Measured 2026-09-06 across 34 controlled searches,
  `memo23/google-flights-scraper` returns an empty dataset on ~39% of searches. A one-date first run
  therefore yields a route verdict 34% of the time, a price with `basis: none` 27%, and no price at
  all 39%.
- **All three surfaces now state the measured rate with its date, as a figure, and promise a
  labelled outcome rather than a verdict.** Every row names its `basis` and `confidence` or says it
  has neither. The figures move with the upstream; the guarantee that the row tells you which case
  you got does not. Improving the success rate edits one number rather than rewriting the claim.
- `medium` confidence needs all six baseline dates to price, which is a ~3% event at the measured
  upstream rate. The README says so instead of implying it is normal.
- The `fare-observation` description no longer implies a verdict is always produced: it charges for
  the observation, which is the thing that accumulates, and says the verdict may be BASELINE or
  INSUFFICIENT\_HISTORY.

### 0.5 - 2026-09-07

Respects the buyer's own spending limit, which this Actor previously ignored entirely.

- **`Actor.charge()` returns a `ChargeResult` and it is now read rather than discarded.**
  `ACTOR_MAX_TOTAL_CHARGE_USD`, `event_charge_limit_reached` and `chargeable_within_limit` were
  referenced nowhere in the source.
- **The work is trimmed to what the limit can pay for BEFORE any upstream search starts.** This is
  the whole point: the expensive part of a run is not our compute, it is the sub-runs billed to the
  BUYER's account, and their charge limit does not govern those. Previously a buyer with a low
  limit would fund seven upstream searches, then be unable to be charged or delivered the rows
  those searches paid for - paying the supplier, receiving nothing, and reasonably blaming us.
- **If the limit is reached mid-run, it stops cleanly**: everything charged has already been
  pushed (rows are pushed before they are charged), no further sub-runs are started, and the
  remaining itineraries get reason rows.
- **The message names the buyer's own limit and never blames the upstream.** It states the limit,
  how many observations it allows, that the searches beyond it were not started so nothing was
  spent on their account, and the one setting to change.
- Unknown is not zero: an unpriced Actor, a local run or an SDK that reports no budget leaves the
  run unrestricted. Refusing to work because a limit could not be read would be a worse failure
  than ignoring it.
- Paired Console setting, made the same day: `minimalMaxTotalChargeUsd` set to $0.015, covering the
  start event plus one fare observation, so the limit cannot be set below one useful run.

### 0.6 - 2026-09-07

Switches the default upstream on measurement, and makes `upstreamActor` genuinely pluggable.

- **Default upstream is now `automation-lab/google-flights-scraper`.** Measured against
  `memo23/google-flights-scraper`, which had been the default: **30 of 30 searches returned results
  versus memo23's 61%**, at roughly a quarter of the price per search. Effective cost per result
  that exists falls from about $0.041 to $0.0096.
- **The switch was gated on a correctness question, not a coverage one.** If two suppliers reported
  different minimums for the same query, pre- and post-switch observations in a buyer's series
  would not be comparable and the accumulated asset would be silently corrupted. Measured across
  four routes: **6 comparisons where both suppliers answered, 6 exact agreements, 0 disagreements.**
  No history marker is needed.
- **New `adapters.py`.** Upstreams disagree on field names (`departDate`/`maxItems` versus
  `departureDate`/`maxResults`) and on which filters they support. An unrecognised upstream keeps
  the historical field shape, so naming an unfamiliar scraper behaves exactly as before.
- **A filter that the upstream cannot apply is never silently dropped.** `maxStops` is enforced by
  this Actor when the upstream cannot, including rejecting rows whose stop count is unreadable -
  guessing would put a possibly-violating fare into a series whose key promises otherwise.
  `airlines` is refused outright at input validation when the chosen upstream cannot filter by
  carrier, because carrier arrives as a display name while the input takes IATA codes.

### 0.7 - 2026-09-07

Re-measures the listing figures against the new upstream. The switch understated the product, and
understating is a cost too.

- **README figures re-measured end to end on the live Actor:** 58 searches with 0 empty, and four
  first runs on four fresh routes each producing a price, a route verdict, and `medium` confidence
  with all six baseline dates priced. The previous figures (34% route verdict, 27% `basis: none`,
  39% no price) were correct for the old upstream's 61% success rate and are now wrong low.
- The older figure is kept in context rather than deleted, because it is the reason every row is
  labelled: the numbers move with the upstream, the labelling does not.
- **The Store description needed no change**, which was the point of writing it without a rate in
  it. It promises a labelled outcome, not a verdict, so a change in reliability does not falsify
  it.
- **The event descriptions needed no change either.** `fare-observation` says the verdict "may be"
  BASELINE or INSUFFICIENT\_HISTORY, which stays true and is simply rarer now; nothing in either
  event description depended on the old success rate.

### 0.8 - 2026-09-08

Stores the complete upstream record, adds the verdict that needs it, and reprices to match.

- **Every observation now stores the ENTIRE upstream row, not five parsed fields.** A past day's
  fares cannot be re-scraped at any price, so a field not stored is lost permanently. The parsed
  five were chosen against the questions we had the day the code was written; the value of a series
  is almost entirely in the questions asked later. ~1.5KB per observation.
- **What that had already cost us:** `duration-minutes` was being discarded, so this Actor could not
  tell whether a fare got cheaper by getting four hours longer - a fake bargain it had been
  reporting as a real one since launch.
- **New `PRODUCT_CHANGED` verdict.** When today's cheapest differs materially in duration or stops
  from what the series has been tracking, it REPLACES the price verdict rather than sitting beside
  it, because a pipeline branching on `GOOD_PRICE` would otherwise book a downgrade. Reachable only
  from `basis: history`, and only against observations stored on or after 2026-09-08. The README
  states that limit rather than implying the whole series supports it.
- **Records written before the widening still load**, with `raw` absent rather than empty. Those
  days cannot be re-fetched, so the fields do not exist for them and nothing is inferred.
- **`fare-observation` repriced $0.005 -> $0.06.** The old price was set against the per-row rate of
  the scrapers this Actor sits beside, which was the wrong comparison: it is not a scraper. Measured
  against the buyer's real alternative - building 60 days of history themselves costs ~$0.58 of
  upstream data plus the scheduler, storage, currency handling and verdict logic - 60 days here is
  under $5 all-in. The listing says what changed with the price.
- **Listing copy now answers the conversion question a history product has to answer**: what the
  buyer sees on day 1, day 4 and day 30, the total 60-day cost as a single bounded number, and that
  the accumulated history lives in their own account and outlives any relationship with us.

### 2026-09-11

- **`.actor/actor.json` now carries the live title and description** ("Flight Fare History API: is
  today a good day to book this?"), read from the Actor's own record and the Store search index on
  2026-09-11, identical on both. It still carried the replaced text ("key-value store", "names its
  basis and confidence"), which is what the next push would have shipped. On 2026-09-11 the Store
  index showed that old text for part of the day while the Actor record held the new; by run 16 the
  two agreed with no change made, so what caused the divergence is still unknown.
  `checks/check_vocab.py` now reads every `actor.json`, so the old text would fire.
