DEV Community

Cover image for Automate hotel rate parity checks: which OTAs undercut your direct rate (Python + n8n)
Tedj MEABIOU
Tedj MEABIOU

Posted on

Automate hotel rate parity checks: which OTAs undercut your direct rate (Python + n8n)

Open your property on Google Hotels and you see what a guest comparing channels sees: Booking.com, Expedia, Agoda, Vio.com and your own site, each with a nightly rate. One of them under your direct rate is a hotel rate parity problem found by looking, and looking does not cover a comp set, run at 07:00 or record how long the gap lasted; a rate shopping tool sells that per property per month. The alternative is rows: one per property and stay, every source side by side, the parity math done.

This is how to get that row as JSON with no API key (there is no public Google Hotels API for prices), keep only the breaches, and run rate parity monitoring from Python or n8n.

1. One request, one row per property and stay

The Hotel Rate Parity Checker on Apify takes Google Hotels URLs or property names, a check-in date and a length of stay, reads Google's OTA price comparison for each property and returns one parity row: every source with a rate, cheapest first, and the summary.

curl -X POST "https://api.apify.com/v2/acts/kestrel~hotel-rate-parity/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hotels": ["https://www.google.com/travel/hotels/entity/ChkIhLCQwvjO2IYWGg0vZy8xMXE0bTZieDkyEAE"], "checkIn": "30 days", "nights": 2, "adults": 2, "currency": "USD", "country": "us"}'
Enter fullscreen mode Exit fullscreen mode

A parity row:

{ "type": "parity", "hotel_name": "Brown's | Avenue Hotel", "entity_id": "ChkIhLCQwvjO2IYWGg0vZy8xMXE0bTZieDkyEAE",
  "check_in": "2026-09-27", "check_out": "2026-09-29", "nights": 2, "adults": 2, "children": 0, "currency": "USD",
  "sources": [
    { "source": "Vio.com", "source_id": 2017, "official": false, "price_nightly": 377.39, "price_total": 754.78, "free_cancellation": null, "url": "https://www.vio.com/…" },
    { "source": "Brown's Avenue", "source_id": 12058, "official": true, "price_nightly": 488.46, "price_total": 976.92, "free_cancellation": null, "url": "https://be.synxis.com/?hotel=…&arrive=2026-09-27&depart=2026-09-29" },
    { "source": "Booking.com", "source_id": 184, "official": false, "price_nightly": 563.3, "price_total": 1126.61, "free_cancellation": null, "url": "https://www.booking.com/hotel/pt/browns-avenue.html?checkin=2026-09-27&checkout=2026-09-29" },
    { "source": "Expedia.com", "source_id": 232, "official": false, "price_nightly": 573.94, "price_total": 1147.88, "free_cancellation": null, "url": "https://www.expedia.com/…" } ],
  "n_sources": 4, "min_source": "Vio.com", "min_price": 377.39, "max_source": "Expedia.com", "max_price": 573.94, "spread_pct": 52.1, "median_price": 525.88,
  "official_source": "Brown's Avenue", "official_price": 488.46, "official_is_cheapest": false, "undercut_by": ["Vio.com"], "undercut_pct": 22.7,
  "fetched_at": "2026-08-29T06:15:42+00:00" }
Enter fullscreen mode Exit fullscreen mode

The official site is the one Google flags; when Google lists none, official_price and undercut_pct are null. hotelNames takes names instead, one place search each, kept only when the listing's name matches most of your words. A free status row per property and stay says ok, filtered, no_rates, not_found or duplicate.

2. The parity math, and a filter that runs before billing

Every figure is each source's lowest nightly rate for your occupancy. From the row above:

  • Spread: (573.94 − 377.39) ÷ 377.39 × 100 = 52.1%, across every source, official site included.
  • Undercut: direct is 488.46 and the cheapest 377.39, so official_is_cheapest is false; undercut_by lists every non-official source under 488.46, worst first; undercut_pct = (488.46 − 377.39) ÷ 488.46 × 100 = 22.7%, the discount for not booking direct.
  • Median 525.88: one outlier, or the whole market moved.

