Every short term rental data subscription sells two numbers per listing and month: how full it is and what it charges. Both come from the public availability calendar on the listing page and a priced stay, and both can be read per listing for a fraction of a cent. This is an Airbnb occupancy rate per listing and month as rows you own, with the caveat the vendors keep in a footnote said up front: the calendar cannot tell a booked night from one the host blocked.
1. One row per listing and month
The Airbnb Occupancy Rate Scraper on Apify takes a place, a search URL or listing ids, reads each listing's calendar for the next months months and folds each into one occupancy row — an airbnb calendar scraper with the arithmetic done.
curl -X POST "https://api.apify.com/v2/acts/kestrel~airbnb-occupancy-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"locationQueries": ["Lisbon, Portugal"], "maxListingsPerQuery": 50, "months": 3, "currency": "USD"}'
One of at most 150 rows:
{ "type": "occupancy", "id": "17088279", "url": "https://www.airbnb.com/rooms/17088279",
"name": "Bairro Alto Refuge", "title": "Apartment in Lisbon", "property_type": "Apartment", "room_type": "Entire home/apt",
"location": "Lisbon", "lat": 38.71209, "lng": -9.14346, "rating": 4.91, "reviews_count": 374,
"bedrooms": 1, "beds": 1, "bathrooms": 1, "person_capacity": 4, "is_superhost": false, "is_guest_favorite": true, "query": "Lisbon, Portugal",
"month": "2026-09", "days_in_month": 30, "days_total": 30, "days_available": 12, "days_unavailable": 18, "occupancy_pct": 60.0,
"checkin_days": 9, "nights_min": 3, "next_available": "2026-09-08", "first_day": "2026-09-01", "last_day": "2026-09-30",
"currency": "USD", "rate_status": "ok", "rate_check_in": "2026-09-08", "rate_check_out": "2026-09-11", "rate_nights": 3, "rate_total": 791, "rate_nightly": 263.67,
"fetched_at": "2026-08-29T08:40:12+00:00" }
occupancy_pct is days_unavailable ÷ days_total × 100; days_total counts from today, so the current month is partial. nights_min is the most common minimum stay; checkin_days and next_available show whether the free days are bookable — a listing 40% available in one-night gaps is full for a three-night minimum.
2. What the number is, and is not
A night is unavailable because a guest booked it or because the host blocked it, and the calendar does not say which. occupancy_pct counts both: an occupancy proxy, not a booking ledger, and for one listing whose host blocks whole months it overstates. Across a comp set of forty similar listings the blocked share is small and fairly constant, so the proxy tracks real STR occupancy closely; that is why days_unavailable sits next to the percentage on every row.
The rate is a sample. The calendar shows no prices to a logged-out visitor, so the actor prices a real stay: the first available check-in day of the month, the listing's minimum nights (capped at seven), for adults guests. rate_nightly is that total divided by nights — an ADR proxy sampled at the start of the month, with rate_check_in, rate_check_out and rate_total on the row so the quote can be reproduced. rate_status explains a missing number: no_stay for a full month, unavailable when adults exceeds capacity, skipped when includeRates is off (half the requests).
3. Python: market pace by month, comps by shape
import csv, os
from collections import defaultdict
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("kestrel/airbnb-occupancy-scraper").call(run_input={
"locationQueries": ["Alfama, Lisbon"], "maxListingsPerQuery": 60, "months": 4, "currency": "EUR"})
rows = [r for r in client.dataset(run["defaultDatasetId"]).iterate_items() if r["type"] == "occupancy"]
with open("occupancy_history.csv", "a", newline="") as f:
csv.writer(f).writerows([r["fetched_at"][:10], r["id"], r["month"], r["room_type"], r["bedrooms"], r["days_available"], r["occupancy_pct"], r["rate_nightly"], r["rate_status"]] for r in rows)
comps = [r for r in rows if r["room_type"] == "Entire home/apt" and r["bedrooms"] == 1]
by_month = defaultdict(list)
for r in comps: by_month[r["month"]].append(r)
for m, rs in sorted(by_month.items()):
priced = sorted(r["rate_nightly"] for r in rs if r["rate_status"] == "ok")
print(m, f"{sum(r['occupancy_pct'] for r in rs) / len(rs):.1f}% occupied, {len(rs)} one-bed entire homes, median {priced[len(priced) // 2] if priced else None} EUR")
Run it every Monday and the CSV holds days_available per listing, month and run; nights that went from available to unavailable between runs are the booking pace, which the calendar does tell you. A monthly run over a season is the occupancy history for the market, the part the vendors charge for.
4. A sheet and a threshold (n8n)
A portfolio watch is three nodes: a weekly Schedule trigger, an HTTP Request to the run-sync endpoint with listingIds for your portfolio and its comp set and months: 3, and a Google Sheets append. The templates in the n8n/ folder of kestrel-actors-examples share that shape, minus the dedupe Code node: every row here is new by fetched_at. For an alert, set minOccupancyPct: 80: only months at or above 80% are delivered, the filter runs before billing, and an empty array means nothing that full this week.
For an exact stay's price or a day-by-day calendar, the sibling Airbnb Scraper bills per listing and calendar month; this one is the cheaper shape for occupancy across many listings.
5. Cost and limits
- $0.002 per delivered
occupancyrow, one per listing and month. 100 listings, 6 months: $1.20. Sixty listings, 3 months, weekly: $0.36 a run, about $1.56 a month.listingandstatusrows, months underminOccupancyPct, unknown places and failed calendars are free. - As an AirDNA alternative it is not a dashboard and has no history you did not run; it is the calendar layer a spreadsheet or model sits on.
- A search returns the site's ranking up to about 280 listings; cover a city with neighbourhood queries or a search URL with map bounds.
monthsis capped at twelve. - One rate sample per month, priced for
adultsguests; weekend premiums and mid-month prices differ. - The rows describe properties, not people. The site's terms discourage automated access; that is a terms question, with the risk on whoever runs it. A compiled database can attract database rights in the EU, so do not resell the raw dataset. Not legal advice.
That is the feed: one call for the rows, a comp filter so the proxy means something, a schedule for the history. Full input/output reference and FAQ on the actor page: apify.com/kestrel/airbnb-occupancy-scraper.
Every actor's inputs, output fields, a sample row and its honest limits are documented at mtedj.github.io/kestrel-actors-examples, including a side-by-side comparison of every review scraper — what each source really carries and where its ceiling is.
Top comments (0)