Is today a good day to book? Scores today's cheapest fare against the price history it builds for your route and answers BOOK_NOW, GOOD_PRICE, TYPICAL or WAIT. It answers on the first run. $0.01 a run plus $0.06 a fare scored; the flight search itself is billed to you by the scraper.
Pricing
from $60.00 / 1,000 fare observation stored and scoreds
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.