Scraper · Agoda / Booking.com / MakeMyTrip / Expedia

Rate Shop

Price many hotels, on many OTAs, across every arrival date in a range — in one request. This is the axis per-hotel OTA pricing can't express: that endpoint prices exactly one hotel, on one OTA, for one stay, so a 30-day three-OTA shop of ten hotels would be 900 separate calls to build and 900 URLs to poll.

Before comparing cells across OTAs, normalise taxes, fees and occupancy: why two hotel prices aren't comparable.

POST/api/v1/scraper/rate-shop/— submit
GET/api/v1/scraper/rate-shop/{job_id}/— poll

Base URL: https://api.scrapeguys.com · Auth: X-API-Key: gsk_… with the ota:<slug> scope for every OTA named in the shop · See the Overview.

No scope of its own. A rate shop runs the same scrapes per-hotel OTA pricing does, in one request, so it requires ota:<slug> for every OTA it names and nothing further. Agoda, Booking.com, MakeMyTrip and Expedia — Goibibo is the one held out of the matrix: it's held out until it has proven itself on single-property /ota-price/ jobs, where one failure costs one job rather than a whole shop. Sending goibibo in otas is rejected with 400, the same as any unrecognized name — use the per-hotel Goibibo guide instead. See the OTA Directory for the live, per-OTA endpoints list.

Submit a job

FieldTypeRequiredDefaultNotes
hotelsobject[]Required—1–50 items. Each needs place_id, or hotel_name + city — see Identifying each hotel.
otasstring[]Required—agoda / booking / makemytrip (alias mmt) / expedia, or their numeric channel codes — see Addressing an OTA by number. Duplicates, aliases and codes of one OTA are collapsed, not billed twice — sending both 1 and "agoda" is one OTA, billed once.
start_datedateRequired—First arrival. Not in the past; ≤ 1 year ahead.
end_datedateRequired—Last arrival, INCLUSIVE — not the last check-out. Equal to start_date = one date. Max 90 dates, and — because it is itself an arrival date — ≤ 1 year ahead, the same bound start_date carries. With makemytrip in otas, ≤ 364 days ahead (a 400 otherwise, since 16 October 2026).
losintOptional1Nights per stay, 1–30.
adultsintOptional21–10. Party total across all rooms.
roomsintOptional11–8. Must not exceed adults.
currencystringOptionalINRMust be supported by every OTA in the request — MakeMyTrip is INR-only, so naming it alongside a non-INR currency is rejected. Applied for the device_type you asked for. Booking.com's non-INR figures are converted at its own exchange rate rather than natively quoted, so treat them as indicative when comparing across OTAs, or compare in INR. Poll rows do not embed offers; the per-offer quote_basis is on the per-hotel Booking result.
device_typestringOptionaldesktopdesktop or mobile, applied to every cell.
tax_requiredbooleanOptionalfalseItemize each rate's tax, exactly as on the per-hotel endpoint — but a shop is multi-OTA, so it is applied only to the cells of the OTAs that can itemize (Agoda and Expedia) and every other cell in the shop (Booking.com, MakeMyTrip) is priced normally and echoes tax_required: false on its own row. Accepted for any set of OTAs, including a shop with neither of them in it, so one request shape covers every shop.
tax_scopestringOptionalone_fare_per_roomSame two values as the per-hotel endpoint. Defaulted when tax_required is sent without it; rejected with 400 if sent without tax_required: true.
Adults only — no child parameter at all. children is rejected with 400, exactly as on the per-hotel endpoints — and so is a non-empty children_ages ("This endpoint prices adults only; `children_ages` is not supported. Omit it or send []."). An omitted, empty ([]) or null children_ages still passes, so one request model can serve both this endpoint and the per-hotel one. To price a party with children, send that hotel, OTA and stay to Agoda, Booking.com, MakeMyTrip or Expedia instead.
Changed 5 October 2026 — children_ages is now refused, and no longer embedded in ?include=result. The field was never declared on this endpoint, so a request carried over from a per-hotel call with "children_ages": [9, 12] used to be silently dropped: it returned 202 and a fully billed matrix priced for adults only, with nothing in the response saying the children had been dropped. It is now a 400 naming the field. Nothing changes for a request that omits it or sends []. On the poll, a matrix embedded by ?include=result (see Response schema below) no longer carries children_ages or children_per_room — both were always [] and 0 here. A cell's own poll_url is unchanged and still carries them.
end_date is the last CHECK-IN, not the last check-out. A "30-day rate shop" means 30 arrival dates; each is priced for los nights, so the final stay checks out los days after end_date. 2026-10-01 → 2026-10-30 with los: 1 is 30 stays, the last being 30 Oct → 31 Oct.
POST /api/v1/scraper/rate-shop/
curl -X POST https://api.scrapeguys.com/api/v1/scraper/rate-shop/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "hotels": [ {"place_id": "ChIJm14which9bDkRXF7ciSSR4eA"}, {"hotel_name": "The Leela Palace", "city": "Bengaluru"} ], "otas": ["agoda", "booking", "makemytrip"], "start_date": "2026-10-01", "end_date": "2026-10-30", "los": 1, "adults": 2, "rooms": 1, "currency": "INR", "device_type": "desktop" }'
202 Accepted
{ "job_id": "550e8400-e29b-41d4-a716-446655440000", "status": "pending", "cells": 180, "hotels": 2, "otas": 3, "dates": 30, "poll_url": "/api/v1/scraper/rate-shop/550e8400-e29b-41d4-a716-446655440000/" }
Changed 16 October 2026 — a misspelt field is a 400. Fields a rate shop does not know are still ignored, so your own extras (client_ref, request_id, …) are harmless. But a key that is a near miss of a real field you did not send — a case or separator variant (startDate, start-date) or a letter or two off (adult, room, child_ages) — is refused with a 400 naming the field it probably meant. Before this, "room": 2 priced one room with nothing in the response to say so.

