Scraper · OTA

Expedia

Return the exact per-room-type price a real guest sees on Expedia — resolved via Expedia's own mobile app API, 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:expedia scope · See the Overview.

Shared endpoint, one vendor per job. /api/v1/scraper/ota-price/ also prices Agoda, Booking.com, MakeMyTrip and Goibibo — set "ota": "expedia" (aliases expedia.com, expedia.co.in) to get this vendor; the endpoint defaults to Agoda when ota is omitted. The request/response shape is the same across all five; this page documents it fully for Expedia, including everything Expedia-specific (mobile-only route, per-storefront currency). 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
otastringRequired—Set to "expedia" explicitly (aliases expedia.com, expedia.co.in) — this endpoint defaults to Agoda when ota is omitted. A numeric channel code ("4" or bare 4) may be sent instead — added 21 September 2026, see Addressing an OTA by number.
hotel_namestringConditional—≤ 300 chars. Resolved to Expedia's own property id via its open typeahead search. Required unless place_id is given — see Identifying the hotel.
citystringConditional—≤ 200 chars. Required unless place_id is given. Expedia addresses a property by a hotel-and-region pair, so a name search needs the city — see Identifying the hotel.
place_idstringOptional""Google Place ID. Identifies the property itself — the same id also works on Agoda, Booking.com, MakeMyTrip and Goibibo. Bridged to Expedia's own hotel-and-region pair on first use, then pinned, so the same place_id returns the same property on every later request without a search. 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). Expedia returns one room entry per room, with the party divided across them.
currencystringOptionalINROne of INR, USD, GBP, AED, SGD — note no EUR, unlike Agoda/Booking.com. The currency selects which Expedia storefront (point of sale) is queried; INR is the native, most-tested one. Unsupported codes are rejected with a 400.
device_typestringOptionaldesktopdesktop or mobile: accepted and echoed, but has no effect on Expedia — see Device pricing below. app_android is rejected with a 400 (Expedia has no separate app route to select — it is the app route always), as is any other value.
children_agesint[]Optional[]One age per child, e.g. [4, 15] — not a count; the list length is the child count. Ages 0–17, max 8 children. Also priced on Agoda, Booking.com and MakeMyTrip. See Children below.
tax_requiredbooleanOptionalfalseSplit tax_amount into tax + fee for the rates tax_scope selects. See Taxes and fees below.
tax_scopestringOptionalone_fare_per_roomOne of one_fare_per_room, all_fares_all_rooms. Only meaningful with tax_required: true. See Taxes and fees below.
POST /api/v1/scraper/ota-price/ — Expedia
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": "expedia", "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": "expedia", "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 two ways on Expedia — there's no third, id-only route the way there is on the other OTAs, because Expedia needs a hotel and a region to address a property.

By name (hotel_name + city) is the default: resolved fresh through Expedia's own typeahead search on every request, to a hotel-and-region pair.

By Google Place ID (place_id) is the recommended way to pin a portfolio, as on Agoda, Booking.com, MakeMyTrip and Goibibo. The first request for a place still goes through Expedia's typeahead (on your hotel_name and city if you sent them, otherwise on the identity we hold for the Place ID); the hotel and region that actually returned a price are then pinned together, so every later request for that place_id addresses the same property directly and skips the search. A property is pinned only when Expedia names it and its name agrees with the Place ID's own; a pin that Expedia stops listing fails the job as dead_property_id and is retired, so the next request resolves afresh.

ota_hotel_id is rejected on Expedia — not silently ignored. Expedia needs a hotel and region pair to address a property; a single id can't express that, so the request is refused with 400 (an ota_hotel_id error naming the reason) before any job is created, rather than accepting the value and quietly not using it. Send hotel_name + city, or place_id.
Confirming what got priced. Read result.origin_hotel_name — what Expedia itself calls the property it priced (its own property summary name); "" means Expedia named nothing, never that it agreed. result.hotel_name is your own search text echoed back and confirms nothing. result.hotel_id is Expedia's property id, but unlike Agoda, Booking.com and MakeMyTrip you can't feed it back in as ota_hotel_id to skip resolution; use place_id for that. Changed 12 October 2026: origin_hotel_name used to be empty on Expedia, and an id Expedia no longer lists used to complete as sold_out: true; with no rooms and no name it now fails as dead_property_id (property no longer listed on Expedia).

Device pricing — device_type

device_type is argument-driven on Agoda and Booking.com, and accepted (but ignored) on MakeMyTrip and here. The field is still validated — desktop or mobile; app_android (the route Agoda and Booking.com offer) and anything else 400 — and echoed back on the job envelope, but it has no effect on which prices you get.

