🎉 Developer Free Trial — 1 week of full API access, no credit card required. Claim your trial →
Scraper · Agoda / Booking.com / MakeMyTrip

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.

POST /api/v1/scraper/rate-shop/ — submit
GET /api/v1/scraper/rate-shop/{job_id}/ — 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.

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 and MakeMyTrip only — Expedia is not supported here.

Submit a job

FieldTypeRequiredDefaultNotes
hotelsobject[]Required1–50 items. Each needs place_id, or hotel_name + city — see Identifying each hotel.
otasstring[]Requiredagoda / 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_datedateRequiredFirst arrival. Not in the past; ≤ 1 year ahead.
end_datedateRequiredLast 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.
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.
device_typestringOptionaldesktopdesktop or mobile, applied to every cell.
signed_inbooleanOptionalfalsePrice as a member account, resolved per OTA — see Signed-in pricing in a shop.
account_typestringOptional""Narrow to a standing; implies signed_in.
accountstringOptional""Name an account outright. Must be valid on every OTA named.
Adults only. 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-012026-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://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"
  }'
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/"
}

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:

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.

Poll for results

GET /api/v1/scraper/rate-shop/{job_id}/
GET /rate-shop/{job_id}/
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
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, …). 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",
  "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:

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.
sold_outThe property resolved but has no availability for those dates.
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.
account_labelWhich member account priced this cell — a shop resolves one per OTA. "" on an anonymous shop.
job_id, poll_urlThis cell as an ordinary OTA price jobpoll_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 resultnull 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 on the response
{ "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.

Storing a credential is not done here — using one is. 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 →