kestrel actors docs

kestrel actorsGuides

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

  1. 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.
  2. 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.
  3. 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.
  4. 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:

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

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.

Run Airbnb Scraper on Apify →

Other actors used on this page: apify.com/kestrel/airbnb-occupancy-scraper, apify.com/kestrel/airbnb-reviews-scraper.

More guides