Expedia is the mobile-app route, always. This source works by asking the exact same GraphQL endpoint the Expedia Android app itself calls — that is the one Expedia surface not gated behind the anti-bot wall a plain web request hits. So every Expedia scrape presents the mobile app's identity (APP_PHONE) regardless of what you send for device_type; there is no separate desktop code path to switch to. If Expedia's website and app happen to price a stay differently, only the app's price — the one shown here — is available through this route.

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[] — the same shape as every other OTA on this endpoint. (device_type is always echoed but carries no meaning on Expedia — see Device pricing; ota_hotel_id is always "" here — a submitted one is rejected outright, see Identifying the hotel; bandwidth_kb is the measured, compressed proxy traffic for the scrape — response headers excluded, and failed scrapes count what they spent.)

200 OK — GET /ota-price/{job_id}/ (result, abridged)
{ "id": "a1b2c3d4-...", "status": "completed", "error_message": "", "result": { "ota": "expedia", "ota_code": 4, "hotel_name": "The Taj Mahal Palace, Mumbai", "origin_hotel_name": "The Taj Mahal Palace, Mumbai", "hotel_id": "19458493", "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": 21800.0, "cheapest_fitting_basis": "per_night_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": "33 m² / 355 ft²", "bed": "1 king bed", "view": "City view", "amenities": ["Free WiFi", "Minibar", "Hair dryer"], "images": ["https://..."], "tags": [], "room_id": "991243155", "room_size_sqm": 33.0, "max_guests": 4, "offers": [ { "price": 21800.0, "currency": "INR", "price_text": "₹ 21,800", "price_basis": "per_night_before_taxes", "price_before_discount": null, "board": "Breakfast included", "free_cancellation": true, "cancellation_text": "Fully refundable before Sun, 13 Dec", "refundable": true, "occupancy": 4, "occupancy_source": "capacity", "fits_occupancy": true, "badges": [], "rate_token": "a1B2c3D4", "supplier_id": null, "rate_channel_id": null, "device_identity": null, "tax_included": false, "tax_status": "excluded", "tax_amount": null, "meal_plan_code": "CP", "meal_plan_code_raw": null, "source_url": null, "source_url_scope": null } ] } ] } }

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[] are Agoda-only channel rollups and always empty here. ota_code is the numeric channel code ota also accepts on submit (see Addressing an OTA by number) — 4 here, present on failed jobs too; added 21 September 2026, before which Expedia had none and this was always null. 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. origin_hotel_name is what Expedia itself calls the property, or "" when it named none — see Identifying the hotel. cheapest_fitting_tax_status is the tax_status of the offer cheapest_fitting_price was read from; cheapest_room is the cheapest fitting rate priced all-in for one room for one night — see Agoda's Response schema for both.
RoomTypename, offers[], sold_out, room_size, room_size_sqm, max_guests, bed, view, description, amenities[], images[], tags[], room_id — max_guests is populated on Expedia (from its "Sleeps N" room chip), unlike Agoda/Booking.com where it's always null; MakeMyTrip is the only other OTA that states it. It is null on an Expedia room that states no "Sleeps N" (some rooms Expedia sells from third-party suppliers carry none), and it can understate: rooms Expedia sells from the Hotelbeds wholesaler read "Sleeps 2" whatever the room holds, so judge fit by each offer's fits_occupancy — see Occupancy from the rate itself. tags[] is always empty (Agoda-only). description is always null — Expedia's mobile API doesn't expose a free-text room description on this route. images[] holds up to 8 photo URLs from the room's own gallery. bed is the OTA's own wording and case; 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 — Expedia has no separate structured view field.
RoomOfferprice, currency, price_text, price_basis, price_before_discount, board, free_cancellation, cancellation_text, refundable, occupancy, occupancy_source ("capacity"|"priced"), 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_basis, 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), rooms_booked, pay_later and payment_text are always null on Expedia, and max_children is null unless occupancy_source is "priced"; those are Agoda- or Booking.com-only. price_before_discount is always null — Expedia exposes no strikethrough/discount pair on this route (Booking.com does; see its guide). occupancy_source is "capacity" on most offers (the room's "Sleeps N") and "priced" on a few (the party the rate itself was priced for, adults in occupancy, children in max_children — see Occupancy from the rate itself); filter on fits_occupancy, never on occupancy_source: "capacity". See Children for how a child party is checked against it. tax_percentage and tax_and_fees are always null on Expedia — see the callout below for tax_amount/fee_amount/tax_basis, and Taxes and fees.
Tax and meal plan are derived here too — link is not, and itemized tax is opt-in. tax_included and meal_plan_code are computed from price_basis and board at the same shared choke point every OTA's result passes through, so Expedia gets them for real, same as Agoda/Booking.com/MakeMyTrip — tax_included restates the basis as a boolean (tax_status says the same as included / excluded / unknown, never null), and meal_plan_code is one of EP/CP/MAP/AP/AI, null only when board carries no meal wording at all. meal_plan_code_raw stays null — Expedia publishes no meal code of its own to preserve. cheapest_fitting_meal_plan_code (added 23 September 2026, at the same shared choke point) is the meal_plan_code of the offer cheapest_fitting_price was read from, null where that offer stated no meal plan or none fit the party. source_url and source_url_scope are always null on Expedia — a deep link needs OTA-specific parsing this source doesn't do; see Agoda for the one OTA where it's populated. tax_amount, fee_amount and tax_basis are null unless you ask — unlike Agoda, Expedia's offers response carries no tax figure at all, so there is no free combined tax_amount here to begin with; send tax_required: true to get the itemized split. See Taxes and fees.
Changed 22 September 2026 — the shared board reader recognises more wording, and "All meals" corrected. Since board (and, through it, meal_plan_code) is computed at the same shared choke point every OTA passes through, two fixes made there apply to Expedia too: it now recognises trade rate-plan names carrying no meal word at all ("European Plan"/"Self catering" → room only, "American Plan" → full board, "Modified American Plan" → half board), and a bare "All meals" now reads as full board rather than all inclusive — it names every meal and says nothing about drinks, the one thing that separates the two. If you stored a meal_plan_code history for Expedia, treat rows read as AI around this date as possibly AP instead.
Price basis on Expedia. price_basis is a per-offer field with values per_night_before_taxes, per_night, total_stay_before_taxes, total_stay or unknown. Expedia's own page shows both a per-night figure and a whole-stay total for every rate; we publish the per-night one as price, so it reads like Agoda's and MakeMyTrip's, not Booking.com's (which quotes the whole stay). Whether that nightly figure contains tax is decided per rate, by holding it against Expedia's own stay total: per_night_before_taxes when the total is higher than price × nights (most rates), per_night when the two are equal and Expedia says taxes are included (the rates it sells tax-inclusive), and unknown when Expedia states nothing that settles it. A rate that carries only a stay total is published as total_stay / total_stay_before_taxes / unknown by the same rule. Always read the field rather than assuming; use the pre-computed cheapest_fitting_price + cheapest_fitting_basis to normalize across OTAs rather than doing it by hand.
Corrected 13 October 2026 — most Expedia rates are before taxes. Until then nearly every rate was published as per_night with tax_included: true, because Expedia's "includes taxes & fees" line was read as describing the nightly price. It describes the stay total: on a measured property, 54 of 69 rates had a total about 18% above price × nights. Those rates are now per_night_before_taxes (tax_included: false, tax_status: "excluded"). price itself did not change — if you stored tax_included or the basis for Expedia, re-read history from before this date.

Taxes and fees

tax_amount is null unless you ask for it — and unlike Agoda, there's no free combined figure to fall back on. Expedia's offers response carries no tax-bearing field at all (every one of mandatoryFees, fees, price.total.amount and the rest comes back empty on this route); the amount only exists at Expedia's own checkout, which this source visits only when you opt in:

Request body — the two fields that opt in
{ "tax_required": true, "tax_scope": "one_fare_per_room" }
tax_scopeWhat gets itemizedRequests
one_fare_per_roomThe cheapest fare of each room type that can seat the party (falling back to the cheapest overall if none fit)Two per room type (~650 KB total, measured)
all_fares_all_roomsEvery priced fare of every room type, uncapped — a narrower answer than asked is never returned silentlyTwo per offer — a ~1 KB checkout-address field (free, folded into the offers query) plus a checkout-page fetch (~540 KB) per rate

Both scopes run four rates at a time to keep wall-clock down without raising the request count — measured on a 20-rate property, 24.9 s against 63 s serially. On a rate that was itemized you get three fields, all read from Expedia's own checkout page rather than computed, and rebased onto price's own per-night/whole-stay period so price + tax_amount + fee_amount is a consistent all-in:

FieldMeaning
tax_amountThe tax alone, on this rate
fee_amountAny extra-occupancy charge beside it, net of its own tax; null when itemization did not run or could not be verified
tax_basistax_and_fees when an extra-occupancy charge came back with the tax, tax_only when it didn't. Unlike Agoda, this is not free — Expedia states no extra-bed count on the offers page itself, so tax_basis is null on every rate you did not itemize.

tax_percentage and tax_and_fees stay null on Expedia either way — its checkout page states amounts, not a rate, and there is no free all-in figure the way Agoda's rate token gives one.

A real, measured example — same property as the Children section, 2 nights:

Taj Mahal Palace — itemized (per-night figures)
Checkout page (whole stay): 2 nights ₹94,000.00 + Taxes ₹16,920.00 = Total ₹110,920.00 Published per-night: price 47,000.00 + tax_amount 8,460.00 = 55,460.00/night
Not every rate resolves, and a throttled checkout page is a different failure than an untaxable rate. EXPEDIA_COLLECT/HOTEL_BEDS rates state "Taxes & fees: included" with no number and cannot be split — that row keeps tax_amount: null, same as before you asked. So does a rate whose checkout page prices something other than the rate we published (the tax is then left unstated rather than attached to the wrong money). Checkout can also answer with a 429 under load; that row is retried on your next job, not this one. Neither failure fails the job — status still completes, and every other rate's split is unaffected. 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".
CaseStatusMessage
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.

Itemizing never costs extra quota: a job is one unit whatever tax_required says; the checkout requests show up in bandwidth_kb. tax_required without a tax_scope is not an error — it defaults to one_fare_per_room, the cheaper of the two. tax_required on an OTA that can't itemize (only Agoda, Expedia and Goibibo do, today) is a 400 — see Agoda's guide for that error's exact wording.

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": "expedia", "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": 47000.0, "price_basis": "per_night_before_taxes", "tax_included": false, "tax_status": "excluded", "tax_amount": 8460.0, "fee_amount": 0.0, "tax_percentage": null, "tax_basis": "tax_only" }

Children

Send ages, not a count: "children_ages": [4, 15]. Expedia's own app schema (rooms[].children: [{"age": n}]) prices each child individually — a 4-year-old and a 15-year-old move the same room by different amounts — so, same as every other OTA on this endpoint, a bare children count is rejected with a 400 telling you to send ages.

Verified live, and priced genuinely — not just accepted. Same hotel/room/dates, 2 adults + 1 room: adults-only ₹47,250, + age 4 ₹58,511, + age 15 ₹52,250, + ages [4, 15] ₹64,276 — the age moves the price, not just a head-count. When a property has nothing that seats the party, Expedia returns an empty room list rather than an adults-only substitute: one property answered 2 adults + ages [4, 15] with zero rooms, and 2 adults + age [4] by dropping one room type while leaving the other's price unchanged (a genuinely free child on that rate).
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": "expedia", "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": [4, 15], "currency": "INR" }'

With more than one room, ages are dealt round-robin into the room list — [4, 7, 9] over 2 rooms becomes [[4, 9], [7]], spreading the party the same way adults already does — and never duplicated: unlike adults (rounded up and repeated per room), copying an age would invent a child that does not exist.

occupancy stays what it always was on Expedia — the room's stated "Sleeps N" capacity, not the specific party priced (see the Response schema table) — but fits_occupancy now checks it as a total guest count: a 3-person party (2 adults + 1 child) qualifies on a Sleeps-4 room and is refused on a Sleeps-2 one. Expedia states no separate child allowance the way Booking.com's max_children does — see Booking.com's Children section for that mechanic — so a child here is simply one more of the "N" guests the room sleeps.

Occupancy from the rate itself

Changed 16 October 2026 — two cases where Expedia's "Sleeps N" can't answer. Expedia's capacity is the room's "Sleeps N" line, and it is not always usable. In both cases below the offer now reads the party the rate itself was priced for: occupancy_source: "priced", the adults in occupancy, the children in max_children, and a fits_occupancy like any other offer. This is Expedia's own statement, not our estimate: the same rate's price moves with the party, and a party Expedia can't seat gets no rates.
  • A room with no "Sleeps N". Some rooms Expedia sells from third-party suppliers arrive with no capacity, bed or size, and appear only on dates when the supplier has stock. Before this date their offers returned occupancy, occupancy_source and fits_occupancy as null, were never counted in cheapest_fitting_price, and a cell where every rate was like this had pricing_status: "unknown". max_guests stays null on such a room, because a priced party is not a capacity.
  • "Sleeps 2" rooms sold to a party with a child. Rooms Expedia sells from the Hotelbeds wholesaler read "Sleeps 2" whatever the room holds, yet Expedia prices and sells them to 2 adults plus a child. Before this date such offers read occupancy: 2, fits_occupancy: false, so a childful search could come back with occupancy_honored: false and cheapest_fitting_price: null although Expedia sells rooms to that party. Now a rate priced for more guests than the room's "Sleeps N" reports the party it was sold for. max_guests stays the room's own "Sleeps N".
Unchanged: every room that states "Sleeps N" and is priced for no more guests than it (every adults-only rate) still reports occupancy_source: "capacity"; a rate with neither a "Sleeps N" nor a booking key still returns null — nothing is guessed; and jobs priced before this date keep the results they were stored with, so price the stay again to see the new fields.

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 Expedia match for name+city)", "resolved hotel has no region id (cannot price)", "dead_property_id (property no longer listed on Expedia)", "resolution unavailable (all exit IPs failed): ...", "proxy quota (407): ...") with result.room_types: [].

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

Expedia-specific notes


Next: Goibibo →