Scraper · OTA

Booking.com

Return the exact per-room-type price a real guest sees on Booking.com'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:booking scope · See the Overview.

Shared endpoint, one vendor per job. /api/v1/scraper/ota-price/ also prices Agoda, MakeMyTrip, Expedia and Goibibo — set "ota": "booking" (alias booking.com) to get this vendor. The request/response shape is the same across all five; this page documents it fully for Booking.com. This prices one hotel you already have — for enumerating every bookable property in a whole city on Booking.com, see the separate Destination Search guide, a different endpoint and scope entirely.

Submit a job

FieldTypeRequiredDefaultNotes
otastringRequired—Set to "booking" explicitly (alias booking.com) — this endpoint defaults to Agoda when ota is omitted. A numeric channel code ("3" or bare 3) may be sent instead — see Addressing an OTA by number.
hotel_namestringConditional—≤ 300 chars. Resolved to Booking.com's own property id via Booking'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 Booking's own id on first use, then pinned — when Booking 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""Booking's own id — a countryCode/slug pair, not a number (e.g. "in/taj-mahal-palace-mumbai"). A full hotel URL is also accepted and the id extracted from it. Skips resolution entirely. See Identifying the hotel. On the app_android route, this is Booking's numeric hotel id instead (e.g. "2451595") — the two shapes are not interchangeable.
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). The app_android route prices one room only — more than 1 is rejected with a 400 there.
children_agesint[]Optional[]One age per child, e.g. [2, 8] — not a count; the list length is the child count. Ages 0–17, max 8 children. Also priced on Agoda, MakeMyTrip and Expedia — see Children below. Also supported on the app_android route.
currencystringOptionalINROne of INR, USD, GBP, EUR, AED. Unsupported codes are rejected with a 400. Narrower on the app_android route: INR, USD, EUR only. The five codes work on both desktop and mobile. Changed 30 October 2026: a non-INR job used to come back with rupee amounts. Prices now arrive in the currency you ask for; the result's currency is read from the prices themselves and currency_requested echoes your request (see Response).
device_typestringOptionaldesktopdesktop or mobile — returns that device's actual room/rate matrix, not the cheapest price across devices. Real on Booking.com — see Device pricing. A third value, app_android, selects Booking's Android app API instead of its website — see Android app route below. Any other value is rejected with a 400.
tax_requiredbooleanOptionalfalseRejected with 400 on Booking.com's default (desktop/mobile) route — Booking's hotel document has no tax components to itemize there. Accepted, and free, on the app_android route — see Taxes on the app route below. A job is one unit of quota whatever you send.
tax_scopestringOptionalone_fare_per_roomOnly meaningful with tax_required: true — see Agoda's Taxes and fees for the two values. Sent without tax_required: true it's still a 400.
Booking.com prices children — send ages, not a count. children_ages (e.g. [2, 8]) is priced by sending each age straight to Booking's own hotel-page URL; a bare children count is rejected with 400 on every OTA, Booking.com included — there's no honest way to turn a count into a request. See Children below for the request shape and how Booking reports occupancy once a child is on the booking.
Availability: Booking.com per-hotel pricing is gated twice while headless-session provisioning is finalized. If your key lacks the ota:booking scope you get 403 {"detail": "This API key does not have the 'ota:booking' scope."}; if the scraper is switched off platform-wide you get 400 {"error": "ota 'booking' is not currently enabled"}. Contact us to enable it.
POST /api/v1/scraper/ota-price/ — Booking.com
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": "booking", "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": "booking", "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.

Identifying the hotel

A request identifies its hotel one of three ways.

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

The city you send is checked against Booking's own label for the property's town with accents folded ("Mūvattupula" reads as "Muvattupula") and a few common aliases (Alleppey / Alappuzha). Where Booking's search labels the town with a different transliteration than yours — "Muvattupuzha" against Booking's "Mūvattupula, Kerala" — the candidate is checked against the town in Booking's own record of the property before it is refused, so a correct spelling of the city no longer fails the job. A candidate whose record places it in another city is still refused.

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 Booking'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 Booking 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 Booking's own id (ota_hotel_id) 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.