minSpreadPct: 5 delivers only properties whose spread reaches 5%; the rest get a status row with filtered: true and are not billed. The spread includes the official site, so a property undercut by N% always has a spread of at least N%: the filter never hides a breach above your line. It does pass a gap the other way, an OTA above direct, which is why the script below also checks undercut_by.

{ "hotelNames": ["Hyatt Regency Lisbon", "Altis Grand Hotel Lisbon", "Brown's Avenue Hotel Lisbon"], "checkIn": "14 days", "nights": 2, "sweepDays": 6, "minSpreadPct": 5, "currency": "EUR", "country": "pt" }
Enter fullscreen mode Exit fullscreen mode

That is a rate parity checker on a schedule: a relative checkIn stays the same distance ahead, sweepDays: 6 covers a week of check-in dates, and on a day when every property is in parity the run costs nothing.

3. Python: a violations log you own

import csv, os
from apify_client import ApifyClient

COMP_SET = ["Hyatt Regency Lisbon", "Altis Grand Hotel Lisbon", "Brown's Avenue Hotel Lisbon", "Tivoli Avenida Liberdade Lisbon", "Bairro Alto Hotel Lisbon"]
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("kestrel/hotel-rate-parity").call(run_input={
    "hotelNames": COMP_SET, "checkIn": "14 days", "nights": 2, "sweepDays": 6, "minSpreadPct": 3, "currency": "EUR", "country": "pt"})
rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
breaches = [r for r in rows if r["type"] == "parity" and r["undercut_by"]]

with open("parity_log.csv", "a", newline="") as f:
    w = csv.writer(f)
    for r in breaches: w.writerow([f'{r["hotel_name"]}|{r["check_in"]}', r["fetched_at"][:10], r["currency"], r["official_price"], r["min_source"], r["min_price"], r["undercut_pct"], ";".join(r["undercut_by"]), r["sources"][0]["url"]])
for r in sorted(breaches, key=lambda r: -r["undercut_pct"]): print(r["hotel_name"], r["check_in"], f'{r["undercut_pct"]}% under direct via {r["min_source"]}')
print([(r["query"], r["status"]) for r in rows if r["type"] == "status" and r["status"] != "ok"])
Enter fullscreen mode Exit fullscreen mode

After the first run, copy each row's entity_id into hotels in place of hotelNames: exact, no search, and a generic name can never resolve to a stranger. Daily, the CSV is hotel price monitoring for the comp set: undercut_pct per hotel|check_in key over time is the parity violation report, and the last column is the offending OTA's booking link.

4. Slack alerts without code (n8n)

No template exists for this one yet; wire it in n8n like the review-alert templates in the n8n/ folder of kestrel-actors-examples:

  1. Schedule trigger, 07:00.
  2. HTTP Request: POST the section 2 input to the run-sync-get-dataset-items URL above; each row becomes one item.
  3. IF: {{ $json.type }} is parity and {{ $json.undercut_pct }} ≥ 3.
  4. Slack: hotel_name, check_in, min_source at min_price vs direct official_price, undercut_pct, sources[0].url.

5. Cost and limits

  • $0.01 per delivered parity row, once per property and stay per run. Five properties, seven check-in dates: at most $0.35 per run, $10.50 for a month of mornings. With minSpreadPct set only breaches are delivered and billed, so a quiet day is $0. Status rows, unresolved names, no-rate properties and duplicates are free.
  • Each source's rate is its lowest offer for the occupancy, not the same room on every channel. Room-level parity needs the parent Google Hotels Prices Scraper with offerLevel: "rooms", one row per source and room at $0.002.
  • Set country to the market you sell in: sources, prices and tax display differ by market.
  • The actor reads publicly displayed prices, the figures any visitor sees without logging in, and stores no personal data. Comparing public rates across channels is standard practice in the industry, but terms of service and local law differ; check that your use complies, and keep the data for your own analysis rather than republishing it as Google's.

That is the pipeline: one call for the comparison, minSpreadPct for the alert, a schedule for the history. Full input/output reference and FAQ on the actor page: apify.com/kestrel/hotel-rate-parity.

Every actor's inputs, output fields, a sample row and its honest limits are documented at mtedj.github.io/kestrel-actors-examples, including a side-by-side comparison of every review scraper — what each source really carries and where its ceiling is.

Top comments (0)