How to scrape Airbnb prices and estimate occupancy
Airbnb prices and Airbnb occupancy come from two different reads: a priced search for a specific stay, and the listing calendar. The prices are exact. The occupancy is an estimate, and any tool that tells you otherwise is selling you a guess with a decimal point on it.
The short version
- Search a market or name listings. A location query returns the site's ranking up to about 280 listings; a listing id or URL prices one property exactly.
- Give the stay, not just the dates. Guests, currency and length of stay change the number. A three-night stay for two is a different price from the same nights for four.
- Read the price breakdown, not just the total. The breakdown carries the discount lines, so the nightly rate before and after a promotion are both recoverable.
- Treat calendar occupancy as a proxy. The calendar shows unavailable nights. It does not say whether they are booked or blocked by the host, and no public data source can.
Two different questions, two different reads
Price is a search for a specific stay: check-in, check-out, guests, currency. Airbnb returns what a logged-out visitor would pay, including the discounts currently applied.
Occupancy is the listing's calendar: which nights are unavailable over the next twelve months.
They are separate because they answer separate questions, and because conflating them is where every "Airbnb data" product goes wrong.
Prices for exact dates
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("kestrel/airbnb-prices-calendar").call(run_input={
"locationQueries": ["Lisbon, Portugal"],
"checkIn": "30 days", "checkOut": "33 days",
"adults": 2, "currency": "EUR",
"maxListingsPerQuery": 80,
"calendarMonths": 3,
})
rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
listings = [r for r in rows if r["type"] == "listing"]
cal = {r["id"]: r for r in rows if r["type"] == "calendar"}
for l in sorted(listings, key=lambda r: r["price_nightly_effective"] or 0)[:10]:
print(l["name"], l["price_nightly_effective"], "| 90-day occupancy",
cal.get(l["id"], {}).get("occupancy_90"))
The row keeps the arithmetic honest:
{ "type": "listing", "id": "1018680572065141154", "name": "Lisboa Bica Nature Fast Net AC Heating",
"check_in": "2026-10-03", "check_out": "2026-10-06", "nights": 3, "adults": 2, "currency": "USD",
"price_total": 426.0, "price_original": 516.0,
"price_nightly": 171.73, "price_nightly_effective": 142.0,
"price_breakdown": [
{ "label": "3 nights x $171.73", "amount": 515.19 },
{ "label": "Early booking discount", "amount": -89.73 },
{ "label": "Price after discount", "amount": 425.46 }],
"rating": 4.88, "reviews_count": 156, "bedrooms": 1,
"badges": ["Guest favorite"], "is_guest_favorite": true }
Two nightly numbers, deliberately. price_nightly is the advertised rate; price_nightly_effective is what the guest actually pays per night after the discount. A market study built on the first one overstates the market by whatever the promotions are running at — in this row, by 21%.
Occupancy, and the caveat that defines it
The calendar tells you a night is unavailable. It does not tell you why. A booked night and a night the host blocked look identical, and there is no public signal that separates them.
So every occupancy number derived from a public Airbnb calendar — from any tool, at any price — is an upper bound. A host who blocks January for renovations reads as 100% occupied. A professional manager who keeps a two-night gap between stays reads as more occupied than one who does not.
That does not make it useless. It makes it comparative:
- Across listings in the same market and month, the bias is broadly similar, so ranking listings by occupancy is meaningful even when the level is not.
- Over time on the same listing, the change is more informative than the level.
- Next to the nightly rate, it is a pacing signal: a listing at 90% occupancy at a high rate is under-priced; one at 30% at a low rate has a different problem.
run = client.actor("kestrel/airbnb-occupancy-scraper").call(run_input={
"locationQueries": ["Lisbon, Portugal"], "maxListingsPerQuery": 50,
"months": 3, "currency": "EUR",
})
rows = [r for r in client.dataset(run["defaultDatasetId"]).iterate_items() if r["type"] == "occupancy"]
by_month = {}
for r in rows:
by_month.setdefault(r["month"], []).append(r["occupancy_pct"])
for month, pcts in sorted(by_month.items()):
print(month, round(sum(pcts) / len(pcts), 1), "% occupied across", len(pcts), "listings")
{ "type": "occupancy", "id": "17088279", "name": "Bairro Alto Refuge",
"property_type": "Apartment", "room_type": "Entire home/apt", "location": "Lisbon",
"rating": 4.91, "reviews_count": 374, "bedrooms": 1, "person_capacity": 4,
"month": "2026-09", "days_in_month": 30, "days_available": 12, "days_unavailable": 18,
"occupancy_pct": 60.0, "checkin_days": 9, "nights_min": 3, "next_available": "2026-09-08",
"currency": "USD", "rate_check_in": "2026-09-08", "rate_check_out": "2026-09-11",
"rate_nights": 3, "rate_total": 791, "rate_nightly": 263.67 }
Notice that days_unavailable: 18 sits right next to occupancy_pct: 60.0. That is deliberate: the count is the fact, the percentage is the interpretation.
The rate on that row is a sample, not an average — one stay per month, the first bookable one at the listing's own minimum length, capped at seven nights, for the guest count you asked for. The sampled dates are on the row so you can see exactly what was priced.
What this replaces, and what it does not
Short-let analytics platforms model true occupancy and revenue by combining calendars with review velocity, historical bookings and their own panels. If you need a modelled revenue estimate with a confidence interval, buy one of those.
What you get here instead is the raw, current, verifiable inputs — every listing in the market with its price, its calendar, its rating and its capacity, on the day you ran it, in a dataset you own and can join to anything else. For comp sets, pricing decisions, pace tracking and market entry studies, that is usually the better shape, and it costs $0.002 per listing-month.
The honest limitations
- Search caps at roughly 280 listings per query. Cover a city with neighbourhood queries, price bands or map bounds.
- A calendar covers twelve months from the current month.
- Prices are what a logged-out visitor sees in your currency. Some markets show all-in prices, others add fees at checkout — the breakdown says which.
- Delisted or snoozed listings return a status row with an error and no charge, rather than disappearing silently from a market count.
- The site changes its internal query hashes occasionally. The honest response is a daily canary run and status rows, not a promise that it never happens.
Run it
Airbnb Prices & Calendar — $0.005 per listing, $0.002 per calendar month, $0.003 per review. Airbnb Occupancy — $0.002 per listing-month with the rate sample included. For guest reviews of the same listings, Airbnb Reviews. Related: tracking competitor prices daily with n8n.
FAQ
Does Airbnb have an API?
Not a public one for this. Airbnb's API is for partners and property-management integrations under an agreement. Everything here reads what a logged-out visitor sees.
How accurate is scraped Airbnb occupancy?
It is an upper bound on true occupancy, because blocked nights and booked nights look identical in the calendar. A host who blocks January for renovations shows as 100% occupied. Used as a comparative measure across similar listings in the same market and month it is genuinely useful; used as a revenue figure for one listing it is wrong in an unknown direction.
What is the ADR figure based on?
One sampled stay per month: the first bookable stay at the listing's own minimum length, capped at seven nights, for the guest count you asked for. It is a sample, not an average — a month with weekend premiums or a mid-month stay can differ, which is why the sampled check-in and check-out dates are on the row.
How do I cover a whole city if search caps at 280 listings?
Split the market: run neighbourhood queries, price bands, or paste search URLs carrying map bounds. Several narrow queries beat one broad one, and duplicates across them are delivered and billed once.
Other actors used on this page: apify.com/kestrel/airbnb-occupancy-scraper, apify.com/kestrel/airbnb-reviews-scraper.