Hotel Price Data for Rate Monitoring: Fields and Costs
A rate-monitoring job needs one price per hotel, per stay date, per occupancy, in a known currency, tied to a room and a rate plan. Booking and Expedia room calls return room-level prices, and region searches on Booking, Expedia and Trivago return one price per hotel. Checking 200 hotels over 30 dates with room calls is 200 × 30 = 6,000 calls × 1 credit = 6,000 credits per source.
What does a rate-monitoring job need from a hotel price feed?
It needs five things on every row: the stay dates, the occupancy, the currency, the room or rate plan the price belongs to, and the source it came from. Without the first three, two prices can't be compared.
| Field | Why a rate check needs it | Where the recordings show it |
|---|---|---|
| Check-in and check-out | A price is for a stay, not a night. Length of stay changes the rate. | Your request; Expedia and Trivago echo check_in and check_out in the response |
| Occupancy | Rates change with the number of adults. | Your request (group_adults, adults); Booking rooms also list occupancy per room |
| Currency | Sources return different currencies for the same query. | Expedia currency (EUR), Trivago currency (USD), Booking inside the price text ($351.05, € 588) |
| Room type | Compares like with like across days. | Booking name, occupancy, beds; Expedia unit_id, name, features |
| Rate plan | The same room is sold at several prices. | Expedia rate_plans[].rate_plan_id |
One gap, stated once: neither room response includes cancellation terms. The only cancellation signal in the recordings is Expedia's hotel-level free_cancellation flag in search results, so if your rate comparison depends on refundable versus non-refundable rates, plan around that.
What do the Booking, Expedia and Trivago responses actually contain?
Hotel-level search responses carry a name, a price, a rating and an identifier you can feed into the next call. Room-level responses carry the rooms and their prices for one hotel. The excerpts below are trimmed from recorded responses for 20 to 23 November 2026, three nights. The Booking and Expedia requests used two adults; the Trivago search request had no adults parameter.
Hotel-level prices from region searches
The Booking.com prices endpoint returns hotels for a destination with a price string:
"prices": [
{
"name": "Royal Saint Michel",
"url": "https://www.booking.com/hotel/fr/royal-saint-michel.html",
"price": "$351.05",
"review_score": "8.8",
…
}
],
"total_prices": 25
The price is text with a currency symbol, and the response doesn't label whether it covers the stay or one night. Check that against the page before you compare it with anything.
The Expedia hotels-search endpoint returns numbers, a currency and both stay and nightly figures:
{
"property_id": "564660",
"name": "Mercure Paris Centre Tour Eiffel",
"total_price": 690,
"price_per_night": 230,
"free_cancellation": false,
"sponsored": true,
"currency": "EUR"
}
A discounted hotel in the same response also carries original_price (1329 against a total_price of 1203).
The Trivago search endpoint returns hotel records with id, stars, rating, review_count and provider_count, plus a forecast_price per night and a destination-wide price_histogram. forecast_price is a monthly forecast: it carries a month field (2026-11 and 2027-01 in the recording), so it is not the price for the dates you searched. The recorded hotels carry no per-hotel stay total, so treat Trivago search as a market-shape source. The Trivago deals endpoint takes a hotel ID, dates, rooms, children_ages[] and currency; there's no recording of it here, so try it in the playground first.
Room-level prices from rooms calls
The Expedia hotels-rooms endpoint returns each room with its rate plans:
"currency": "EUR",
"rooms": [
{
"unit_id": "226385",
"name": "Classic Room, 1 Double Bed",
"features": ["18 sq m", "Sleeps 2"],
"rate_plans": [
{ "rate_plan_id": "397104173", "total_price": 690, "price_per_night": 230 },
{ "rate_plan_id": "397104181", "total_price": 825, "price_per_night": 275 }
]
},
…
]
This is the same hotel as the first Expedia search result, and its cheapest rate plan (690) matches the search's total_price, so the rooms call shows what sits above the search price. Use total_price as the key figure: the second room's 700 over three nights is shown as 233 a night, so price_per_night is rounded.
The Booking.com rooms call (/v1/booking/rooms) returns a smaller record:
{
"name": "Attic Single Room",
"price": "€ 978 € 588 Original price € 978 Current price € 588 € 326.12 × 3 nights € 978.35 Limited-time Deal …",
"occupancy": "1 adult",
"beds": "1 twin bed",
"available": true
}
The price field is one text blob holding the original price, the current price and a deal note. Extract the number after "Current price" and keep the raw string. occupancy is the room's stated capacity, not an echo of your group_adults, so don't use it to verify your query.
Should you monitor with region searches or room calls?
Use both, for different jobs. A region search answers "which hotels moved" at one call per page of results. A rooms call answers "which room and rate plan moved" for one hotel, at one call per hotel per date. They return different things, so a search's cost per date is not a cheaper way to get room prices.
| Region search | Rooms call | |
|---|---|---|
| Prices | One per hotel | One or more per room |
| Calls per date | One per page of results | One per hotel |
| Good for | Spotting a market-wide move, finding hotels to track | Rate-plan changes, room mix, undercut checks |
| Endpoints | Booking prices, Expedia hotels-search, Trivago search |
Booking rooms, Expedia hotels-rooms |
A practical pattern: search across your 30 dates to see where the market moves, then run room calls for your own hotels and a fixed competitor set. More sources are on the Travel APIs page.
What does checking 200 hotels over 30 dates cost?
A full room sweep of 200 hotels over 30 check-in dates is 6,000 calls, and every successful call costs 1 credit, so 6,000 credits per source and occupancy. Failed calls cost nothing. What a credit costs is on the pricing page; the arithmetic below stays in credits.
| Job | Calls | Credits |
|---|---|---|
| One room sweep, one source, 2 adults | 200 × 30 = 6,000 | 6,000 |
| The same sweep for 1 and 2 adults | 2 × 6,000 = 12,000 | 12,000 |
| Weekly sweeps for a month (4 runs) | 4 × 6,000 = 24,000 | 24,000, within the Build pack's 50,000 |
| Daily sweeps for a month (30 runs) | 30 × 6,000 = 180,000 | 180,000, within the Growth pack's 200,000 |
| Region search, 30 dates, first page only | 30 × 1 = 30 | 30 per city per source for the first page; a full city needs more calls |
| Free tier: 33 hotels × 30 dates | 990 | 990 of 1,000 free credits a month |
One page is not a whole city. In the recordings the first page returned 10 of Expedia's 500 results and 35 of Trivago's 2,500, so covering a city means more calls per date, following offset and next_offset.
Rate matters as much as credits. Paying accounts run at a minimum of 5 requests a second, so 6,000 calls take 6,000 ÷ 5 = 1,200 seconds, or 20 minutes, but only if you actually send 5 a second. That is a ceiling. Your throughput is workers ÷ latency: 4 workers at about 2 seconds a call give roughly 2 requests a second, which is about 50 minutes for 6,000 calls. Scale the worker count with latency, for example 10 workers at 2 seconds to approach the ceiling. Free accounts get 1 request a second. The rate limits page has the details, and the credits page explains how balances are debited.
How do you run the sweep and reconcile the credits?
Fan out the calls, honour Retry-After, and add up X-Credits-Used from every response. Don't compute the total from the first and last X-Credits-Remaining of a concurrent run: responses arrive out of order, so those two values don't bracket the run. This script sweeps Expedia room prices for a list of property IDs.
import os
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date, timedelta
import requests
URL = "https://api.perceptlayer.com/v1/expedia/hotels-rooms"
HEADERS = {"X-API-Key": os.environ["API_KEY"]}
PROPERTY_IDS = ["564660", "994215"] # your 200 Expedia property IDs
FIRST_CHECK_IN = date(2026, 11, 20)
DATES = 30
NIGHTS = 3
WORKERS = 4 # requests a second = WORKERS / latency in seconds, capped at 5; use 1 on a free account
def fetch(job):
property_id, check_in = job
params = {
"property_id": property_id,
"check_in": check_in.isoformat(),
"check_out": (check_in + timedelta(days=NIGHTS)).isoformat(),
"adults": 2,
}
for _ in range(3):
try:
r = requests.get(URL, params=params, headers=HEADERS, timeout=30)
except requests.Timeout:
# The call may have succeeded and been billed; the reply was lost.
return job, [], 0, "timeout"
if r.status_code in (429, 503):
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
break
used = int(r.headers.get("X-Credits-Used", 0))
if not r.ok:
return job, [], used, r.json().get("error", {}).get("code", "error")
data = r.json()["data"]
rows = [
{
"property_id": property_id,
"check_in": data["check_in"],
"currency": data["currency"],
"room": room["name"],
"rate_plan_id": plan["rate_plan_id"],
"total_price": plan["total_price"],
}
for room in data["rooms"]
for plan in room["rate_plans"]
]
return job, rows, used, None
jobs = [
(pid, FIRST_CHECK_IN + timedelta(days=i))
for pid in PROPERTY_IDS
for i in range(DATES)
]
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
results = list(pool.map(fetch, jobs))
rows = [row for _, r, _, _ in results for row in r]
credits_used = sum(used for _, _, used, _ in results)
problems = [(job, why) for job, _, _, why in results if why]
print(f"{len(rows)} rate rows, {credits_used} credits used, {len(problems)} calls to review")
Because requests have no idempotency key, a timed-out call may have been billed even though you got no body. The script flags those instead of counting them as free. See errors for the codes you'll hit, and the Expedia API page for the playground.
What should you check before trusting the numbers?
Check four things before a price goes into a dashboard, so the series isn't a mix of different quantities.
- Currency. In the recordings Expedia returned EUR, Trivago USD and Booking a mix of
$and€strings. Booking and Trivago accept acurrencyparameter and Expedia's room and search endpoints don't list one, so convert on your side and store the original currency. - Unit. Expedia gives both
total_priceandprice_per_night; use the total, since the nightly figure is rounded. For Booking text prices, confirm whether a value covers the stay or a night. - Identity. Booking identifies hotels by
countryandslug, Expedia byproperty_id, Trivago byid. The recordings don't link them, so keep your own mapping table. - Timestamps. Data is returned as found at the time of the call. Store the time you made each request next to the price.
Pick one source, run the sweep on 5 hotels and compare it with the site by hand before scaling to 200. Get a free API key for 1,000 credits a month; no card needed. For the Booking side in more depth, see Booking.com Hotel Prices API, and for a polling loop around prices, Build a Price Tracking Agent in Python. Store dates as ISO 8601 and currencies as ISO 4217 codes.
APIs in this post
Frequently asked questions
How many credits does a hotel rate check cost?
Each successful call costs 1 credit on these endpoints. Checking 200 hotels over 30 check-in dates with a rooms call is 200 × 30 × 1 credit = 6,000 credits per source and occupancy. Failed calls cost nothing and return X-Credits-Used: 0. Credit pricing is on the pricing page.
Can I get room-level prices, or only one price per hotel?
Both. Booking rooms and Expedia hotels-rooms return prices per room, and Expedia adds one entry per rate plan. Booking prices, Expedia hotels-search and Trivago search return hotel-level results. The calls are separate, so budget for each one rather than assuming a search includes rooms.
Do the responses include cancellation terms?
Not in the room responses. Expedia's search results include a hotel-level free_cancellation boolean, which is the only cancellation field in the recordings. If refundable and non-refundable rates must be tracked separately, check the page for each rate plan against your results before relying on it.
How fast can I run a 6,000-call sweep?
At 5 requests a second, the minimum for paying accounts, 6,000 calls take 20 minutes, but that is a ceiling: throughput is workers ÷ latency, so 4 workers at about 2 seconds a call manage roughly 2 a second. A free account runs at 1 request a second, so the same count would take 100 minutes, and its free credits cover only 1,000 calls. Rate-limited responses carry Retry-After; wait that many seconds before retrying.