Scraper · OTA

Agoda

Return the exact per-room-type price a real guest sees on Agoda's own site — not a metasearch or commission-inflated affiliate price. Submit a job, receive a job_id, poll for the result.

POST/api/v1/scraper/ota-price/— submit
GET/api/v1/scraper/ota-price/{job_id}/— poll

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

Shared endpoint, one vendor per job. /api/v1/scraper/ota-price/ also prices Booking.com, MakeMyTrip, Expedia and Goibibo — set "ota": "agoda" (the default when the field is omitted) to get this vendor. The request/response shape is the same across all five; this page documents it fully for Agoda, including everything Agoda-specific (device identity, channel fields). For per-room-type pricing this is a direct-route rate — not what Google Hotels shows, and not what Destination Search returns for a whole city.

Submit a job

FieldTypeRequiredDefaultNotes
otastringOptionalagodaLeave unset, or set explicitly to agoda. Setting booking or makemytrip routes to a different vendor with a different currency/price-basis profile — see Booking.com / MakeMyTrip. A numeric channel code (e.g. "1" or bare 1) may be sent instead of a name — see Addressing an OTA by number.
hotel_namestringConditional—≤ 300 chars. Resolved to Agoda's own property id via Agoda's own search. Required unless place_id or ota_hotel_id is given — see Identifying the hotel.
citystringConditional—≤ 200 chars. Required unless place_id or ota_hotel_id is given.
place_idstringOptional""Google Place ID. Identifies the property itself — the same id works across Agoda, Booking.com, MakeMyTrip and Goibibo. Bridged to Agoda's own property id on first use, then pinned — when Agoda names the property and that name agrees with the Place ID's own — so the same place_id returns the same property on every later request. Otherwise each request resolves again. See Identifying the hotel.
ota_hotel_idstringOptional""Agoda's own numeric propertyId (e.g. "16082488"). Skips resolution entirely — the fastest and most direct route once you hold it. See Identifying the hotel.
check_indateRequired—YYYY-MM-DD; not past, ≤ 1 year.
check_outdateRequired—After check_in; ≤ 30 nights.
adultsintOptional21–10. Total party size across all rooms, not per room.
roomsintOptional11–8. Must be ≤ adults (at least one adult per room).
children_agesint[]Optional[]One age per child, e.g. [9, 12] — not a count; the list length is the child count. Ages 0–17, max 8 children. Also priced on Booking.com, MakeMyTrip and Expedia. See Children below.
tax_requiredbooleanOptionalfalseAsk for tax_amount to be separated from any fee beside it, read from Agoda's own booking-form itemization where needed. Without this, tax_amount is null. Usually free on Agoda — a rate is recognised or proven tax-only straight from the room grid on ~97% of rates; a booking-form request is spent only where it can't be, and shared across every rate it also proves — see tax_scope, and Taxes and fees below. Always two requests per rate on Expedia; Goibibo's split is free too — no extra request, see its guide. Agoda, Expedia and Goibibo only — rejected with 400 on makemytrip and on booking's default route (Booking.com's app_android route accepts it). A tax_required job is still one unit of quota — the extra requests show up in bandwidth_kb, not as extra units.
tax_scopestringOptionalone_fare_per_roomone_fare_per_room (the cheapest fare of each room type) or all_fares_all_rooms (every fare of every room type) — the rates a request may be spent on. On Expedia this is two requests per rate in that set; on Agoda almost all of that set resolves for free (see Taxes and fees below), so in practice one_fare_per_room still returns a tax on far more than one fare per room — a request is only ever spent on a rate that needs one. Defaulted to the cheaper option when tax_required is sent without it. Rejected with 400 if sent without tax_required: true.
currencystringOptionalINROne of INR, USD, GBP, EUR, AED. Unsupported codes are rejected with a 400.
device_typestringOptionaldesktopdesktop, mobile or app_android. desktop / mobile return that device's actual room/rate matrix, not the cheapest price across devices — real on Agoda, see Device pricing. app_android selects Agoda's Android app instead of its website — see Android app route. Any other value is rejected with a 400.
Agoda, Booking.com, MakeMyTrip and Expedia all price children. Send ages, not a count: "children_ages": [9, 12] — each of the four prices a child by age, so an 11-year-old and a 2-year-old are different products on the same rate. Sending children_ages with an OTA that hasn't been wired for it is rejected with 400 rather than silently priced adults-only. A bare children count (on any OTA, including these four) is rejected the same way — there's no honest way to turn a count into a request. See Children below, and Booking.com / MakeMyTrip / Expedia for how occupancy is reported on each.
POST /api/v1/scraper/ota-price/ — Agoda
curl -X POST https://api.scrapeguys.com/api/v1/scraper/ota-price/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "ota": "agoda", "hotel_name": "The Taj Mahal Palace, Mumbai", "city": "Mumbai", "check_in": "2026-12-15", "check_out": "2026-12-17", "adults": 2, "rooms": 1, "currency": "INR" }'
202 Accepted
{ "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "pending", "ota": "agoda", "ota_hotel_id": "", "place_id": "", "poll_url": "/api/v1/scraper/ota-price/a1b2c3d4-e5f6-7890-abcd-ef1234567890/" }
Changed 16 October 2026 — a misspelt field is a 400. Fields this endpoint 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 (checkIn, check-in) 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.

Addressing an OTA by number

Anywhere an OTA name is accepted — ota here, and each entry of otas on Rate Shop — you may send a numeric channel code instead:

CodeOTANotes
1agoda—
2makemytrip—
3booking—
4expediaadded 21 September 2026
5goibiboadded 21 September 2026

The same table is served at GET /otas/, so an integration can read the mapping at runtime rather than transcribe it — an OTA numbered later then appears on its own.

Quoted ("1") and bare (1) both work. Names and aliases keep working exactly as before, and ota always echoes the canonical name, never the number — a job submitted as "ota": 2 comes back as "ota": "makemytrip". The number is in result.ota_code beside it, so an integration keyed on these numbers need not map ota back itself. In a rate shop, mixing forms is fine and a code and its name are recognised as the same OTA rather than billed twice.

A fixed contract, not a generated list. These numbers are written down explicitly rather than derived from any ordering, so adding an OTA in future cannot renumber the existing ones. 1, 2 and 3 have not moved and will not — a code is an identifier you store, and moving one would silently re-point an existing integration at a different channel. expedia and Goibibo answered ota_code: null and had no number at all until 4 and 5 were added for them on 21 September 2026. Adding a number is safe for an existing caller in a way moving one never is. A code does not imply the endpoint offers that OTA — 5 is accepted on /ota-price/ only; Goibibo is still held out of Rate Shop under its number exactly as under its name.

result.ota_code shipped 15 September 2026: every result — on /ota-price/ and on each row of Rate Shop — now carries the OTA's numeric channel code alongside ota, including on failed jobs. 4 and 5 (expedia, goibibo) were added 21 September 2026 — before that both answered null.

OTA Directory

Every OTA this API prices, with the numeric code that addresses it, the aliases accepted for it, the endpoints that actually offer it, and whether your key is scoped for them. It's the machine-readable form of the table above, published so an integration can read the mapping at startup instead of hard-coding it. Costs 0 units.

GET/api/v1/scraper/otas/

No query parameters. Any active key may call it — it carries no scope of its own, because it's the same contract this page publishes to every client.

200 OK — GET /otas/
{ "otas": [ { "code": 1, "ota": "agoda", "aliases": [], "endpoints": ["ota-price", "rate-shop"], "scopes": {"ota:agoda": true} }, { "code": 2, "ota": "makemytrip", "aliases": ["makemytrip.com", "mmt"], "endpoints": ["ota-price", "rate-shop"], "scopes": {"ota:makemytrip": true} }, { "code": 3, "ota": "booking", "aliases": ["booking.com"], "endpoints": ["ota-price", "rate-shop", "destination-search"], "scopes": {"ota:booking": true, "destination:booking": false} }, { "code": 4, "ota": "expedia", "aliases": ["expedia.co.in", "expedia.com"], "endpoints": ["ota-price", "rate-shop"], "scopes": {"ota:expedia": true} }, { "code": 5, "ota": "goibibo", "aliases": ["goibibo.com"], "endpoints": ["ota-price"], "scopes": {"ota:goibibo": false} } ] }
FieldMeaning
codeThe numeric channel code. Send it anywhere an OTA is named — ota on Submit a job, any entry of otas on Rate Shop — quoted or bare. null for an OTA not yet numbered; there are none today.
otaThe canonical name, and the one every response echoes back. A job submitted as 2 returns "ota": "makemytrip" with result.ota_code: 2 beside it.
aliasesOther strings accepted for this OTA, e.g. "mmt", sorted alphabetically.
endpointsThe endpoints that offer this OTA: ota-price, rate-shop, destination-search.
scopesEvery scope gating this OTA → whether your key holds it. A false is a fact about your key, not about the OTA.

Rows are ordered by code.

A code does not mean every endpoint offers that OTA. Goibibo carries 5 and is still held out of the rate shop, under its number exactly as under its name — read endpoints rather than inferring availability from the presence of a code.
Says nothing about an OTA being momentarily disabled on our side. That's operational state which can change between this call and your next one — /ota-price/ reports it authoritatively at the moment it matters.

Errors — 401 (missing, unknown or deactivated key).

Identifying the hotel

A request identifies its hotel one of three ways.

By name (hotel_name + city) is the default — resolved through Agoda's own search and picked by us. It works without you knowing anything about Agoda, and is what every existing integration uses.

By Google Place ID (place_id) is the recommended way to pin a portfolio. One id identifies the property across Agoda, Booking.com, MakeMyTrip and Goibibo, so you keep a single key per hotel instead of four. The first request for a place still goes through Agoda's own search — there's no other way in — but the id that actually returned a price is then pinned, so every later request for that place_id addresses the same property directly. A pin is written only when Agoda names the property and that name agrees with the Place ID's own; otherwise the job still returns prices and the next request resolves again. Send hotel_name alongside it if you have it — it's used as the search text for that one resolution; without it we look the name up from the Place ID itself.

By Agoda's own id (ota_hotel_id, its numeric propertyId, e.g. "16082488") is the most direct route, and the one to prefer once you hold it. The whole resolution step disappears — the single most expensive part of a job — and the answer cannot drift to a different hotel between runs. The id's format is checked at submit (digits only): a malformed id is rejected with 400 before any job is created or billed. A well-formed id from another OTA cannot be told apart and is priced as Agoda's own id — Agoda, MakeMyTrip and Goibibo ids are all plain numbers.

Where to get the id. Every result already returns Agoda's own id as result.hotel_id, so one name-based run per property gives you the id to send from then on.

Checking we priced the hotel you meant. Read result.origin_hotel_name — what Agoda itself calls the property it priced. result.hotel_name cannot answer this on Agoda: it substitutes its own name rather than echoing your search text, but it's still not a confirmation signal — use origin_hotel_name. An empty origin_hotel_name means Agoda named nothing on that job, never that it agreed.

As of 18 September 2026, a by-name resolution takes a second look whenever the pick doesn't cover a distinguishing word from your request — typically a locality dropped because the name carries a chain suffix (e.g. ", an IHG Hotel"), which can make Agoda's own search substitute a same-brand property in another city instead of simply failing. This cuts down how often that happens; it isn't a guarantee, so keep checking origin_hotel_name regardless.

What we do NOT do with your id. A submitted ota_hotel_id is used exactly as given and changes no state on our side — it's never written into our own name→id mapping. If Agoda reports the property as delisted, we re-resolve once from hotel_name + city with the dead id excluded, and price the current listing only if its name still matches the hotel you asked for — a near-miss (e.g. Hyde Hotel Dubai vs Hyde Park Hotel) is refused, since the response echoes your name and you could not tell. If there is no match, or the request carried no name and city to search on, the job fails with dead_property_id.

Device pricing — device_type

device_type is argument-driven: desktop (default) or mobile returns that device's actual room/rate matrix — not the cheapest price across devices. Ask for desktop and you get desktop prices even where mobile is cheaper, and vice versa. The device_type you submitted is echoed back on the job envelope.

Real on Agoda. A phone is quoted a fenced rate channel on a minority of properties (usually ~10% cheaper, occasionally dearer) — mobile fetches under a phone identity and returns the phone's own rates. (On Booking.com this is also real but structured differently; on MakeMyTrip it's device-flat — prices are byte-identical to desktop; on Goibibo the field is accepted but ignored — every scrape is the Android app route regardless.)

desktop and mobile are both web identities — no OTA lets you ASK for Android apart from iOS on its website. Agoda's app_android value is different in kind: it selects a separate app route, below. Goibibo is the one source that always answers as the Android app internally, regardless of what you send.

The Android app route — device_type: app_android

A third device_type value, available for ota: agoda and ota: booking (see Booking.com's): it prices through the room-grid call Agoda's own Android app makes instead of its website, and returns the rooms and rates the app is quoted. It is not mobile renamed — the app's identity opens an app-only rate channel on a minority of properties (rate_channel_id "55"), where a phone browser is quoted a different fenced channel. MakeMyTrip, Expedia and Goibibo have no app route to select and reject app_android with a 400 naming device_type.

POST /api/v1/scraper/ota-price/ — Agoda, Android app route
curl -X POST https://api.scrapeguys.com/api/v1/scraper/ota-price/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "ota": "agoda", "hotel_name": "The Taj Mahal Palace, Mumbai", "city": "Mumbai", "check_in": "2026-12-15", "check_out": "2026-12-17", "adults": 2, "currency": "INR", "device_type": "app_android" }'

The result has the same envelope and response shape as every other Agoda job (see Response schema) — device_type is just echoed back as "app_android". Everything else on this page applies unchanged except the following:

FieldOn the app_android route
ota_hotel_id / place_idSame numeric propertyId as the website route, so an id you hold works on both, and a place_id pinned by one route is replayed by the other.
currency, roomsINR, USD, GBP, EUR, AED and rooms 1–8, exactly as on the website route — no narrower than it, unlike Booking.com's app route.
children_agesSupported. Measured: ages [2, 11] moved the answer from the 2-adult grid to the family rooms, as on the website route.
tax_required / tax_scopeSupported, and it runs the website route's own recipe (see Taxes and fees) — one limit: Agoda's booking form will not state a rate on an app-only channel. Those rates are filled when they are recognised as GST on sight (India, INR); otherwise they keep tax_amount: null with tax_and_fees intact.
price_basisThe app's answer carries no price label, so the route states the basis itself: per night, per_night_before_taxes or per_night according to Agoda's own tax-inclusive flag on the rate, unknown if Agoda sends none (and tax_status follows).
tax_and_fees / tax_basisAgoda sends a rate's tax-inclusive total to some device identifiers and not others. The route asks under identifiers it is known to send it to; if none tried carries it, tax_and_fees is null on every offer of that job, and tax_required then has nothing to read.
bandwidth_kbCounts every fetch the job made — getting that figure can take a second fetch on a cold start.
device_identityAlways null — a single identity is asked.
Availability. The app_android route ships switched off by default and is enabled per server, separately from the main ota:agoda gate. Where it isn't enabled, a request with device_type: app_android is rejected with 400 naming device_type ("The agoda app_android route is not enabled on this server.") before any job is created. Contact us to enable it.

Poll for results

Poll the poll_url every ~2 seconds until status is completed or failed. Statuses: pending → processing → completed | failed. Job IDs are scoped to this endpoint — a Google Hotels job ID 404s here, and vice versa.

GET/api/v1/scraper/ota-price/{job_id}/
GET /ota-price/{job_id}/
curl https://api.scrapeguys.com/api/v1/scraper/ota-price/YOUR_JOB_ID/ \ -H "X-API-Key: gsk_your_api_key" # Repeat every ~2 s until status is "completed" or "failed"

Response schema

The poll response is a job envelope: id, status, ota, hotel_name, city, ota_hotel_id, place_id, check_in, check_out, adults, children_ages, rooms, currency, device_type, result, error_message, created_at, started_at, completed_at, duration_seconds, bandwidth_kb — plus tax_required / tax_scope, echoing what you submitted (see Taxes and fees). On completion, result holds the pricing object, which nests HotelRoomPricing → room_types[] → offers[]. (bandwidth_kb is the measured, compressed proxy traffic for the scrape — response headers excluded, and failed scrapes count what they spent.)

hotel_name at the top of the envelope is not stable when you didn't send one. If you identified the hotel by place_id or ota_hotel_id alone, whatever appears here is incidental and can differ between two identical requests. Never join on it — use result.origin_hotel_name for Agoda's own name and result.hotel_id for its id.
200 OK — GET /ota-price/{job_id}/ (result, abridged)
{ "id": "a1b2c3d4-...", "status": "completed", "error_message": "", "result": { "ota": "agoda", "ota_code": 1, "hotel_name": "The Taj Mahal Palace, Mumbai", "origin_hotel_name": "The Taj Mahal Palace, Mumbai", "hotel_id": "16082488", "currency": "INR", "check_in": "2026-12-15", "check_out": "2026-12-17", "adults": 2, "rooms": 1, "nights": 2, "adults_per_room": 2, "price_scope": "per_room", "occupancy_honored": true, "cheapest_fitting_price": 22500.0, "cheapest_fitting_basis": "per_night_before_taxes", "cheapest_fitting_meal_plan_code": "CP", "cheapest_fitting_tax_status": "excluded", "cheapest_room": { "room_name": "Luxury Room Palace Wing", "price": 22826.97, "price_includes_taxes": true, "currency": "INR", "meal_plan": "Breakfast included", "meal_plan_code": "CP", "refundable": true, "free_cancellation": true, "cancellation_text": "Cancel for free before Dec 13, 2026" }, "source": "http", "scraped_at": "2026-10-15T12:00:00+00:00", "sold_out": false, "error": null, "truncated": false, "suppliers": ["3038"], "rate_channels": ["1"], "room_types": [ { "name": "Luxury Room Palace Wing", "sold_out": false, "room_size": "45 m²", "bed": "1 king bed", "view": "City view", "amenities": ["Free WiFi", "Parking"], "images": ["https://..."], "tags": ["free-cancellation", "king-bed"], "room_id": "991243155", "room_size_sqm": 45.0, "max_guests": null, "offers": [ { "price": 22500.0, "currency": "INR", "price_text": "₹ 22,500", "price_basis": "per_night_before_taxes", "price_before_discount": null, "board": "Breakfast included", "free_cancellation": true, "cancellation_text": "Cancel for free before Dec 13, 2026", "refundable": true, "pay_later": false, "payment_text": "Book and pay now", "occupancy": 2, "occupancy_source": "capacity", "rooms_booked": null, "fits_occupancy": true, "badges": ["Lowest price available!"], "rate_token": "8b:0;c:2D1N_100P;...", "supplier_id": "3038", "rate_channel_id": "1", "device_identity": null, "tax_included": false, "tax_status": "excluded", "tax_amount": null, "fee_amount": null, "tax_percentage": null, "tax_and_fees": 326.97, "tax_basis": "tax_only", "meal_plan_code": "CP", "meal_plan_code_raw": null, "source_url": "https://www.agoda.com/en-us/book/?secdat=…", "source_url_scope": "rate" } ] } ] } }

Object fields

ObjectFields
HotelRoomPricingota, ota_code, hotel_name, origin_hotel_name, hotel_id, currency, check_in, check_out, room_types[], adults, children_ages[], rooms, nights, adults_per_room, children_per_room, price_scope ("per_room"), occupancy_honored, cheapest_fitting_price, cheapest_fitting_basis, cheapest_fitting_meal_plan_code, cheapest_fitting_tax_status, cheapest_room, source ("http"), scraped_at, sold_out, error, truncated, suppliers[], rate_channels[] — suppliers[]/rate_channels[] roll up the Agoda channel fields seen across the offers (see Agoda-specific notes). ota_code is the same numeric channel code ota accepts on submit (see Addressing an OTA by number) — 1 here, present on failed jobs too. origin_hotel_name is what Agoda itself calls the property, or "" when it named none — see Identifying the hotel. children_ages echoes what you submitted ([] for an adults-only request, including on a failed job); children_per_room is the same list spread across rooms, rounded up. cheapest_fitting_tax_status (added 13 October 2026) is the tax_status of the offer cheapest_fitting_price was read from — included, excluded or unknown, so the scalar says whether it already contains tax without decoding the basis; null only when cheapest_fitting_price is. cheapest_room (added 14 October 2026) is one rate priced all-in: the cheapest rate that seats the party, ranked on price plus the taxes and fees the OTA stated, for one room for one night — {room_name, price, price_includes_taxes, currency, meal_plan, meal_plan_code, refundable, free_cancellation, cancellation_text}. It can be a different rate from cheapest_fitting_price, whose ranking ignores tax. price_includes_taxes is false only when no fitting rate stated its tax, and then price is the pre-tax figure. null on a failed or sold-out job and when no rate is confirmed to fit. source is "http" on every OTA.
RoomTypename, offers[], sold_out, room_size, room_size_sqm, max_guests, bed, view, description, amenities[], images[], tags[], room_id — bed is the OTA's own wording and case (e.g. "1 king bed"); only spacing mechanics are cleaned up centrally, so the same bed can't read as two different strings depending on which OTA sent it. view is one of a fixed vocabulary, identical across every OTA: "Sea view", "City view", "Garden view", "Courtyard view", "Pool view", "Lake view", "Valley view", "Bay view", "Jungle view", "Hill view", "Mountain view", matched on whole phrases only — never a bare noun, so a room called "Suite with Plunge Pool" is not a pool view. Synonyms fold onto one token ("Ocean View" → Sea view, "Swimming Pool View" → Pool view); Hill view and Mountain view deliberately do not fold into each other. null means no view in the vocabulary was named — never a raw upstream string. Mined from the room name and Agoda's own room tags — Agoda has no separate structured view field.
RoomOfferprice, currency, price_text, price_basis, price_before_discount, board, free_cancellation, cancellation_text, refundable, pay_later, payment_text, occupancy, occupancy_source ("capacity"), max_children, rooms_booked, fits_occupancy, badges[], rate_token, raw_label, supplier_id, rate_channel_id, device_identity, tax_included, tax_status, tax_amount, fee_amount, tax_percentage, tax_and_fees, tax_basis, meal_plan_code, meal_plan_code_raw, source_url, source_url_scope — the channel and device fields are Agoda-specific, populated only here (null on Booking.com/MakeMyTrip); tax_percentage, tax_and_fees, pay_later and payment_text are Agoda-only, see Agoda-specific notes below. Itemized tax_amount and fee_amount are Agoda and Expedia — see Taxes and fees. max_children is a Booking.com-only capacity field, always null here — see Booking.com's Children section. price is always the amount actually payable; price_before_discount is null on Agoda — it never exposes a usable strikethrough on this route (Booking.com does; see its guide). free_cancellation / refundable are bool | null, tri-state: null when Agoda's rate text states no cancellation policy at all, false only when it explicitly states the rate is non-refundable, true when it explicitly offers free cancellation.
FieldMeaning on Agoda
free_cancellationTri-state, matching refundable: true Agoda states free cancellation, false Agoda states the rate is not freely cancellable, null Agoda states neither. A rate inside its own penalty window carries no "Cancel for free before …" text at all — since 15 September 2026 that case is read from Agoda's own isFreeCancellation flag and policy code instead of falling back to null. cancellation_text stays null there, because Agoda published no wording to show.
refundableTri-state: true some or all of the money comes back on cancellation, false none does, null unknown. Usually agrees with free_cancellation — the exception is a no-cancellation-text rate whose policy code charges only part of the stay: free_cancellation: false, refundable: true.
rooms_bookedAgoda only. How many rooms this one rate books together — set only on a multi-room package such as Agoda's "2 X Room", which it offers to a party one room cannot seat. Its price and every amount beside it are for all of those rooms together, not for one, so a package is never fits_occupancy: true and never counts towards cheapest_fitting_price or cheapest_room. null on every ordinary one-room rate. Added 14 October 2026.
pay_laterAgoda only. true when Agoda lets the guest book now and pay later, false when it says payment is due at booking, null when it says neither. Read from Agoda's own flag, not inferred from the prose — tri-state on purpose, like refundable: a rate whose terms we couldn't read is never reported as pay-now. Measured to be a per-property behaviour, not a per-market one — some properties offer it on every rate, most don't offer it at all.
payment_textAgoda only. Agoda's own wording for when payment is due, verbatim — the deadline where it states one ("No payment until Nov 18, 2026"), otherwise the button's "Pay later" / "Book and pay now". Published as text rather than parsed into a date, exactly like cancellation_text, because the wording is Agoda's to change.
tax_includedWhether price already contains taxes and fees — restates price_basis as a boolean, so the two can never disagree.
tax_statustax_included as a word: included, excluded or unknown. Always one of the three, never null — where tax_included is null (price_basis: "unknown") this says unknown. Read it rather than the boolean wherever a null would otherwise be treated as false: a rate whose tax status wasn't stated is not a tax-exclusive one. Derived beside tax_included, so the two cannot disagree. Added 13 October 2026.
tax_amountnull unless you ask for it. Send tax_required (with a tax_scope) and, on the rates that resolve, this is the tax on the whole charge — the room and any extra-bed fee beside it combined — per night, matching price. It's filled free from the room grid where the grid's figure is proven to be tax alone (the GST rate recognised on sight, or a share already proven at this property); otherwise it's read from Agoda's own booking-form itemization, within tax_scope. No estimate either way: an unasked-for or unresolvable rate reports null. See Taxes and fees below.
fee_amountAgoda and Expedia, with tax_required, only. Whatever else sits in the figure beside the tax — an extra-occupancy (child / extra-bed) charge, net of its own tax (that tax lives in tax_amount instead, so price + tax_amount + fee_amount doesn't double-count it), a service charge, or an extra-person fee payable at the property. 0.0 where the rate carries none; null on every non-itemized rate, and where a charge exists but Agoda's booking form couldn't be reconciled with the room grid's own combined figure. Goibibo populates this field too, with tax_required, but it answers a different question there — the hotelier's service charge plus Goibibo's own service fee, not an extra-occupancy charge; see its guide.
tax_percentageAgoda + tax_required, only. The rate the tax in tax_amount is charged at — either the Indian GST rate the figure exactly equals on the rate's price or its pre-discount price (5 or 18), or Agoda's own stated rate from the booking form, for this rate or for the rate whose booking-form answer proved this one's tax share — never derived from tax_amount / price, which isn't a rate at all on a discounted row or one carrying a fee. null when neither applies, including when Agoda states a literal 0 on a row it taxed.
tax_and_feesAgoda, on every offer, free — no tax_required needed. Everything Agoda adds on top of price, taxes and fees together without saying how it splits, so price + tax_and_fees is the all-in on every rate, per room — matching price. Fixed 6 October 2026: a request for more than one room used to return this figure for the whole booking, reading 136% of price at two rooms and 254% at three; a rooms > 1 job scraped before that date should be re-pulled if it used this field. Not a tax — where a rate needs an extra bed, that charge is inside it and is often the larger half; use tax_basis to see which rows carry one, and itemize with tax_required to split it out. null means Agoda stated no total, never that the rate carries no extra charge.
tax_basisAgoda, on every offer, free — no tax_required needed. tax_and_fees: the figure has something other than tax in it. tax_only: it does not. null: Agoda said nothing. On an itemized rate this is rewritten from what the itemization actually found. ⚠️ On a rate that was not itemized, this comes from Agoda's own extra-bed count alone, so tax_only there rules out an extra bed and nothing else — a service charge or an extra-person fee can still be sitting in the same figure without the bed count moving (measured: 47 of 468 checked rates). Treat a non-itemized tax_only as "no bed charge", not "no charge at all"; send tax_required to know for certain.
meal_plan_codeOne of EP (room only), CP (breakfast), MAP, AP, AI — derived from board on Agoda (it publishes no meal code of its own, so meal_plan_code_raw is always null here). Since 15 September 2026, a rate that names no meal is EP when Agoda flags it isBreakfastIncluded: false (previously null), and a second meal listed as its own benefit alongside breakfast (e.g. "Breakfast included" + "Dinner Included") reads MAP, not CP — the same rule applies on Booking.com and MakeMyTrip. Since 23 September 2026, a rate whose wording names no meal is also read from Agoda's own numeric benefit ids — Agoda publishes no meal plan code of any kind, so these ids are the only statement of the meal that isn't prose Agoda is free to reword. This rung never overrides a stated isBreakfastIncluded: false flag when the two disagree — the rate then reads null rather than a guess. null only when Agoda states no meal plan and neither the flag nor the benefit ids establish one.
cheapest_fitting_meal_plan_codeAdded 23 September 2026. The meal_plan_code of the same offer cheapest_fitting_price was read from — two OTAs (or two rates) quoting the same amount aren't quoting the same product if one of them includes breakfast, and that isn't visible from the scalar price alone. null where the offer stated no meal plan, or where no offer fits the party.
source_url / source_url_scopeAgoda is the one OTA that gives a rate-level link — source_url_scope is "rate", meaning the URL opens the booking form for this exact offer, with dates and occupancy pre-applied (Booking.com and MakeMyTrip only link to the property page — see their guides). null when no URL was available.
Price basis on Agoda. price_basis is a per-offer field with values per_night_before_taxes, per_night, total_stay_before_taxes, total_stay or unknown. On Agoda it's usually per_night_before_taxes, derived from Agoda's own price label — always read the field rather than assuming. If you're comparing against Booking.com or MakeMyTrip, note those use different bases (Booking.com quotes the whole stay); use the pre-computed cheapest_fitting_price + cheapest_fitting_basis (+ cheapest_fitting_meal_plan_code, so you're not comparing a breakfast rate against a room-only one) to normalize.

Taxes and fees — the all-in for free, the split almost always free too

Two different questions, two fields:

FieldQuestion it answersCost
tax_and_feesWhat does this rate cost on top of price?free, on every Agoda offer
tax_amount + fee_amountHow much of that is tax, and how much is a fee?opt-in (tax_required) — free on ~97% of rates; a booking-form request is spent only where the grid's combined figure can't be proven to be tax alone, and is shared across every rate it proves — see Cost on Agoda below
The identity that holds either way
price + tax_and_fees = the all-in, always (per room, matching price) price + tax_amount + fee_amount = the same all-in, to within a fraction of a rupee, on rates we itemized

fee_amount is deliberately net of its own tax — that tax is folded into tax_amount instead, which covers the whole charge, so the sum doesn't double the fee's tax. The sub-rupee gap against tax_and_fees is the published price being floored while the itemized figures are not.

tax_and_fees is not a tax rate and must not be labelled "Tax" in a UI. Where a rate needs an extra bed, that charge is inside it and is often the larger half. tax_basis (free, on every offer) flags the rows that carry something other than tax — but on a rate that was never itemized, read the warning under the tax_basis row above: it only rules out a bed, not a service charge or an extra-person fee.

tax_amount is null unless you ask for it. There's no estimate — a figure appears only where the rate was itemized for us, either for free straight off the room grid or on the booking form. Ask for it with:

Request body — the two fields that opt in
{ "tax_required": true, "tax_scope": "one_fare_per_room" }

"Cheapest" (what one_fare_per_room itemizes) means cheapest among rates that can seat your party — Agoda routinely returns rates that cannot, and as the cheapest row — falling back to the cheapest overall if none fit. tax_scope only bounds what a request may be spent on, not what comes back free — see below.

On a rate that was itemized you get three fields. tax_amount and fee_amount are always read rather than computed:

FieldMeaning
tax_amountThe tax alone
fee_amountEverything else in the figure beside the tax — an extra-occupancy (child / extra-bed) charge net of its own tax, a service charge, or an extra-person fee payable at the property; 0.0 where the rate carries none, null where one exists but couldn't be reconciled
tax_percentageThe GST rate the figure was recognised at, or Agoda's own stated rate from the booking form (for this rate, or for the rate that proved its tax share); null where neither applies

So the all-in is price + tax_amount + fee_amount. A real, measured example:

Hotel Ajanta — two itemized rates
Premium Room price 6,845.00 + tax 1,824.31 + extra bed 2,085.00 = 10,754.31 (18%) Deluxe Room price 3,423.00 + tax 204.01 + extra bed 0.00 = 3,627.01 (5%)

tax_basis comes free on every offer. On an itemized rate it's rewritten from what the itemization found: tax_and_fees whenever fee_amount is above zero, whatever the charge turned out to be. On a rate that was not itemized, it's read from Agoda's own extra-bed count alone — see the warning under the tax_basis field row above before treating tax_only there as "no charge at all".

Cost on Agoda. The room grid already states each rate's combined tax_and_fees figure, and on most rates that figure is the tax and nothing else — the grid just doesn't say on which. A rate is resolved the cheapest way first:
  1. Recognised, no request. For a property in India priced in INR, the figure is checked against the GST slab (5% up to ₹7,500 a night, 18% above) on the rate's price and on its pre-discount price. An exact match is tax and nothing else by construction.
  2. Proven, no request. The figure is the same share of the price as a share the booking form has already itemized as tax alone at this property, within the last 14 days.
  3. One request for a group. One rate of each remaining distinct share is put to the booking form; if it comes back tax alone, every rate sharing that share is filled at no further cost.
  4. One request each. A rate carrying a charge (an extra bed, a service charge, an extra-person fee), and every rate sharing its share.
Measured live across 22 properties: 97% of rates return a tax with no request at all, and a tax_required job typically costs 0–1 booking-form requests total (median 0 under one_fare_per_room, 0–1 under all_fares_all_rooms) — against roughly one request per rate before this shortcut existed. What's left is almost always rates that genuinely carry a charge, which still need one request each on every job. Every job prices under the same public rate channel regardless of tax_required, so asking for the itemized tax never changes any other price on the job.
Not every rate resolves. A rate the booking form won't state — and, on Agoda, every rate sharing its share once a few of them have been refused — reports tax_amount: null rather than an unverified figure; tax_and_fees is still there for the all-in. A job issues at most 80 booking-form requests, so on a large property under all_fares_all_rooms the rates beyond that keep tax_amount: null too. Itemizing never costs extra quota: the job is one unit either way. Read the job's own tax_required / tax_scope echo (see Response schema) to tell "you didn't ask" apart from "we asked and couldn't verify". Agoda, Expedia and Goibibo — tax_required on booking's default route or on makemytrip is a 400 (Booking.com's app_android route is the one exception — itemizing is free there too). See Expedia's guide for how its split differs (no free combined figure, and a heavier per-rate cost), and Goibibo's guide for the one OTA here where itemizing costs nothing extra.
CaseStatusMessage
tax_required on an OTA that can't itemize400`<ota>` cannot itemize a rate's tax yet — the figure returned would be taxes AND fees combined, which is what `tax_amount` already gives you without this flag. Supported: agoda, expedia, goibibo.
tax_scope sent without tax_required: true400`tax_scope` selects WHICH rates get the itemized tax, so it only means anything with `tax_required: true`. Send both, or neither.

tax_required without a tax_scope is not an error — it defaults to one_fare_per_room, the cheaper of the two.

Booking.com's default route and MakeMyTrip still report taxes and fees combined in tax_amount, from their own responses, with no split available via tax_required — see their guides. (MakeMyTrip does publish the free tax_basis now, so you can at least tell which rows carry a fee before deciding it doesn't matter. Booking.com's app_android route itemizes too, for free — see below.) Agoda, Expedia and Goibibo itemize — Goibibo's split costs nothing extra, unlike the other two.

POST /api/v1/scraper/ota-price/ — itemized tax
curl -X POST https://api.scrapeguys.com/api/v1/scraper/ota-price/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "ota": "agoda", "hotel_name": "The Taj Mahal Palace, Mumbai", "city": "Mumbai", "check_in": "2026-12-15", "check_out": "2026-12-17", "adults": 2, "rooms": 1, "currency": "INR", "tax_required": true, "tax_scope": "one_fare_per_room" }'

An itemized offer in the result:

result.room_types[].offers[] — itemized
{ "price": 6845.0, "tax_included": false, "tax_amount": 1824.31, "fee_amount": 2085.0, "tax_percentage": 18.0, "tax_and_fees": 3909.76, "tax_basis": "tax_and_fees" }

That rate carried an extra-bed charge, so it cost a booking-form request. Most rates don't need one — same request body, no extra cost, resolved straight off the room grid:

result.room_types[].offers[] — itemized free, recognised on sight
{ "price": 6280.0, "tax_included": false, "tax_amount": 375.29, "fee_amount": 0.0, "tax_percentage": 5.0, "tax_and_fees": 375.29, "tax_basis": "tax_only" }

tax_percentage is populated there even though no booking-form request was made — the figure matched the GST slab (5% up to ₹7,500 a night, 18% above) on the rate's price exactly, which is how it was recognised as tax alone.

tax_required shipped 3 Sep 2026 to split a rate's tax from any extra charge beside it, itemized and verified per rate. The all-in figure it replaced was never wrong, only mislabeled — it's back as tax_and_fees, free on every offer whether or not you itemize, under a name that says what it actually contains. Changed 6 October 2026: itemizing stopped costing a request on almost every rate — see the Cost on Agoda callout above.

Children

Send ages, not a count: "children_ages": [9, 12]. Agoda prices each child by age — an 11-year-old and a 2-year-old are different products on the same rate — so there's no honest way to turn children: 2 into a request, and a bare children count is rejected with a 400 telling you to send ages.

Agoda, Booking.com, MakeMyTrip and Expedia — all four, verified live. Each sends a real child occupancy, verified by moving the price with the age rather than just by the request being accepted. Any OTA added later starts unable to price children and stays that way until it is wired and verified the same way — children_ages on an unsupported OTA is rejected with a 400 rather than silently priced adults-only. An adults-only request is unaffected on every OTA. See Booking.com, MakeMyTrip and Expedia for how each reports occupancy once children are priced — it isn't the same on all four.
POST /api/v1/scraper/ota-price/ — with children
curl -X POST https://api.scrapeguys.com/api/v1/scraper/ota-price/ \ -H "X-API-Key: gsk_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "ota": "agoda", "hotel_name": "The Taj Mahal Palace, Mumbai", "city": "Mumbai", "check_in": "2026-12-15", "check_out": "2026-12-17", "adults": 2, "rooms": 1, "children_ages": [9, 12], "currency": "INR" }'

When children are priced, occupancy on each offer counts all guests — Agoda's capacity line reads "2 adults & 2 children", reported here as 4 — so fits_occupancy keeps answering the same question it does for an adults-only party.

MakeMyTrip reports occupancy the same way — all guests folded into one number, when it can match the tariff on adults and children together. Expedia's occupancy was already a total-guest "Sleeps N" capacity before children existed, so it needed no change to fold children in the same way. Booking.com does not: it keeps occupancy as the adult capacity alone and states any child allowance separately, in a max_children field this endpoint returns only on Booking.com — because Booking itself sometimes reclassifies an older child as an adult, with no child line at all. See Booking.com's Children section for that mechanic.

Result outcomes

Submitting can 4xx (validation 400, auth 401, missing scope 403, throttling 429) or, rarely, a 5xx if we couldn't accept the job on our side — retry the submit. Once a job is accepted, polling always returns HTTP 200 — you distinguish scrape outcomes by fields, never by status code:

OutcomeHow to detect
Pricedstatus: "completed", result.error: null, result.room_types populated.
Sold outstatus: "completed", result.sold_out: true, result.room_types: [], result.error: null — resolved but no availability.
Failurestatus: "failed" with a human-readable error_message; result.error carries the same string (e.g. "unresolved (no Agoda match for name+city)", "proxy quota (407): ...", "dead_property_id (property no longer listed on Agoda)", "availability unverified: ...") with result.room_types: [].

A scrape failure is never a 4xx/5xx on the poll — it is a failed job with an explanation.

Agoda-specific notes

Changed 10 September 2026 — free_cancellation/refundable can now be null. A rate whose text stated neither a free-cancellation nor a non-refundable policy used to come back free_cancellation: false — indistinguishable from a rate that explicitly said non-refundable. It now comes back null. false now means Agoda's own text explicitly stated the rate is non-refundable. If you treated false as "not free to cancel", re-check any stored history around this date — many of those rows simply had no stated policy.

Next: Booking.com →