Scraper · Meta

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.

POST/api/v1/scraper/scrape/— submit
GET/api/v1/scraper/scrape/{job_id}/— poll

Base URL: https://api.scrapeguys.com · Auth: X-API-Key: gsk_… with the gmaps scope · See the Overview.

This is one of several Hotel products, each on its own endpoint. This page covers Google Hotels — a metasearch comparison across OTAs — end-to-end and stands on its own. For the exact per-room-type price straight from an OTA's own site, see the standalone Agoda, Booking.com, MakeMyTrip, Expedia or Goibibo guides (all on /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.

FieldTypeRequiredDefaultNotes
hotel_namestringOptional""≤ 300 chars. Required only when no place_id is given. Also used for logging & fallback search.
place_idstringOptional""≤ 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.
citystringOptional""≤ 200 chars. Required when using name search (no place_id).
check_indateRequired—YYYY-MM-DD. Not in the past; ≤ 1 year ahead.
check_outdateRequired—After check_in; ≤ 30 nights.
adultsintOptional21–10.
childrenintOptional00–6. A bare count: each child is priced at age 12 (Google's own default) unless children_ages is sent.
children_agesint[]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.

POST /api/v1/scraper/scrape/
curl -X POST https://api.scrapeguys.com/api/v1/scraper/scrape/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "hotel_name": "Rambagh Palace", "place_id": "ChIJm14which9bDkRXF7ciSSR4eA", "check_in": "2026-04-10", "check_out": "2026-04-12", "adults": 2 }'
202 Accepted
{ "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "pending", "api_version": 1, "poll_url": "/api/v1/scraper/scrape/a1b2c3d4-e5f6-7890-abcd-ef1234567890/" }

Poll for results

Poll the poll_url every ~2 seconds until status is completed or failed. Statuses: pending → processing → completed | failed.

GET/api/v1/scraper/scrape/{job_id}/
GET /scrape/{job_id}/
curl https://api.scrapeguys.com/api/v1/scraper/scrape/YOUR_JOB_ID/ \ -H "X-API-Key: gsk_your_api_key" # Repeat every ~2 s until status is "completed" or "failed"
poll.py
import requests, time API = "https://api.scrapeguys.com/api/v1/scraper" H = {"X-API-Key": "gsk_your_api_key"} job = requests.post(f"{API}/scrape/", headers=H, json={ "hotel_name": "Rambagh Palace", "place_id": "ChIJm14which9bDkRXF7ciSSR4eA", "check_in": "2026-04-10", "check_out": "2026-04-12", }).json() while True: res = requests.get(f"{API}/scrape/{job['job_id']}/", headers=H).json() if res["status"] in ("completed", "failed"): break time.sleep(2) for offer in res["result"]["pricing"]: print(offer["platform_name"], offer["price_display"])

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.

200 OK — GET /scrape/{job_id}/
{ "id": "a1b2c3d4-...", "status": "completed", "api_version": 1, "hotel_name": "Rambagh Palace", "place_id": "ChIJm14which9bDkRXF7ciSSR4eA", "check_in": "2026-04-10", "check_out": "2026-04-12", "adults": 2, "children": 0, "children_ages": [], "result": { "hotel_metadata": { "name": "Rambagh Palace", "hotel_class": 5, "user_rating": 4.7, "review_count": 8234, "address": "Bhawani Singh Road, Jaipur...", "phone": "+91 141 221 1919", "website": "https://www.tajhotels.com/...", "latitude": 26.8982, "longitude": 75.8092, "check_in_time": "14:00", "check_out_time": "12:00", "amenities": ["Free breakfast", "Pool", "Spa", "Free Wi-Fi"], "free_breakfast": true, "images": ["https://lh5.googleusercontent.com/..."] }, "pricing": [ { "platform_name": "Booking.com", "price": 45000.0, "price_display": "₹45,000", "currency": "INR", "price_before_discount": 52000.0, "booking_url": "https://www.booking.com/hotel/in/rambagh-palace...", "free_cancellation": true, "cancellation_deadline": "8 Apr", "room_type": "Palace Room", "breakfast_included": null, "board": null, "meal_plan_code": null, "tax_included": false, "is_official": false }, { "platform_name": "Rambagh Palace", "price": 48000.0, "currency": "INR", "is_official": true, "room_type": "Palace Room with Breakfast", "breakfast_included": true, "board": "Breakfast included", "meal_plan_code": "CP" } ], "matched_hotel": "Rambagh Palace", "navigation_mode": "maps_preview", "timings": { "total": 3.2, "response_size_kb": 21.5 } }, "error_message": "", "duration_seconds": 3.2, "bandwidth_kb": 21.5 }

Fields — pricing[] (per OTA)

FieldTypeDescription
platform_namestringOTA or official-site name (e.g. Booking.com, Agoda).
pricenumberNumeric nightly price.
price_displaystringLocalized formatted price, e.g. ₹45,000.
currencystringCurrency code as localized by Google.
price_before_discountnumberStruck-through pre-discount price, when shown.
booking_urlstringDirect deep link to the offer.
free_cancellationboolWhether free cancellation is offered.
cancellation_deadlinestringFree-cancellation cut-off, when present.
room_typestringRate's room description.
breakfast_includedbool / nullWhether 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.
boardstring / nullThe 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_codestring / nullEP, CP, MAP, AP or AI for that board; null with it.
tax_includedbool / nullWhether taxes are included, when detectable.
tax_amountnumber / nullThe taxes-and-fees amount, when Google itemizes it.
tax_sourcestringProvenance of the tax read (e.g. maps_preview, entity, travel_rpc, ghotel_http).
logo_urlstringOTA logo image URL, when shown.
sourcestringPresent on non-panel offers: sponsored, entity or entity_featured.
is_officialboolTrue 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.

Shape varies by resolution path. 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.
Failed jobs return 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.

POST /api/v1/scraper/scrape/ — with children
curl -X POST https://api.scrapeguys.com/api/v1/scraper/scrape/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "place_id": "ChIJm14which9bDkRXF7ciSSR4eA", "check_in": "2026-04-10", "check_out": "2026-04-12", "adults": 2, "children_ages": [5, 9] }'

Batch scraping

Scrape up to 20 hotels that share the same check-in/check-out in one call. Each hotel becomes its own job.

POST/api/v1/scraper/batch/— submit
POST/api/v1/scraper/batch/results/— fetch results
POST /api/v1/scraper/batch/
curl -X POST https://api.scrapeguys.com/api/v1/scraper/batch/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "check_in": "2026-04-10", "check_out": "2026-04-12", "hotels": [ { "place_id": "ChIJm14which9bDkRXF7ciSSR4eA" }, { "hotel_name": "The Oberoi", "city": "New Delhi", "adults": 2 } ] }' # Per-hotel fields: place_id, hotel_name, city, adults, children, children_ages (same rules as single submit) # → 202: { "jobs": [ { "job_id": "...", "hotel_name": "...", "city": "...", "poll_url": "..." }, ... ] }
POST /api/v1/scraper/batch/results/
curl -X POST https://api.scrapeguys.com/api/v1/scraper/batch/results/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "job_ids": ["uuid-1", "uuid-2"] }' # → 200: { "results": [ <job result>, <job result> ] } (1–20 IDs)

Batch 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.

Quota: a batch consumes one request unit per hotel against your rate limit and quotas, and is admitted or refused as a whole: every unit is checked before any job is created. If the batch does not fit, the 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.

POST /api/v1/scraper/scrape/ — v2
curl -X POST https://api.scrapeguys.com/api/v1/scraper/scrape/ \ -H "X-API-Key: gsk_your_api_key" \ -H "X-API-Version: 2" \ -H "Content-Type: application/json" \ -d '{ "hotel_name": "Rambagh Palace", "city": "Jaipur", "check_in": "2026-04-10", "check_out": "2026-04-12" }' # v2 adds result.resolution, result.match_verification and result.v2_retry
VersionBehaviour
v1Standard resolution & OTA extraction.
v2Server-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.

ValueMeaningWhat to do
okOffers were found; pricing is non-empty.Use the prices.
noneThe requested dates were served and published no offers.Treat as genuinely unavailable — retrying won't help.
unknownNo 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:

ReasonMeaning
no_offersPairs with none — the dates were served and were empty.
placeupdate_refusedThis 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_failedThe date-specific lookup failed in a way that looks transient. A later retry may succeed.
scrape_errorPairs 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.
Job status is unaffected — except for 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.

GET/api/v1/scraper/place-id/?hotel_name=…&city=…

Auth: X-API-Key: gsk_… with the gmaps scope.

Query parameterTypeRequiredNotes
hotel_namestringRequiredA full name, a partial name or keywords. ≤ 300 chars.
citystringRequiredThe city it is in. Matched exactly (case, accents and punctuation ignored); it narrows the search. ≤ 200 chars.
max_matchesintegerOptional1–50, default 10. How many candidates to return in matches.
GET /api/v1/scraper/place-id/
curl -G https://api.scrapeguys.com/api/v1/scraper/place-id/ \ -H "X-API-Key: gsk_your_api_key" \ --data-urlencode "hotel_name=Taj Mahal" \ --data-urlencode "city=Mumbai"
200 OK
{ "hotel_name": "Taj Mahal", "city": "Mumbai", "place_id": null, "match_count": 1, "matches": [ { "place_id": "ChIJwe1EZjDG5zsRaYxkjY_tpF0", "hotel_name": "The Taj Mahal Palace", "city": "Mumbai", "match": "fuzzy", "score": 0.82, "display_name": "", "latitude": 18.9217, "longitude": 72.8332, "ota_ids": {} } ] }

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 {}.

Coverage. The search covers hotels we have already identified by Place ID, so a hotel we have not seen yet will not appear. 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 →