kestrel actors docs

kestrel actorsGuides

How to track competitor prices daily with n8n

Price tracking is not a scraping problem, it is a diffing problem: yesterday's numbers, today's numbers, and only the differences worth an email. Four n8n nodes do it with no database, and the same shape works for hotels, flights, Amazon and eBay.

The short version

  1. Add a Schedule trigger. One run a day at a fixed hour. Prices move on the source's clock, not yours, so a consistent time makes the series comparable.
  2. Call the actor with an HTTP Request node. POST to the run-sync-get-dataset-items endpoint with Header Auth carrying your Apify token. The response is the rows themselves.
  3. Diff against the last run in a Code node. n8n's workflow static data holds the previous prices keyed by entity and date. Compute the change, keep the drops past a threshold.
  4. Send only when there is something to send. An IF node routes to your email or Slack node when the drop list is non-empty, and to a no-op when it is not.

The four-node shape

Schedule (daily 07:00)
  -> HTTP Request  POST run-sync-get-dataset-items   (Header Auth: Bearer <APIFY_TOKEN>)
  -> Code          compare with the last run, keep drops >= 3%
  -> IF            anything to report?  -> Email / Slack     : No-op

That is the entire workflow. The HTTP node:

POST https://api.apify.com/v2/acts/kestrel~booking-prices-scraper/run-sync-get-dataset-items?timeout=300
Authentication: Header Auth  (Authorization: Bearer <your Apify token>)
Body (JSON):
{
  "hotelIds": ["536251", "2508912", "2843226", "4832752", "8306313"],
  "checkIn": "45 days",
  "nights": 2,
  "adults": 2,
  "rooms": 1,
  "currency": "USD",
  "includeRooms": false
}

Two details in that body do a lot of work. checkIn: "45 days" is relative, so the schedule always prices the same lead time and never goes stale — see scheduling with relative dates. And the ids are numeric property ids rather than names, so no lookup happens and the same five properties are priced every morning with no ambiguity.

The diff node

// One item: every hotel whose stay total fell by at least DROP_PCT since the last run.
const DROP_PCT = 3;      // Booking prices a stay per session; 3% filters that noise
const MAX_KEYS = 5000;
const store = $getWorkflowStaticData('global');
store.prices = store.prices || {};

const hotels = $input.all().map(i => i.json).filter(r => r.type === 'hotel' && r.hotel_id);
const drops = [], soldOut = [];

for (const h of hotels) {
  if (h.sold_out || h.price_total == null) { soldOut.push(h.name); continue; }
  const key = `${h.hotel_id}|${h.check_in}`;
  const prev = store.prices[key];
  const change = prev && prev.price ? (h.price_total - prev.price) / prev.price * 100 : null;
  if (change !== null && change <= -DROP_PCT) {
    drops.push({ name: h.name, was: prev.price, now: h.price_total,
                 pct: Math.round(change * 10) / 10, currency: h.currency });
  }
  store.prices[key] = { price: h.price_total, at: new Date().toISOString() };
}

const keys = Object.keys(store.prices);
if (keys.length > MAX_KEYS) for (const k of keys.slice(0, keys.length - MAX_KEYS)) delete store.prices[k];

return [{ json: {
  count: drops.length,
  subject: drops.length ? `${drops.length} price drops` : 'No price drops today',
  table: drops.map(d => `${d.name}: ${d.was} -> ${d.now} ${d.currency} (${d.pct}%)`).join('\n'),
  sold_out: soldOut,
} }];

Three things this gets right that a first draft usually does not:

The same shape, other sources

Only the HTTP body and the key change:

What you are watchingActorKeyRow to diff
Hotel rates for a comp setBooking prices or Google Hotelsproperty + check-inthe stay total
One property over a date windowHotel price trackerproperty + datethe lowest rate
Parity breachesRate parityproperty + datewho is under the official rate
AirfaresFlight price trackerroute + departure datethe day's cheapest fare
Amazon competitorsAmazon searchASIN + marketplacethe result-page price and rank
eBay marketeBay searchitem idprice, and units sold since yesterday

For Amazon and eBay, the interesting diff is often not the price. On Amazon it is the change in ratings count between runs — the closest public proxy for sales velocity. On eBay it is the change in units sold, which tells you the asking price is being paid.

Let the actor do the filtering

Every filter you can push into the run input is money you do not spend and rows you do not diff. A price ceiling on a hotel or flight run delivers only the rows under your target, so a quiet day costs nothing at all and the workflow simply does not fire. The status row still reports how many rows were filtered and what the cheapest was, so you can log "nothing under 90 EUR today, cheapest was 112" without paying for the 112.

That is the argument for putting the threshold in two places: a hard one in the run input to control cost, and a soft one in the code node to control noise.

Ready-made workflows

The examples repository carries importable n8n workflows for this exact pattern — Booking price drops, Google Hotels price-drop alerts, the Google Flights cheapest-day feed, Amazon best-seller rank tracking and several negative-review monitors. Each is a workflow.json you can import plus a description of what to change: github.com/mtedj/kestrel-actors-examples.

Run it

Any of the price actors slots into the shape above. Start with Booking.com prices for hotels, Google Hotels if you want every OTA in one row, or the flight price tracker for routes. Related: scheduling with relative dates and how not to overpay for scraped rows.

FAQ

Do I need a database for price history?

Not for alerting. n8n's workflow static data holds the last value per key, which is all a "cheaper than yesterday" alert needs — a few thousand keys is fine. You need a database when you want the full series for analysis, and at that point write the dataset rows to it and keep the alerting logic where it is.

Why call run-sync-get-dataset-items instead of starting a run?

Because it returns the rows in the HTTP response, so one node does the whole job with no polling and no webhook. Give it a generous timeout — five minutes is a sensible default for a run of a few hundred rows — and use the asynchronous run endpoint with a webhook only for jobs that legitimately take longer.

How do I stop the alert being noisy?

A percentage threshold, and the right source. Booking prices a stay per session, so two runs seconds apart can differ by a few per cent with nothing having changed — a 3% threshold filters that out. Google Hotels rates are steadier. If an alert is still chatty, raise the threshold before you blame the data.

Does this work in Make or Zapier?

Yes. Any tool with a scheduler, an HTTP node and somewhere to keep the last value does it. In Make it is the Apify "Run an Actor" module with the same JSON body; the diffing step is the only piece that has to be written by hand anywhere.

Run Booking.com Scraper on Apify →

Other actors used on this page: apify.com/kestrel/google-hotels-prices, apify.com/kestrel/hotel-price-tracker, apify.com/kestrel/flight-price-tracker, apify.com/kestrel/amazon-search-scraper, apify.com/kestrel/ebay-search-scraper.

More guides