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.
Base URL: https://gumo.co.in · Auth: X-API-Key: gsk_… with the ota:<slug> scope for every OTA named in the shop · See the Overview.
ota:<slug> for every OTA it names and nothing further. Agoda, Booking.com and MakeMyTrip only — Expedia is not supported here.Submit a job
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
hotels | object[] | Required | — | 1–50 items. Each needs place_id, or hotel_name + city — see Identifying each hotel. |
otas | string[] | Required | — | agoda / booking / makemytrip (alias mmt), 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_date | date | Required | — | First arrival. Not in the past; ≤ 1 year ahead. |
end_date | date | Required | — | 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. |
los | int | Optional | 1 | Nights per stay, 1–30. |
adults | int | Optional | 2 | 1–10. Party total across all rooms. |
rooms | int | Optional | 1 | 1–8. Must not exceed adults. |
currency | string | Optional | INR | Must be supported by every OTA in the request — MakeMyTrip is INR-only, so naming it alongside a non-INR currency is rejected. |
device_type | string | Optional | desktop | desktop or mobile, applied to every cell. |
signed_in | boolean | Optional | false | Price as a member account, resolved per OTA — see Signed-in pricing in a shop. |
account_type | string | Optional | "" | Narrow to a standing; implies signed_in. |
account | string | Optional | "" | Name an account outright. Must be valid on every OTA named. |
children is rejected with 400, exactly as on the per-hotel endpoints.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.curl -X POST https://gumo.co.in/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"
}'
{
"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/"
}
Identifying each hotel
A hotel is named by place_id (recommended — one key that works across all three OTAs) 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).
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:
{
"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.
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.
Poll for results
curl https://gumo.co.in/api/v1/scraper/rate-shop/YOUR_JOB_ID/ \
-H "X-API-Key: gsk_your_api_key"
# Optionally: ?include=result — see below
status | Meaning |
|---|---|
pending | Accepted; the matrix has not been expanded into cells yet. results is []. |
processing | Cells are running. results holds one row per cell, finished or not — read each row's own status. |
completed | Every cell reached a terminal state. |
failed | The shop itself could not be expanded — error_message says why. |
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, …). 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
{
"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",
"account_labels": {}, "results_embedded": false,
"results": [
{
"job_id": "9c1f…",
"place_id": "ChIJm14which9bDkRXF7ciSSR4eA", "hotel_name": "", "city": "",
"ota": "agoda", "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",
"sold_out": false, "hotel_id": "16082488",
"origin_hotel_name": "The Taj Mahal Palace, Mumbai",
"account_label": "",
"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". 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:
| Field | Meaning |
|---|---|
cheapest_fitting_price | The lowest price that can actually seat the party — not a raw minimum over the offers, which routinely picks a rate that cannot. |
cheapest_fitting_basis | That price's price_basis, since basis is per-offer. |
sold_out | The property resolved but has no availability for those dates. |
hotel_id | The OTA's own id for the property it priced. |
origin_hotel_name | What 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. |
account_label | Which member account priced this cell — a shop resolves one per OTA. "" on an anonymous shop. |
job_id, poll_url | This cell as an ordinary OTA price job — poll_url returns its complete room matrix (tax_amount, meal_plan_code, source_url and all). |
?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 exactly the payload the per-hotel endpoints return. 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 disabled OTA, own_account / account_credential, or an account not available on every OTA named), 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), 503 (signed-in pricing is provisioned but momentarily unreadable on our side — retry). On poll: 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).
Signed-in pricing in a shop
signed_in, account_type and account work as they do on the per-hotel endpoints — but a credential is per-(OTA, account), so a shop resolves one account per OTA and the response reports them as a map:
{ "account_labels": { "agoda": "corporate", "booking": "7:corporate", "makemytrip": "corporate" } }
Each cell is priced as its own OTA's account, and account_label on each result row says which. A single label would have been wrong rather than merely limited: the same name usually does not exist on all three, so a three-OTA shop would have priced two of them anonymously while reporting an account.
If you name an account explicitly it must be valid on every OTA in the request — a shop that cannot be signed in everywhere it was asked to is refused whole at submit, rather than pricing part of the matrix anonymously under an account's name.
own_account and account_credential are rejected with 400: storing is per-OTA and this endpoint is multi-OTA, so which OTA the credential belonged to would be ambiguous. Send it once to the per-hotel endpoint (e.g. Agoda's own_account), then reference it here by name with account — either the bare name you chose or the namespaced account_label we echo back; both resolve to the same stored credential. It must be stored for every OTA in the request. Do not send account_type alongside it — that is a 400, for the same reason the per-hotel endpoint gives: we did not provision the credential and cannot verify the standing it claims.Next: Destination Search →