Identifying each hotel

A hotel is named by place_id (recommended — one key that works across all four OTAs in this shop) or by hotel_name + city.

ota_hotel_id is rejected with 400 here — not silently ignored. An OTA id names a property on one OTA, and a shop asks several, so it could not identify the hotel across the matrix. A dropped pin would return 202 and bill the whole matrix resolved by name while you believed the property was pinned — refusing outright avoids that. Use place_id instead if you need a cross-OTA pin.

Whatever identity you send is echoed back on every cell it produces, and it must be unique within the request — two entries resolving to one identity are rejected naming both positions, because their cells would be indistinguishable and you would silently attribute one hotel's prices to both.

On the three OTAs whose own id fully addresses a property (Agoda, Booking.com, MakeMyTrip), a place_id is bridged to that OTA's own id on the first cell that resolves it, and then pinned — every later cell inherits it, which is what makes origin_hotel_name worth checking on the summary row rather than only in each full matrix (see Response schema below). Expedia pins too, as a pair — it addresses a property by a hotel and region pair rather than one stable id, so both are pinned together; a cell whose result carries no region is not pinned and re-resolves the place_id by name on the next request (see Expedia's identity notes).

What a rate shop costs

cells = hotels × otas × dates, and every cell is one billable scrape. The example above is 180 requests against your quota from a single HTTP call.

The whole shop is priced before any work starts and refused as a unit if it will not fit your remaining daily or monthly quota — with the arithmetic in the message:

429 Too Many Requests
{ "detail": "This request needs 180 of your daily quota but only 44 remain (1000/day). Split it by date range or by hotel." }

That is deliberate. The alternative is to start the fan-out and fail partway, which bills you for a matrix you cannot use and leaves you unable to tell which half ran. Nothing is created on a refusal. The per-minute rate limit applies to the submit only — the fan-out is ours, not yours.

Hard caps. A single request is capped at 1,000 cells, 50 hotels and 90 dates, and no arrival may be more than a year out — so a 90-date shop has to start at least 89 days inside that horizon. At the default 1,000/day quota, one full shop exhausts the day; tell us the shape of your shop and we will size your tier to it.

See Rate limits & quotas on the Overview page for how GET /usage/'s in_flight block reports work already accepted but not yet billed — including cells of a rate shop still running.

Tax in a rate shop

tax_required fills tax_amount and fee_amount on the Agoda and Expedia cells' rates, and costs what it costs on the per-hotel endpoint: as of 6 October 2026, free on ~97% of Agoda rates — recognised or proven as tax alone straight off the room grid — with a booking-form request spent only where that can't be shown, shared across every other rate it proves at the same time; always two requests per rate on Expedia. One thing carries over and matters more across a matrix: a rate the booking form won't state comes back unverified with tax_amount: null. An Agoda cell prices under the same public rate channel whether or not tax_required is set, so itemizing the tax never shifts any other price in the shop — Expedia has no channel-id concept of its own, so this doesn't apply there. The shop echoes tax_required / tax_scope at the top level, and each row of the poll carries its own cell's tax_required (see Response schema), so a row that wasn't itemized is never mistaken for one that was.

