Quick answer
Airbnb's homes-search page embeds a JSON payload with everything the front end needs to render a results grid — price, rating, host, superhost badge, bedroom count — but not everything a comps dataset wants. Amenities are usually absent from that payload entirely; they live on the listing's own detail page. Building an Airbnb scraper around that reality means returning null for a field the search payload didn't carry, instead of guessing, and pinning the residential exit IP to the market's own country so a $180 nightly rate doesn't quietly become a $180 rate in the wrong currency.
What's actually in Airbnb's search payload
Airbnb doesn't publish a documented API for this data, so the only source of truth is the page itself, as of September 2026. Each search-results page for a given place and date range ships a large embedded JSON blob that server-renders the grid — no separate XHR call needed to get the base fields. Per listing, that blob reliably carries: listing ID, title, room/listing type, coordinates, price and currency as displayed, rating, review count, host name and ID, superhost status, bedroom/bed/bathroom counts, max guest capacity, and a set of photo URLs.
What it does not reliably carry: amenities. The search grid doesn't need to render a full amenity list to sell you on a card, so Airbnb doesn't ship one in the search payload. That data lives on the listing's own /rooms/<id> page, in a different section of embedded JSON entirely, structured around amenity categories rather than a flat list.
Why we return null instead of inventing a field
The tempting shortcut is to infer amenities from the listing title or type — "Entire home" listings statistically have a kitchen more often than a private room does — and ship a best-guess field. We don't do that. A null amenities field tells the caller exactly what happened: the search payload didn't carry it, and enrichment wasn't requested. A guessed value that's wrong 15% of the time is worse than an honest gap, because nothing downstream flags it as uncertain.
So the Actor ships enableDetailEnrichment as an explicit opt-in. Off, amenities is always an empty list and the run only touches the search-results pages — one request per page of results. On, the Actor makes a second pass and fetches each emitted listing's own detail page for its amenity section, which roughly doubles the total request volume for that run. The customer decides whether that trade-off is worth it for their use case; we don't decide it for them by silently doing the expensive thing on every run, and we don't decide it by silently doing the cheap-but-wrong thing either.
# actors/airbnb-scraper/src/models.py (shape, not full source)
class ResultRow(BaseModel):
price_per_night: float | None = None
currency: str | None = None
amenities: list[str] = Field(default_factory=list)
# amenities stays [] unless enrichment ran — never inferred
The same discipline applies to total_price: it only populates when both checkIn and checkOut are supplied, because that's the only case where Airbnb's own page computes a stay total. Without both dates, you get the base nightly rate and nothing else — not a multiplied guess.
Pinning the proxy country to the market's currency
Airbnb resolves the currency it displays partly from the requesting IP's apparent geography. A residential exit that lands in the wrong country doesn't error — it returns a normal-looking 200 with prices in that country's currency, and the response gives no indication anything drifted. A run built around "San Francisco, CA" that happened to route through a residential exit in the UK would come back with a fully-formed dataset of listings priced in GBP, every field populated, every row internally consistent — and wrong for the customer's use case.
The fix is the same shape we use across the fleet for this failure class: never let the exit country be random when the target's response depends on it. proxyCountry is a required, non-randomized field wired straight into the proxyConfiguration — apifyProxyCountry is set explicitly per run, not left to whatever the residential pool happens to hand back. If a customer is comping a market in Spain, the proxy exits from Spain for every request in that run, and currency on every row reflects it. We don't attempt currency conversion on top of that — price_per_night and currency are passed through exactly as Airbnb's page reports them, because converting introduces an exchange-rate assumption that's stale the moment it's written.
Retrying without losing the run
Airbnb's search surface sits behind anti-bot defenses rather than a documented, exhaustible rate limit — a blocked request is a session to drop, not a quota to wait out. On 403/429/5xx, the Actor rotates to a fresh residential session and browser-fingerprint impersonation profile and retries with capped exponential backoff. A run blocked partway through surfaces as completed with a status message describing what landed, not a hard failure that discards results the customer already paid the per-event price for.
The part that generalizes
Any page whose response depends on signals it doesn't expose — currency from IP geography, amenities on a second page only, a total only when both dates are given — will hand back a plausible answer to the wrong question if you don't pin the input and check what's actually present. Pin what the response depends on, and leave a field null rather than inferring it when the source genuinely didn't say.
Pricing
$0.20 per run, $0.0029 per listing row — about $3.10 per 1,000 listings, amenities included when enableDetailEnrichment is on. A search that matches nothing costs only the start fee.
Run the Airbnb Listings & Pricing Scraper on Apify — free trial credit, no card required.
Built by Devil Scrapes. We pin what the target's response depends on and leave the rest honestly blank. 😈
Top comments (0)