Booking's id is a path, not a number. Unlike Agoda and MakeMyTrip, Booking has no numeric property id reachable here — its pricing route is keyed on a countryCode/slug pair, e.g. "in/taj-mahal-palace-mumbai". Send exactly that shape; a plain integer is rejected with 400 rather than silently reinterpreted. A full hotel URL is also accepted and the id extracted from it — "https://www.booking.com/hotel/in/taj-mahal-palace-mumbai.html" becomes "in/taj-mahal-palace-mumbai". The slug may contain letters, digits, hyphens and underscores — some older Booking listings keep underscore page names, e.g. "gr/holidayinnathens_ilissia", and those are accepted as given. The id's format is checked at submit (a countryCode/slug pair or a booking.com URL): 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 Booking's own id.
Where to get the id. Every result already returns Booking'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 Booking itself calls the property it priced (its own page title). result.hotel_name cannot answer this: it's your own search text echoed back, not confirmation that Booking agreed — use origin_hotel_name. An empty origin_hotel_name means Booking named nothing on that job, never that it agreed.

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 Booking's page redirects to the city search page for that slug (the case this site calls wrong_slug), the job fails with that error rather than quietly falling back to a name search, which would hand back a different property.

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 Booking.com. mobile draws Booking's mobile hotel document — the same rooms, priced for a phone (Booking's mobile-only price is ~10% lower on a minority of properties). (On Agoda this is also real, via a fenced rate channel; on MakeMyTrip it's device-flat — prices are byte-identical to desktop.)

desktop and mobile are both web identities — no OTA lets you ASK for Android apart from iOS on its website. Goibibo is the one source that always answers as the Android app internally, regardless of what you send. The app_android value (Booking.com and Agoda) doesn't select a web device at all — it selects a separate app route, below.

The Android app route — device_type: app_android

A third device_type value, available for ota: booking and ota: agoda (see Agoda's app route, which behaves differently): it prices through Booking's own Android app API instead of its website, and returns the rooms and rates the app itself is quoted — not another web device, a different route entirely. Any other OTA rejects app_android with a 400 naming device_type (MakeMyTrip has none; Expedia and Goibibo are already app routes with no web route alongside them to pick from).

POST /api/v1/scraper/ota-price/ — Booking.com, 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": "booking", "hotel_name": "The Taj Mahal Palace, Mumbai", "city": "Mumbai", "check_in": "2026-12-10", "check_out": "2026-12-12", "adults": 2, "currency": "INR", "device_type": "app_android" }'

The result has the same envelope and response shape as every other Booking.com job (see Response schema above) — device_type is just echoed back as "app_android". These fields behave differently on this route:

FieldOn the app_android route
ota_hotel_idBooking's numeric hotel id (e.g. "2451595") — not the countryCode/slug the default route takes. The wrong shape is rejected with 400 rather than silently reinterpreted; the two ids resolve and cache separately, so one route's id never leaks into the other's.
currencyINR, USD, EUR only — narrower than the default route's INR, USD, GBP, EUR, AED. GBP / AED are rejected with 400 here.
rooms1 only. More than one room is rejected with 400 — multi-room pricing hasn't been measured on this route yet.
price_basisWhole stay, same as the default route: total_stay_before_taxes when the app lists an excluded charge, else total_stay.
occupancy_source"priced" — the party each rate is actually priced for. A request for 3 adults also returns the 1- and 2-adult rates the app quotes alongside it; use fits_occupancy / cheapest_fitting_price rather than assuming the first rate fits your party.
truncatedtrue when the extra call for those lower-occupancy rates failed — your party's own rate is still present; only the 1-/2-adult rows may be missing.
children_agesSupported — measured equal to the default route on 138 rate rows across four properties (same ages move the price and the rate's max_children the same way). Same request shape as Children above.
tax_requiredSupported, and free — unlike the default route, which rejects it with 400. See Taxes on the app route below.
place_idSupported, and pinned under this route's own numeric id — separately from the default route's countryCode/slug pin for the same property.
Extra fieldsPopulates pay_later, payment_text, tax_basis, tax_and_fees, badges[] and room images[] — none of these are populated on Booking.com's default route.
source_urlAlways null — an app API call has no page to link to (source_url_scope is null too).
Price vs. the default routeEqual on most properties, but not guaranteed to match — one of six properties measured came back 10% cheaper on the web route for every rate. Don't substitute one route's price for the other's.

Taxes on the app route — free, like Goibibo

tax_required is rejected with 400 on Booking.com's default route — its hotel document has no components to itemize — but it is accepted, at no extra request, on device_type: app_android: the app's own room list states every charge it adds on top of the price, the same way Goibibo's does.

Request body — opt in on the app route
{ "device_type": "app_android", "tax_required": true, "tax_scope": "one_fare_per_room" }
FieldMeaning on the app route
tax_and_feesFree, on every offer — the combined charge, whether or not you asked for the split. price + tax_and_fees is the all-in either way.
tax_amount + fee_amountCombined in tax_amount on an ordinary job (same figure as tax_and_fees). With tax_required, the tax alone, with any fee moved to fee_amount — read from the charges the room list already named, not a second request.
tax_basisFree on every offer: tax_and_fees when a fee is mixed into the excluded charge, tax_only when it isn't, unknown when the app's charges were stated but did not add up to a split we could trust (the combined tax_and_fees is still there), null when nothing was excluded from price.
Availability. The app_android route ships switched off by default and is enabled per server, separately from Booking.com's main ota:booking gate (see the callout under Submit a job above). Where it isn't enabled, a request with device_type: app_android is rejected with 400 naming device_type as the problem, 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, tax_required, tax_scope, rooms, currency, device_type, result, error_message, created_at, started_at, completed_at, duration_seconds, bandwidth_kb — tax_required / tax_scope echo what you submitted (false / "" when you sent neither). 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.)

Non-INR amounts are converted by Booking.com — read quote_basis. Booking prices the stay in the property's own currency and restates it at its own exchange rate (measured 30 October 2026: about 95.2 INR per USD against roughly 88 mid-market). quote_basis is "native" when the amount was quoted in currency itself, "converted" when Booking restated it, and null when the page did not say. It is reported only when observed, and sits on each offer and on the result. Treat a "converted" figure as indicative, or request INR for an Indian property if you need like-for-like comparison. The result's currency is read from the prices; currency_requested echoes your request.
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 Booking'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": "booking", "ota_code": 3, "hotel_name": "The Taj Mahal Palace, Mumbai", "origin_hotel_name": "The Taj Mahal Palace, Mumbai", "hotel_id": "in/taj-mahal-palace-mumbai", "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": 24000.0, "cheapest_fitting_basis": "total_stay_before_taxes", "cheapest_fitting_meal_plan_code": "CP", "cheapest_fitting_tax_status": "excluded", "source": "http", "scraped_at": "2026-10-15T12:00:00+00:00", "sold_out": false, "error": null, "truncated": false, "suppliers": [], "rate_channels": [], "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": 24000.0, "currency": "INR", "price_text": "₹ 24,000", "price_basis": "total_stay_before_taxes", "price_before_discount": null, "board": "Breakfast included", "free_cancellation": true, "cancellation_text": "Free cancellation before 13 December 2026", "refundable": true, "occupancy": 2, "occupancy_source": "capacity", "fits_occupancy": true, "badges": ["Limited-time Deal"], "rate_token": null, "supplier_id": null, "rate_channel_id": null, "device_identity": null, "tax_included": false, "tax_status": "excluded", "tax_amount": 4320.0, "meal_plan_code": "CP", "meal_plan_code_raw": null, "source_url": "https://www.booking.com/hotel/in/taj-mahal-palace-mumbai.html", "source_url_scope": "property" } ] } ] } }

Object fields

ObjectFields
HotelRoomPricingota, ota_code, hotel_name, origin_hotel_name, hotel_id, currency, currency_requested, quote_basis, 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[] are always empty on Booking.com; those are Agoda-only channel fields. cheapest_fitting_tax_status is the tax_status of the offer cheapest_fitting_price was read from (null only when that is); cheapest_room is the cheapest fitting rate priced all-in for one room for one night — Booking's whole-stay amounts are divided by nights, so it is the stay's average night — see Agoda's Response schema for its fields. ota_code is the numeric channel code ota also accepts on submit (see Addressing an OTA by number) — 3 here, present on failed jobs too. origin_hotel_name is what Booking 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. See Children below.
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 — Booking.com has no separate structured view field.
RoomOfferprice, currency, price_text, quote_basis, price_basis, price_before_discount, board, free_cancellation, cancellation_text, refundable, occupancy, occupancy_source ("priced"|"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, tax_basis, fee_amount, tax_and_fees, pay_later, payment_text, tax_percentage, meal_plan_code, meal_plan_code_raw, source_url, source_url_scope — the channel/device fields (supplier_id, rate_channel_id, device_identity) and rooms_booked are always null on Booking.com; those are Agoda-only. tax_percentage is always null; tax_basis, fee_amount, tax_and_fees, pay_later and payment_text are null on the default route and populated only on the app_android route. max_children is the child allowance Booking states separately from occupancy on a rate — see Children below; null means Booking stated no child line on that rate, not that it admits none. price is always the amount actually payable; price_before_discount carries the struck-through original on a discounted row — see Booking.com-specific notes below. free_cancellation / refundable are bool | null, tri-state: null when Booking'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 Booking.com
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 — read it rather than the boolean wherever a null would otherwise be treated as false. Added 13 October 2026.
cancellation_textThe rate's own cancellation sentence, e.g. "Free cancellation before 13 December 2026", read from the policy's own line in the rate row. Changed October 2026: it used to be matched out of the whole conditions text and could carry the next condition with it — on about one rate in ten (all of them pay-at-property rates) it ended with "No prepayment needed – pay at the property". That payment wording is no longer in this field. null when the rate states no cancellation policy.
tax_amountReal on Booking.com, whole-stay — read from the "+… taxes and charges" line Booking renders under the price. That line only ever appears when price itself excludes tax, so tax_amount is only ever non-null when tax_included is false — in which case price + tax_amount is the all-in total. When tax_included is true, price is already all-in and tax_amount is always null — never add it to anything. Contrast with Agoda: there tax_amount is null by default and only gets populated on request (tax_required) — and when it is, it's the tax alone, per night, with any extra-occupancy charge reported separately as fee_amount (see Agoda's Taxes and fees). Don't compare the two bases without converting first.
meal_plan_codeOne of EP (room only), CP (breakfast), MAP, AP, AI — derived from board on Booking.com (it publishes no meal code of its own, so meal_plan_code_raw is always null here). Since 15 September 2026, a second meal listed as its own benefit alongside breakfast (e.g. "Breakfast included" + "Dinner Included") reads MAP, not CP — the same shared rule as on Agoda and MakeMyTrip. Since 23 September 2026, a rate whose wording prices the meal it leaves out (e.g. "Breakfast ₹ 989 (optional)", "Fabulous breakfast ₹ 2,500") also resolves to EP — Booking publishes no room-only code and no room-only flag, so the price it puts on the excluded meal is the only witness. A rate that never mentions food is still null: silence is not evidence, only a stated exclusion is.
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_scopeBooking.com only links to the property page — source_url_scope is always "property", meaning the URL opens the hotel's page on Booking, not a specific rate; the traveller still has to pick the rate there. Contrast with Agoda, which links straight to the booking form for the exact offer (source_url_scope: "rate"). null when no URL was available.
Price basis on Booking.com. price_basis is a per-offer field with values per_night_before_taxes, per_night, total_stay_before_taxes, total_stay or unknown. Booking.com returns total_stay or total_stay_before_taxes (depending on whether Booking shows a taxes-and-charges line) — the total for the whole stay, not per-night. Normalize before comparing with Agoda or MakeMyTrip, both of which price per-night; 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 avoid doing this by hand.

Children

Send ages, not a count: "children_ages": [2, 8]. Booking.com prices each child by age — its own hotel-page URL carries an age/req_age pair per child — so a bare children count is rejected with 400, same as on every other OTA.

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": "booking", "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": [2, 8], "currency": "INR" }'
occupancy stays the ADULT capacity on Booking.com — children are a separate max_children allowance, not folded in. Booking states two capacities on a rate and they aren't interchangeable: a row reading "Max adults: 2 / Max children: 1" seats a 2-adult party and that child at the 2-adult price, so occupancy: 2 plus max_children: 1 is a party of three. max_children is null when Booking states no child line at all — which is not the same as "0 children allowed": Booking sometimes reclassifies an older child as an adult instead, dropping the child line entirely and serving a higher occupancy row in its place (measured live: a 2-adult party plus one child aged 2 or 8 stayed on the "Max adults: 2 / Max children: 1" row at an unchanged price; the same party with the child aged 15 instead saw a distinct "Max adults: 3" row at a higher price, with no child line at all). fits_occupancy already accounts for this — it checks the requested adults and children against occupancy and max_children together, so you don't have to combine the two fields by hand to know whether a rate can seat your party.

Some childful searches show each rate as "Guests: 2 adults , 1 child" with no "Max children" line at all. That line is read as the party the rate was priced for: occupancy_source is "priced", occupancy is the adults and max_children is the children — so such a rate seats exactly that party (and not 3 adults, or 2 adults with 2 children). Before October 2026 those rows were read as having no child allowance, so a childful search could come back with no rate fitting the party.

Contrast with Agoda and MakeMyTrip, where a matched childful rate reports occupancy as all guests (adults plus children) with max_children always null — that field only ever appears on Booking.com results.

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 Booking match for name+city)", "proxy quota (407): ...", "wrong_slug (hotel page redirected to search results)") with result.room_types: [].

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

Booking.com-specific notes

Changed 22 Aug 2026 — Booking.com stale identifiers. An input that used to come back status: "completed" with result.sold_out: true when Booking's identifier for the hotel had gone stale now comes back status: "failed" with error_message: "wrong_slug (hotel page redirected to search results)". If you treated sold_out: true as "no availability" for Booking, re-check any history around this date — some of those rows were actually a stale identifier, not a sold-out property.
Changed 17 Aug 2026 — Booking.com discounted rates. price on a Booking.com row that carries a discount now reports the payable amount instead of the struck-through original — a parser correction, not a price movement, since the old value was never bookable at that figure. price_before_discount is populated on those rows instead of always reading null. If you store a Booking.com price series, expect a one-time step down on discounted rows.
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 Booking'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.
Changed 23 September 2026 — priced-meal wording now resolves meal_plan_code, and "All meals" corrected. See the meal_plan_code row above: a rate that prices the meal it excludes now reads EP instead of null. The shared board reader also now treats a bare "All meals" as full board rather than all inclusive (all inclusive needs the drinks the wording always mentions alongside it), and recognises more trade rate-plan names ("European Plan", "Self catering", "American Plan", "Modified American Plan") that carry no meal word at all — both flow through into meal_plan_code here since it's derived from board. cheapest_fitting_meal_plan_code is new the same day — see the row above.
Added 9 October 2026 — the app_android device route. device_type now accepts a third value, app_android, which prices through Booking's own Android app API instead of its website — see Android app route above. It's a separate, opt-in route: the default (desktop / mobile) behavior on this page is unchanged, and the new route ships switched off by default.

Next: MakeMyTrip →