Google Hotels
Return a full multi-OTA price comparison plus rich metadata for any hotel and date range worldwide. Submit a job by Google Place ID (recommended) or hotel name + city, then poll for the result.
/api/v1/scraper/scrape/— submit/api/v1/scraper/scrape/{job_id}/— pollBase URL: https://api.scrapeguys.com · Auth: X-API-Key: gsk_… with the gmaps scope · See the Overview.
/api/v1/scraper/ota-price/); for just the cheapest all-in room on one OTA, see Cheapest Room; for many hotels across many OTAs and dates in one request, see Rate Shop; for enumerating every property in a city rather than pricing one you already have, see Destination Search.Submit a job
Provide either a place_id (recommended — deterministic) or a hotel_name. When searching by name (no place_id), city is also required. A place_id-only request is valid; if both are given, place_id is used first with name+city as fallback. Place ID mode exists because name matching is the least reliable way to identify a hotel — here is why, and what to key on instead. Guides covers the data-quality problems that sit around these endpoints.
Why Place ID beats name matching, and how to store and refresh IDs: matching the same hotel across OTAs.
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
hotel_name | string | Optional | "" | ≤ 300 chars. Required only when no place_id is given. Also used for logging & fallback search. |
place_id | string | Optional | "" | ≤ 200 chars. Google Place ID — deterministic & fastest lookup. Provide this or hotel_name. Pasted whitespace and invisible characters (e.g. U+202F, U+2060, zero-width spaces) are stripped automatically; any other character outside A–Z a–z 0–9 _ - is rejected with 400. |
city | string | Optional | "" | ≤ 200 chars. Required when using name search (no place_id). |
check_in | date | Required | — | YYYY-MM-DD. Not in the past; ≤ 1 year ahead. |
check_out | date | Required | — | After check_in; ≤ 30 nights. |
adults | int | Optional | 2 | 1–10. |
children | int | Optional | 0 | 0–6. A bare count: each child is priced at age 12 (Google's own default) unless children_ages is sent. |
children_ages | int[] | Optional | [] | One age per child, e.g. [5, 9]. Ages 0–17, max 6. Overrides children — the list length is the child count. Sending both with different counts is rejected with 400 (an explicit children: 0 is treated as unset). See Children. |
Children. Google prices a child by age — on one test property a 5-year-old and a 12-year-old came back at different prices on the same OTA. A request with children is priced by Google Travel for the whole party (adults plus each child's age), never from an adults-only price: on a Place-ID scrape the result carries party_pricing_source (travel_rpc for Google's price call, entity when that call failed and the hotel's Travel page was used instead). If neither answers, that scrape reports an error and the pipeline falls back to another Google surface that carries the party — an adults-only price is never returned for a party with children. Expect fewer OTAs than for adults only — only OTAs with a room for the party list it — and an empty pricing when no room fits (on v2, pricing_status: "none"). The poll response echoes children_ages, so the party every price is for is explicit; children echoes the resolved count.
Poll for results
Poll the poll_url every ~2 seconds until status is completed or failed. Statuses: pending → processing → completed | failed.
/api/v1/scraper/scrape/{job_id}/Response schema
A completed job returns the job envelope with a result object. Top-level fields: id, status, api_version, hotel_name, city, place_id, check_in, check_out, adults, children, children_ages, result, error_message, created_at, started_at, completed_at, duration_seconds, bandwidth_kb.
Fields — pricing[] (per OTA)
| Field | Type | Description |
|---|---|---|
platform_name | string | OTA or official-site name (e.g. Booking.com, Agoda). |
price | number | Numeric nightly price. |
price_display | string | Localized formatted price, e.g. ₹45,000. |
currency | string | Currency code as localized by Google. |
price_before_discount | number | Struck-through pre-discount price, when shown. |
booking_url | string | Direct deep link to the offer. |
free_cancellation | bool | Whether free cancellation is offered. |
cancellation_deadline | string | Free-cancellation cut-off, when present. |
room_type | string | Rate's room description. |
breakfast_included | bool / null | Whether this offer includes breakfast. null when Google did not say, which is the case for most offers. true/false comes only from the offer's own wording (an excluded or separately priced meal such as "Breakfast ₹ 2,500" is false) or from the OTA's own booking link. It is no longer derived from the property's amenity list — see hotel_metadata.free_breakfast. |
board | string / null | The offer's meal plan: Room only, Breakfast included, Half board, Full board or All inclusive. null when the offer names none. Same vocabulary as rate shop. |
meal_plan_code | string / null | EP, CP, MAP, AP or AI for that board; null with it. |
tax_included | bool / null | Whether taxes are included, when detectable. |
tax_amount | number / null | The taxes-and-fees amount, when Google itemizes it. |
tax_source | string | Provenance of the tax read (e.g. maps_preview, entity, travel_rpc, ghotel_http). |
logo_url | string | OTA logo image URL, when shown. |
source | string | Present on non-panel offers: sponsored, entity or entity_featured. |
is_official | bool | True for the hotel's own direct rate. |
Fields — hotel_metadata
name, hotel_class (stars), user_rating, review_count, description, address, neighborhood, phone, website, latitude, longitude, check_in_time, check_out_time, amenities[], free_breakfast, images[].
free_breakfast (bool / null) is the property's claim of free breakfast, taken from its amenities; null when Google listed no amenities. It describes the hotel, not any individual rate — use per-offer breakfast_included / board to compare what a price includes.
navigation_mode tells you which pipeline produced the result (maps_preview for Place-ID scrapes; direct/fallback/name_only/http for name search). Metadata keys that could not be parsed may be absent rather than null on the name-search path, and Place-ID results can include extra arrays such as similar_hotels, nearby_vacation_rentals and onsite_venues. Treat unknown keys as additive and every key as optional.status: "failed" with a human-readable error_message (e.g. Hotel "Rambagh Palace" not found in listing results, "Request timed out", "children pricing failed: …").Errors — on submit: 400 (validation: neither place_id nor hotel_name, a place_id containing characters outside A–Z a–z 0–9 _ -, name search without city, a past or over-a-year-out check_in, a stay over 30 nights, children and children_ages disagreeing), 401 (missing, unknown or deactivated key), 403 (key lacks the gmaps scope), 429 (per-minute limit, or daily / monthly quota exhausted). On poll: 404 (unknown id, another client's job, or an OTA price job — those poll on /ota-price/), 401, 403.
Children
Unlike every OTA endpoint, a bare children count is accepted here — Google's own guest picker defaults an unspecified child to age 12. Send children_ages instead when you know the ages, e.g. "children_ages": [5, 9] (0–17 each, max 6 — the list length is the child count, and it overrides children): Google prices a 5-year-old and a 9-year-old differently on the same OTA.
A party with children is never read off the adults-only price. The place-lookup call this endpoint otherwise prices from has no child field at all, so once a child is in the party the job is priced through Google Travel instead — its own price RPC first, the hotel's own listing page if that fails. Expect fewer OTAs than for adults only (not every one lists family occupancy), and on API v2 a pricing_status: "none" alongside the empty pricing when no room fits the party. The resolved children_ages is always echoed back on every poll (it isn't in the 202 submit response — see Submit a job) — so the party a price is for is never implicit.
Batch scraping
Scrape up to 20 hotels that share the same check-in/check-out in one call. Each hotel becomes its own job.
/api/v1/scraper/batch/— submit/api/v1/scraper/batch/results/— fetch resultsBatch limits: hotels must hold 1–20 entries (400 otherwise — "Maximum 20 hotels per batch request."), and each needs a place_id or a hotel_name + city. Each place_id is cleaned and validated exactly as in a single submit. batch/results takes 1–20 IDs (400 "Provide 1-20 job_ids."); malformed IDs are skipped without error, IDs that are unknown, belong to another client or are OTA price jobs are simply absent from results (results come back newest first, not in request order — match on id), and a request where none parse is a 400 ("No valid job IDs provided."). Both batch endpoints need the gmaps scope.
429 says how many requests it needed and how many remain, and no hotel is submitted — nothing is billed and no per-minute slot is used. Keep a batch within your remaining per-minute allowance (10 by default), or split it.API versions
Pass an optional X-API-Version header (1 or 2; default 2 — send X-API-Version: 1 to stay on the v1 pipeline; the examples on this page show v1 jobs). The chosen version is echoed back as api_version. Unrecognized values fall back to the default silently — they do not error. The header is read on /scrape/ and /batch/ only.
| Version | Behaviour |
|---|---|
v1 | Standard resolution & OTA extraction. |
v2 | Server-side Place ID resolution with broader OTA coverage (incl. MakeMyTrip) and lower bandwidth. Adds to result: resolution {place_id, source: "client"|"cache"|"places_text_search"|"unresolved"}, match_verification {match_confidence: "high"|"medium"|"low"|"unknown", name_score, locality_match, matched_name}, v2_retry {triggered, added_platforms?, retry_error?} on every v2 Maps-path result (triggered: true when a thin OTA panel triggered the targeted retry), and pricing_status (with pricing_status_reason when not "ok") — see below. If a previously-resolved place_id turns out to be retired by Google, v2 automatically re-resolves and rescrapes rather than surfacing the failure; when that happens, resolution also includes replaced_dead_place_id with the stale id. |
Fields — pricing_status (v2 only)
An empty pricing: [] is ambiguous on its own — it can mean the hotel genuinely has no offers, or that we couldn't confirm one way or the other. pricing_status disambiguates it. The field is additive: ignore it and v2 behaves exactly like before.
| Value | Meaning | What to do |
|---|---|---|
ok | Offers were found; pricing is non-empty. | Use the prices. |
none | The requested dates were served and published no offers. | Treat as genuinely unavailable — retrying won't help. |
unknown | No surface answered for these dates; inventory is undetermined and the hotel may well be bookable. | Don't record as "no availability" — retry later. |
When pricing_status isn't ok, pricing_status_reason qualifies it:
| Reason | Meaning |
|---|---|
no_offers | Pairs with none — the dates were served and were empty. |
placeupdate_refused | This listing's date-specific lookup is stably declined for these dates — a property-level trait, not a bad attempt. Retrying the same dates returns the same nothing; a broader retry may still recover a price via a different path. |
placeupdate_failed | The date-specific lookup failed in a way that looks transient. A later retry may succeed. |
scrape_error | Pairs with unknown — the scrape itself errored, so inventory couldn't be checked. Unlike the other unknown reasons, this one accompanies a failed job — see error_message. |
scrape_error. For none and for unknown with reason placeupdate_refused/placeupdate_failed, status stays "completed" and error_message stays empty — the job did complete; only the claim about inventory is being qualified. pricing_status_reason: "scrape_error" is different: it means the job itself is "failed", with the failure described in error_message (see the Failed jobs note above). result — and pricing_status_reason inside it — is still present in this case, same object as always; it's absent only for the rarer crash a scrape can hit before producing any result at all.Place ID lookup
Find the Google Place ID we hold for a hotel, from a full name, a partial name or a few keywords — so the next request can go by place_id. It searches the hotels we already hold and costs 0 units.
/api/v1/scraper/place-id/?hotel_name=…&city=…Auth: X-API-Key: gsk_… with the gmaps scope.
| Query parameter | Type | Required | Notes |
|---|---|---|---|
hotel_name | string | Required | A full name, a partial name or keywords. ≤ 300 chars. |
city | string | Required | The city it is in. Matched exactly (case, accents and punctuation ignored); it narrows the search. ≤ 200 chars. |
max_matches | integer | Optional | 1–50, default 10. How many candidates to return in matches. |
hotel_name and city echo your request as sent. matches lists every candidate in the city whose name resembles your query, best first. Each carries its own stored hotel_name, a score (0–1), coordinates, and match: "exact" or "fuzzy". An exact match (case, punctuation and spacing ignored) is always matches[0]. Spelling variants are tolerated, so Hotel Amaya finds a stored The Amayaa, and accented names such as Résidence Alpina match exactly.
place_id is never a guess. The top-level place_id is set only for an exact match; otherwise it is null and the candidates stay inside matches. A near-named neighbour prices perfectly well, so confirm the candidate (name, city, coordinates) before sending its place_id to a price request.ota_ids holds the OTA property ids we hold for the candidate, keyed by OTA. An OTA's entry is included only when your key also holds that OTA's scope; otherwise it is omitted, and a key with no OTA scopes gets {}.
404 means "nothing resembling this in the city", not "no such hotel": this endpoint never resolves a new name. To get a Place ID for a new hotel, submit it by name to /scrape/ with X-API-Version: 2; when the name resolves, the id comes back in result.resolution.place_id. Note that & is no longer treated as and for an exact match; it still finds the hotel as a candidate.Errors — 400 (hotel_name or city missing or blank: "`hotel_name` and `city` are both required."; a value too long; or max_matches outside 1–50), 401, 403 (key lacks the gmaps scope), 404 ({"error": "No Place ID is stored for this hotel_name and city."}). A name with candidates but no exact match returns 200 with place_id: null; check matches, not just the status.
Next: Agoda →