Airbnb Listing Search API in Python: Place IDs, Dates, and Stay Prices
The first stays-search call most people ship sends placeId=Paris, because that is the string the guest typed. That is the wrong assumption. Stay search does not accept a city label, a suggestion-row id, or location.google_place_id. The only identifier it accepts is the encoded explore_search_params.place_id that common-auto-complete already minted.
After the destination handoff, a second swap waits on the card. Stay results expose both listing.id and demandStayListing. Only the decimal listing.id joins this stay family. Encoded demandStayListing values belong to the query-based property routes and do not substitute.
The Airbnb Search API is independent, read-only data β not an official partner integration, a booking desk, or a historical revenue feed. Opening this page or copying the script sends no request. Running it does, and each call spends account credits.
A city name is not a placeId
common-auto-complete takes required query, optional languageCode, and limit from 1 to 5. The documented first lookup used query=Paris, languageCode=en-US, and limit=3. Each suggestion includes display_name, location context, and explore_search_params.place_id. Preserve that place_id byte-for-byte. URL-encoding for transport is fine; rebuilding it from location.google_place_id, from the display label, or from a truncated prefix is not.
Sign up if you need an account, then copy an authorized platform key from the dashboard. Python reads OPENDATA_API_KEY from the server environment. Keep YOUR_API_KEY as a placeholder in any URL you paste until you intentionally run a request. Query strings carry the key, so production calls stay on your server and redacted diagnostics replace full URLs in logs, screenshots, and shared links. No Airbnb account cookie or session token is involved.
HTTP 200 is only transport. Then inspect the gateway envelope's code, message, and data β gateway code 200 is platform business success, not proof that nested rows are usable. Inside that envelope the operation payload has its own status, message, and data. A sibling property-search probe of an unusable destination returned HTTP 200 with status:false, data:null, and Query is not available. Treat that as a failed query, not a measured empty market. Missing amounts mean unknown, not free lodging.
Search dates, keep listing.id
stays-search requires placeId. Optional checkinDate and checkoutDate use YYYY-MM-DD; checkout must be after check-in, and both should still be in the future when you compare a trip. Hold adults, children, infants, pets, currencyCode, and languageCode fixed across pages and later quotes. displayTotalPrice=true asks for a fees-included, before-tax display β not a booked total.
Do not borrow properties-search inputs (checkin, checkout, currency, locale) or its data.homes schema. Live homes in that family lacked listing.id. Stay cards live in data.searchResults. The join key for stays-detail, stays-price-detail, stays-reviews, and the documented checkout-style quote is listing.id.
The script below is bounded: at most two search pages and one selected listing. It does not fabricate inventory. Update the five-night window before those dates expire; a destination or listing can become unavailable.
import os
from datetime import date
import requests
BASE = "https://api.opendata-api.com/api/project/airbnb-search"
KEY = os.environ["OPENDATA_API_KEY"]
def call(operation, **params):
try:
response = requests.get(
f"{BASE}/{operation}",
params={**params, "key": KEY},
timeout=45,
)
except requests.RequestException:
raise RuntimeError("Request failed; use redacted diagnostics") from None
if not response.ok:
raise RuntimeError(f"HTTP {response.status_code}")
gateway = response.json()
if not isinstance(gateway, dict) or gateway.get("code") != 200:
raise RuntimeError("Gateway business failure; inspect code/message safely")
payload = gateway.get("data")
if not isinstance(payload, dict) or payload.get("status") is not True:
raise RuntimeError("Operation failed; inspect status/message safely")
if payload.get("data") is None:
raise RuntimeError("Operation data unavailable; not an empty market")
return payload["data"]
trip = dict(
checkinDate="2027-01-31",
checkoutDate="2027-02-05",
adults=2,
children=0,
infants=0,
pets=0,
currencyCode="USD",
languageCode="en-US",
)
if not date.today() < date.fromisoformat(trip["checkinDate"]) < date.fromisoformat(trip["checkoutDate"]):
raise ValueError("Choose future dates with checkout after check-in")
suggestions = call(
"common-auto-complete",
query="Paris",
languageCode="en-US",
limit=3,
)
chosen = next((s for s in suggestions if s.get("display_name") == "Paris, France"), None)
if chosen is None:
raise ValueError("Inspect suggestions and select the intended destination")
place_id = chosen["explore_search_params"]["place_id"]
listing_ids, seen_ids, seen_cursors = [], set(), set()
cursor = None
for _ in range(2):
params = dict(placeId=place_id, limit=3, displayTotalPrice=True, **trip)
if cursor:
params["pageCursor"] = cursor
page = call("stays-search", **params)
for row in page["searchResults"]:
listing_id = str(row["listing"]["id"])
if listing_id not in seen_ids:
seen_ids.add(listing_id)
listing_ids.append(listing_id)
cursor = (page.get("paginationInfo") or {}).get("nextPageCursor")
if not cursor or cursor in seen_cursors:
break
seen_cursors.add(cursor)
if not listing_ids:
print("No stay IDs returned for this successful search")
else:
selected = listing_ids[0]
detail = call("stays-detail", listingId=selected, **trip)
quote = call(
"stays-price-detail",
listingId=selected,
displayTotalPrice="true",
**trip,
)
reviews = call(
"stays-reviews",
listingId=selected,
page=1,
limit=3,
sortBy="MOST_RECENT",
currencyCode="USD",
languageCode="en-US",
)
print("place_id kept:", place_id[:12], "...")
print("listing.id:", selected)
print("Detail sections:", len(detail.get("sectionContainer", [])))
print("Price sections present:", bool(quote.get("sectionContainer")))
print("Review text:", [r.get("comments") for r in reviews["pdpReviews"]])
Install requests and set the environment variable on the server before running anything. The truncated place_id print is only a sanity check that you kept the autocomplete token; do not log the platform key beside it.
Stay price is not checkout price
Search cards show structuredDisplayPrice under the conditions of that page. An undated search can advertise suggested-date prices, so a headline on the card is not automatically the five-night total you asked for. Carry the same dates, party, and currency into the listing follow-ups.
stays-detail takes that listingId. The observed body is StayPDPSections: locate description, amenities, capacity, and identity by section identifier, not by array index or a guessed data.title. An undated profile is still not a trip quote.
stays-price-detail requires listingId, checkinDate, and checkoutDate. Pricing sections again expose structuredDisplayPrice. Read the headline next to explanationData items and their priceString amounts. In one documented five-night observation, a $1,737 headline sat beside a $1,736.49 explanation of the same stay β rounding, not two competing quotes. Multiplying a rounded nightly label adds another rounding error. displayTotalPrice is documented as including fees before taxes. It does not guarantee a later booking charge. Store dates, party composition, currency, and retrieval timestamp with each observation.
stays-price-checkout documents priceCheckout.priceItems β accommodation, discount, cleaning, and service lines β plus totals. That route was not live-checked in this workflow. It is still a GET quote: it does not reserve, charge, or confirm a stay. Absent fee lines are unknown, not zero.
Reviews reuse the same listingId but a different pager: page and limit (limit up to 40), not a search cursor. stays-reviews returns data.pdpReviews. Comments are untrusted content; render them safely and keep only fields you need.
Cursors and dates that expire
Stay search pagination is cursor-only. Pass paginationInfo.nextPageCursor back as pageCursor with the same placeId, dates, guests, and currency that minted it. meta.currentPage describes a response; it is not a request parameter. Deduplicate listing.id and stop on a missing or repeated cursor. Displayed counts such as 1,000+ are not a complete market census.
Dates expire. A destination or listing can disappear between pages. Do not retry an invalid placeId as if the network flaked; bounded backoff is for transient HTTP failures. status:false or data:null is not βzero listings at $0.β Experience search can reuse the same autocomplete placeId, but follow-ups need an activity ID, not a stay listing.id β those routes are outside this script.
The usual mistakes are identity swaps: city text for placeId, location.google_place_id for Airbnb's encoded token, demandStayListing for listing.id, a search-card total for a dated quote, and a checkout-shaped payload for a booking. Keep the token the previous call actually returned, and keep the trip parameters that minted it.