Without the flag, an Agoda cell still has tax_and_fees (tax and fees combined) free on every rate, and a Booking.com or MakeMyTrip cell has that same combined figure in tax_amount — which is all those OTAs publish. An Expedia cell is the one exception: it has no free tax figure at all, so tax_amount, fee_amount and tax_basis are all null until you ask for tax_required (see Expedia's taxes-and-fees notes). On every cell that doesn't itemize the flag changes nothing, and is recorded as false.

Poll for results

GET/api/v1/scraper/rate-shop/{job_id}/
GET /rate-shop/{job_id}/
curl https://api.scrapeguys.com/api/v1/scraper/rate-shop/YOUR_JOB_ID/ \ -H "X-API-Key: gsk_your_api_key" # Optionally: ?include=result — see below
statusMeaning
pendingAccepted; the matrix has not been expanded into cells yet. results is [].
processingCells are running. results holds one row per cell, finished or not — read each row's own status.
completedEvery cell reached a terminal state.
failedThe shop itself could not be expanded — error_message says why.
A shop whose cells all failed is completed, not failed. The shop did its job; the answer is "no data for these hotels", and each row carries its own error_message (unresolved, dead_property_id, …) and a pricing_status that tells a hotel the OTA does not list (hotel_not_matched) from a scrape that failed in flight (scrape_failed). A sold-out cell is not an error — it completes with sold_out: true. Only an error on the shop itself is a failed shop.

Response schema

200 OK — GET /rate-shop/{job_id}/
{ "id": "550e8400-...", "status": "processing", "complete": "57/180", "cell_count": 180, "hotels": [{"place_id": "ChIJ…"}, {"hotel_name": "The Leela Palace", "city": "Bengaluru"}], "otas": ["agoda", "booking", "makemytrip"], "start_date": "2026-10-01", "end_date": "2026-10-30", "los": 1, "adults": 2, "rooms": 1, "currency": "INR", "device_type": "desktop", "tax_required": false, "tax_scope": "", "results_embedded": false, "results": [ { "job_id": "9c1f…", "place_id": "ChIJm14which9bDkRXF7ciSSR4eA", "hotel_name": "", "city": "", "ota": "agoda", "ota_code": 1, "check_in": "2026-10-01", "check_out": "2026-10-02", "status": "completed", "error_message": "", "cheapest_fitting_price": 22500.0, "cheapest_fitting_basis": "per_night_before_taxes", "cheapest_fitting_tax_status": "excluded", "cheapest_fitting_meal_plan_code": "CP", "sold_out": false, "occupancy_honored": true, "pricing_status": "ok", "pricing_status_reason": null, "hotel_id": "16082488", "origin_hotel_name": "The Taj Mahal Palace, Mumbai", "tax_required": false, "poll_url": "/api/v1/scraper/ota-price/9c1f…/" } ], "error_message": "", "created_at": "2026-09-01T12:00:00Z", "completed_at": null }
⚠ results always holds one row per cell — including cells that have not run yet. It is not filtered to finished work. An unfinished row carries status: "pending" or "processing" and null price figures, which mean "not priced yet", NOT "no rate available" — its pricing_status says so (unknown / not_finished). Read each row's own status before recording its numbers, or you will bank a null for a hotel/date/OTA you paid for. complete reads "57/180" — cells in a terminal state out of cells submitted. Results stream in, so poll until it reads N/N, or consume incrementally by filtering on row.status == "completed".

Rows are summaries; full matrices are one fetch away

The poll does not embed each cell's room matrix, and could not — one Agoda cell measures 217 KB (11 room types, 85 offers), so a 1,000-cell shop would be a ~212 MB response. Each row instead carries the figures a rate shop is built on:

FieldMeaning
cheapest_fitting_priceThe lowest price that can actually seat the party — not a raw minimum over the offers, which routinely picks a rate that cannot.
cheapest_fitting_basisThat price's price_basis, since basis is per-offer.
cheapest_fitting_tax_statusAdded 13 October 2026. Whether that price already contains tax: included, excluded or unknown (the OTA did not say — never assumed either way, so unknown is not excluded). It is the same word each rate carries as tax_status on its own OTA price job, read off cheapest_fitting_basis. null exactly when cheapest_fitting_price is.
cheapest_fitting_meal_plan_codeAdded 23 September 2026. What that price includes — EP room only, CP breakfast, MAP half board, AP full board, AI all inclusive. Always present, filtered shop or not — a matrix of prices where one cell feeds you and another doesn't is not comparable, and the summary row is where a rate shop is actually read. null where the OTA stated no meal plan, or where no offer fits the party.
sold_outTri-state. true the property resolved and has no availability for those dates; false it has availability; null we do not know — the cell has not finished, or it failed (read error_message). A failed cell is never reported as false, because "not sold out" would read as "bookable".
occupancy_honoredWhy a finished cell can have no price and sold_out: false: false means the property answered with rooms and rates but not one of them seats the party — the OTA's own page shows that search as unavailable while the hotel itself is not sold out. true at least one rate fits; null no occupancy evidence, or the cell has not finished.
pricing_status, pricing_status_reasonAdded 14 October 2026. Whether a blank price means "the OTA has nothing to sell for this party" or "we never found out" — ok, none or unknown, with a reason whenever it is not ok (null when it is). Same three values as the Google Hotels pricing_status. Worked out on every poll from what the cell already holds, so a cell priced before that date answers too. See below.
hotel_idThe OTA's own id for the property it priced.
origin_hotel_nameWhat the OTA itself calls that property — the field to check if you want to confirm each cell priced the hotel you meant. The row's hotel_name is your own submitted text echoed back and cannot answer that. "" means the OTA named no property on that cell, never that it agreed. It is in the summary (not only in each full matrix) because a wrong identity spreads: a place_id is bridged to an OTA id by the first cell that proves one, and every later cell inherits it.
ota_codeThe cell's numeric OTA channel code — 1 agoda, 2 makemytrip, 3 booking, 4 expedia (see Addressing an OTA by number) — present even before the cell has run.
tax_requiredWhether this specific cell itemized its tax — true only on Agoda or Expedia cells in a shop sent with tax_required: true (see Tax in a rate shop); every other cell reads false, whatever the shop asked for.
job_id, poll_urlThis cell as an ordinary OTA price job — poll_url returns its complete room matrix (tax_amount, meal_plan_code, free_cancellation, source_url and all). The 15 and 23 September 2026 Agoda cancellation-state and meal-plan fixes, and the 23 September 2026 Booking.com meal-plan fix (see Agoda-specific notes and Booking.com-specific notes), apply to shop cells the same as any other job.

pricing_status on a row

A row with no cheapest_fitting_price can mean two opposite things: the OTA answered and has nothing to sell for this party, or we never found out. The first is a fact to record; the second must not be recorded as "no availability", because the hotel may well be bookable.

pricing_statuspricing_status_reasonMeaningWhat to do
oknullA price that seats the party was found.Use cheapest_fitting_price.
nonesold_outThe OTA reports the property sold out for these dates.Record as unavailable.
noneno_ratesThe OTA listed rooms, but none has a rate for these dates.Record as unavailable.
noneno_room_fits_partyRates exist, but not one seats the requested party (occupancy_honored: false).Record as unavailable for this party; a smaller party may price.
nonenot_sold_on_otaThe OTA says the hotel is not available on it at all (MakeMyTrip's "Currently Not Available on MakeMyTrip"). Since 27 October 2026 the row's status is completed with sold_out: true and not_bookable: true; rows priced earlier carry status: "failed". Both report this reason.Record as not sold on this OTA. Retrying will not help.
unknownnot_finishedThe cell is still pending or processing.Poll again.
unknownscrape_failedThe scrape failed in flight; error_message has the detail.Retry later.
unknownhotel_not_matchedThe hotel could not be matched to a listing on the OTA, or the OTA says the id we used is not one of its listings (each OTA words that differently, e.g. dead_property_id on Agoda and Expedia).Retrying the same name and city will not help; check them, or send the hotel's place_id.
unknownoccupancy_unstatedRates exist, but the OTA did not say how many guests any of them seats, so no price can be vouched for this party.Open poll_url to see the rates.
unknownprice_unstatedA room seats the party, but none of its rates carried a price.Retry later.

none is said only when the OTA itself told us there is nothing to sell. Everything short of that — unfinished, failed, or rates whose occupancy the OTA never stated — is unknown. Individual /ota-price/ results do not carry pricing_status; it is a rate shop row field.

?include=result embeds every matrix inline for shops of 100 cells or fewer; above that it is refused with a 400 pointing at poll_url. results_embedded on the response says which form you are holding. It is additive: each row keeps every field above and gains result — null until that cell finishes, then the payload the per-hotel endpoints return, minus the children_ages / children_per_room request echoes (a rate shop has no child parameter to echo — see the callout under Submit a job). Fetch the ten rows your report needs rather than the thousand it does not.

Each row echoes the identity the hotel was submitted under, so (place_id, ota, check_in) — or (hotel_name, city, ota, check_in) for name-identified hotels — addresses exactly one cell and is the key to join the matrix onto your own catalogue.

Errors — on submit: 400 (validation, a hotel with no identity, duplicate hotels, an unsupported currency for one of the OTAs, children, a non-empty children_ages, or a disabled OTA, a misspelt field name, or an end_date more than 364 days ahead with makemytrip in otas), 401 (missing, unknown or deactivated key), 403 (the key lacks ota:<slug> for any OTA named — the whole shop is refused), 429 (per-minute limit, or the shop exceeds remaining daily or monthly quota). On poll: 400 (?include=result on a shop of more than 100 cells), 401, 404 (unknown id, or another client's — its existence is not disclosed), 403 (the key no longer holds the scope for an OTA in the shop).


Next: Destination Search →