How to check hotel rate parity across OTAs
Rate parity work is one comparison repeated: for this property, on this date, is anyone cheaper than our own booking engine? Doing it by opening five OTAs is slow and not reproducible. Doing it from the one page where every source already prices the same room takes a single row per date.
The short version
- Fix the stay, the market and the currency. Parity is only meaningful within one market: whether taxes are shown follows the market's display rules, so a US view and a German view of the same hotel are not comparable.
- Name the properties by entity id. Resolve each hotel by name once, check the id, then run on ids. Names are matched by best guess and two "Grand Hotel" in one city resolve to whichever Google prefers.
- Sweep a window of dates. Parity breaks on specific dates, not on all of them. A six-day sweep at a fixed lead time catches the ones a single spot-check misses.
- Read undercut_by, not the spread alone. The spread tells you the market is messy; undercut_by names the channels selling below your direct rate, which is the list you can act on.
What a parity row is
One row, one property, one date, every source Google lists for it, already compared:
{ "type": "parity", "hotel_name": "Brown's | Avenue Hotel",
"check_in": "2026-09-27", "check_out": "2026-09-29", "nights": 2, "adults": 2, "currency": "USD",
"sources": [
{ "source": "Vio.com", "official": false, "price_nightly": 377.39, "price_total": 754.78 },
{ "source": "Brown's Avenue", "official": true, "price_nightly": 488.46, "price_total": 976.92 },
{ "source": "Booking.com", "official": false, "price_nightly": 563.30, "price_total": 1126.61 },
{ "source": "Expedia.com", "official": false, "price_nightly": 573.94, "price_total": 1147.88 }
],
"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 }
Read the last line first. The hotel's own booking engine is being undercut by 22.7% by a wholesaler-fed OTA. That is the alert. Everything above it is the evidence.
Working code
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("kestrel/hotel-rate-parity").call(run_input={
"hotelNames": ["Hyatt Regency Lisbon", "Altis Grand Hotel Lisbon"],
"checkIn": "30 days", # relative: the schedule keeps the same lead time
"nights": 2,
"sweepDays": 6, # six consecutive check-in dates
"currency": "EUR", "country": "pt",
})
rows = [r for r in client.dataset(run["defaultDatasetId"]).iterate_items() if r["type"] == "parity"]
for r in sorted(rows, key=lambda r: -(r["undercut_pct"] or 0)):
if r["undercut_by"]:
print(r["hotel_name"], r["check_in"],
f'{r["undercut_pct"]}% under direct via {r["undercut_by"][0]}',
f'({r["min_price"]} vs {r["official_price"]} {r["currency"]})')
Two properties over six dates is twelve rows — twelve cents. A fifty-property estate at a weekly cadence over a 30-date window is 1,500 rows a week, about $15. That is the entire budget for something rate-shopping platforms charge per property per month.
Why the comparison is one row rather than a join
You could read every source for every property yourself and compare in your warehouse. It costs more, in three ways.
First, rows: pricing a property across five sources is five rows to store and reconcile; the comparison is one. Second, alignment: sources appear and disappear by market and by day, so "Expedia was missing on Tuesday" has to be distinguished from "Expedia was more expensive on Tuesday". Third, occupancy: every source has to be asked for the same party or the comparison is meaningless, which is easy to get wrong when each OTA has its own occupancy semantics.
At $0.01 the parity row is the most expensive row in the suite, and it is still cheaper than the alternative — it is one call that carries a whole comparison, and a property with no rates at all is a free status row rather than a charge for nothing.
Sweep dates, not just properties
Parity does not break uniformly. It breaks on the dates where a channel manager pushed a promotion, where a wholesaler's allotment is being dumped, or where your own engine forgot a restriction. A single spot check on one arbitrary date finds a fraction of it.
A window of consecutive check-in dates at a fixed lead time is the shape that finds real breaches: run it daily with a relative check-in and a sweep, and you get both a date-of-stay view (which nights are exposed) and a booking-curve view (how the exposure changes as the date approaches).
Reading the numbers without over-reacting
spread_pctis max over min. A wide spread is not itself a breach — it usually means one source has a non-refundable or a members-only rate in the mix.official_is_cheapestis the single boolean most direct-booking strategies are actually trying to protect.undercut_byis the action list. One name on it is a conversation with a channel; four names is a distribution problem.median_priceis the honest "market rate" for a property on a date — less jumpy than the minimum, which is often a single outlier.
The honest limitations
- Rates are what Google displays for the market and currency you choose, including that market's tax display rules. Compare within one market.
- A few properties carry no official flag because Google files them under a marketplace brand; the row then has a null official price.
- Name lookups are guarded, not clairvoyant. Two similarly named properties in one city resolve to Google's best match — check the entity id once, then run on ids.
- This is occupancy-level, not room-level parity. Each source's lowest offer for your party, not a matched room type.
- Sources per property vary by day, typically 3 to 25, so a drop from six sources to three is a data point about distribution, not necessarily a scraping failure.
Run it
Hotel Rate Parity Checker — $0.01 per parity row, with status rows and properties that returned no rates free. For the underlying prices with every source as its own row, see Google Hotels prices; for a single property tracked across a date window, Hotel Price Tracker; and for the daily-alert version, tracking competitor prices with n8n.
FAQ
What counts as a parity breach?
A source priced below the property's own booking engine for the same stay and occupancy. The row gives you it directly: official_price is the direct rate, undercut_by lists the sources beneath it, and undercut_pct is how far the cheapest one is below you.
Why does one property have no official price?
Because official-site detection is Google's. A few properties are listed under a marketplace brand — a property manager, a franchise booking engine — and carry no official flag, so official_price is null. The other sources still compare among themselves.
Is a 52% spread normal?
It is common enough that you should not treat it as an error. In the verified example, four sources on one Lisbon property ranged from 377.39 to 573.94 for the same two nights, with the hotel's own engine at 488.46 — third of four. That is what an unmanaged parity position looks like.
Can I check room-level parity?
Not from the comparison row, because the comparison shows each source's lowest offer for the occupancy, not a matched room type. For room-level work, price the property per source with room-level offers and match the room names yourself — they differ per channel, which is exactly why the cheap comparison is done at occupancy level.
Run Hotel Rate Parity Checker on Apify →
Other actors used on this page: apify.com/kestrel/google-hotels-prices, apify.com/kestrel/booking-prices-scraper.