# Airbnb Calendar & Date-Specific Nightly Price (`mavilinklabs/airbnb-calendar-price`) Actor

Per-date Airbnb availability plus the real bookable nightly price for those dates, in the currency you request (USD/EUR/GBP/TRY), with per-night breakdown and an explicit capacity flag. Airbnb's calendar returns price:null; this fetches the actual quote. Pay only for delivered data.

- **URL**: https://apify.com/mavilinklabs/airbnb-calendar-price.md
- **Developed by:** [MaviLinkLabs](https://apify.com/mavilinklabs) (community)
- **Categories:** Real estate, Travel
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $8.00 / 1,000 price quotes

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Airbnb Calendar & Real Date-Specific Price Scraper

Get **per-date availability** for one or many Airbnb listings **and** the **real bookable nightly price** for those dates — for an **exact date range** or **months ahead**, in **the currency you request**.

Airbnb's calendar tells you *whether* a date is free, but its calendar response returns `price: null`. The real price for a specific date comes from a separate booking quote. This actor fetches that quote and returns it as a structured, honest record.

### What it does

- **Per-date availability calendar** — up to 12 months, with `available`, check-in / check-out eligibility, and each date's own `minNights` / `maxNights`.
- **Real, date-specific nightly price** — for available windows it asks Airbnb's own booking engine for the price quoted for those exact dates, rather than the `null` the calendar returns. What you get is the total Airbnb displays for that window — **which may or may not include tax, depending on the response** (see *Honest limits*; every quote reports which via `taxScope`) — not a final checkout total.
- **Exact date range OR months ahead** — set `startDate` + `endDate` for a specific window, or use `calendarMonths` to look a number of months into the future.
- **One stay in, one quote out** — if you ask for one specific stay (`startDate` + `endDate`), you get **exactly one quote for exactly that stay**, and you are charged for exactly that one quote. The actor does not add sampled side-quotes you did not ask for. See *Asking for one stay vs. asking for a price curve* below.
- **Two pricing modes:**
  - `sampled` (default) — a handful of quotes spread across months (one per month first), giving a low-cost price curve across the horizon.
  - `allAvailableDates` — price *every* eligible check-in date; requires `maxPricedDates` as an explicit cost budget.
- **Controlled cost, no silent truncation** — in `allAvailableDates` mode the budget is enforced by `onCostLimitExceeded`: `error` (default: price nothing for that listing, 0 quote charge, `status: "cost-limit-exceeded"`) or `truncate` (price the first N chronologically and flag exactly how many dates were omitted).
- **Requested currency** — every price is returned in the ISO currency you set (`USD`, `EUR`, `GBP`, `TRY`, ...), not whatever the listing happens to use.
- **Multiple listings per run** — pass a list of room URLs or IDs.
- **Min-stay aware quotes** — each quote window respects that date's own minimum-nights rule (or your fixed `quoteNights`), so quotes don't fail on min-stay mismatches.
- **Explicit guest-capacity handling** — you set `adults`; the actor reads the listing's `maxGuests` and reports `requestedAdults`, `effectiveAdults`, and `adultsReducedToFitCapacity`. If your guest count exceeds capacity, it re-quotes at the fitting count instead of returning no price — and flags that it did.
- **Structured price breakdown** — `nightlyBase`, `nights`, `accommodationSubtotal`, `subtotal`, `fees[]`, `discount`, `taxes`, `totalPrice`, and `currency`, each **read from Airbnb's own price-detail lines** instead of being derived by dividing a rounded total. `accommodationSubtotal` is the accommodation line on its own; `subtotal` is the **pre-tax total**; `totalPrice` is what the lines add up to. Three different numbers, three separate fields — no field is ever used with two meanings.
- **Cent-accurate total** — Airbnb's headline price is rounded **up** for display (a $305.75 stay shows as "$306"). `priceRaw` / `priceTotal` keep that displayed value, while `priceBreakdown.totalPrice` — and the `perNight` average derived from it — carry the exact figures from the price-detail lines.
- **Long stays are priced as long stays** — when Airbnb quotes by the month or week instead of by the night, that line is read as the accommodation (never as a fee) and the period is named. If the window you asked for is **not** one such period (a 46-night stay priced monthly), the actor reports the monthly rate and **withholds** the window totals instead of passing a monthly figure off as a 46-night one. See *Long stays* below.
- **The tax scope is reported, not assumed** — Airbnb's response for the same listing and window is not always the same: sometimes it itemizes a `Taxes` line, sometimes it bundles one `Taxes and fees` line, sometimes it declares the total is *before taxes*. Every quote says which one it got via `taxScope` and `totalIncludesTaxes`, so a total is never silently mixed up with a pre-tax one.
- **`cancellationPolicy` and `canInstantBook`** — read from the live listing response, when a quote request is made (see limits below).
- **Pay only for successfully delivered events** — calendars and quotes are charged only when real data was delivered.

### Asking for one stay vs. asking for a price curve

These are two different questions, and the actor bills them differently.

**One stay** — you give `startDate` + `endDate` and want the price of *that* stay:

```json
{ "listings": ["..."], "startDate": "2026-09-01", "endDate": "2026-09-05" }
```

You get **one quote** for `2026-09-01 → 2026-09-05` and are charged for **one** price-quote event. The actor treats this as an *explicit stay* when all of the following hold:

- both `startDate` and `endDate` are set, **and**
- `pricingMode` is `sampled` (the default), **and**
- `quoteNights` equals the number of nights in the range **or** is `0` (the default, "use the date's own minimum-nights rule").

In that case no sampled calendar windows are quoted at all — only the stay you asked for.

**A price curve** — you want many dates priced, not one stay. Use one of:

- `pricingMode: "allAvailableDates"` with `maxPricedDates` — prices *every* eligible check-in date in the window. This is never treated as an explicit stay, in or out of a date range.
- `calendarMonths` without `startDate`/`endDate` — sampled quotes spread across the months ahead (one per month first), up to `maxQuotesPerListing`.
- `startDate`/`endDate` **plus** a `quoteNights` that differs from the range length — e.g. a 30-day window with `quoteNights: 3` asks "what does a 3-night stay cost across this window", so sampling stays on and you additionally get the whole-window quote.

> **Known consequence — read this if you use a wide range.** `sampled` + a wide `startDate`/`endDate` + `quoteNights: 0` is an explicit stay, so a 90-day range returns **one quote for a single 90-night stay**, not four sampled windows inside it. No calendar sampling happens at all on that path — the single exact quote is the entire price output for the listing.
>
> **And if Airbnb will not price that long stay, you get zero quotes for the listing.** Very long stays are frequently above a listing's `maxNights`, or simply not offered, and the actor never invents a price it did not receive: the run then delivers the availability calendar and **no** price quotes (you are charged for the calendar, and for **zero** price-quote events). The run log names the window and the reason. If you wanted prices across the span rather than one long stay, use `pricingMode: "allAvailableDates"` with `maxPricedDates`, or `calendarMonths` without a range, or set a `quoteNights` that differs from the span.
>
> This is documented here as current behavior, not defended as the right default. **Open product decision for the next release:** whether a wide range + `quoteNights: 0` should keep meaning "one long stay" or should fall back to a sampled price curve when the long stay is unpriceable. Behavior is unchanged in this version.

**`maxQuotesPerListing` is a hard cap.** In `sampled` mode, every delivered and charged quote — *including* the direct quote for your exact window — counts against it. `maxQuotesPerListing: 4` with a date range delivers at most 4 quotes, never 5. (In `allAvailableDates` mode the budget is `maxPricedDates`; `maxQuotesPerListing` does not apply there.)

### Honest limits (please read)

- **Live quote, not a fixed price.** Prices are the live quote at scrape time. Airbnb prices are dynamic and change through the day — re-run for a fresh quote. This actor does not claim a permanent or forever-exact price.
- **Taxes: whatever Airbnb prints, plus a label for what it means.** The actor never calls the checkout endpoint and never estimates a tax. It reports the tax lines that are in the response, and `taxScope` states which situation produced the numbers:

  | `taxScope` | meaning | `taxes` | `taxesAndFeesCombined` | `totalIncludesTaxes` |
  |---|---|---|---|---|
  | `taxes-itemized` | a separate `Taxes` line was in the response | amount | `null` | `true` or `false` |
  | `taxes-and-fees-combined` | Airbnb printed **one** `Taxes and fees` line | `null` | amount | `true` or `false` |
  | `taxes-itemized-and-combined` | both shapes arrived in the same response | amount | amount | `true` or `false` |
  | `ambiguous` | a tax line exists but the lines do not reconcile with the total | as read | as read | `null` |
  | `before-taxes-declared` | no tax line; Airbnb's own total says *"before taxes"* | `null` | `null` | `false` |
  | `unclassified-lines` | no tax line was recognized, but the response carries extra line(s) that were passed through unclassified in `fees[]` (e.g. `VAT`, `Sales tax`, `Resort fee`) | `null` | `null` | `null` |
  | `no-tax-line` | no tax line, no declaration, and **no extra line of any kind** | `null` | `null` | `null` |
  | `not-classified-locale` | non-English `locale`: labels not classified at all | `null` | `null` | `null` |
  | `no-price-details` | the response carried no price-detail lines; the whole breakdown is `null` | `null` | `null` | `null` |

  `totalIncludesTaxes` is decided **arithmetically** whenever a tax line is present: `true` if the total equals the sum of all lines, `false` if it equals the sum without the tax terms, and `null` (with `taxScope: "ambiguous"`) if neither matches. It is never inferred from wording alone.

  `taxesDeferred` is `true` exactly when **no tax figure of any kind was isolated** (`taxes` and `taxesAndFeesCombined` are both `null`). It does not by itself mean Airbnb charges no tax — read `taxScope` for that: `no-tax-line` is the only value that says the response really had nothing extra, while `unclassified-lines` says extra lines were there and we refused to guess what they were.

  **A bundled `Taxes and fees` line is not split** — guessing the tax share of it would be inventing a number, so it is reported as its own field and never as `taxes`.
- **Whether a total includes tax depends on the response, not on us.** The same listing and window can return a tax-inclusive total on one request and a *before taxes* total on another; we have observed both and do not claim to know why. Read `totalIncludesTaxes` on the quote rather than assuming.
- **`priceBreakdown.fees[]` is a passthrough, not a classification.** Fee lines are taken from Airbnb's own price-detail line items as `{label, amount}` with the label exactly as Airbnb localized it (e.g. `"Resort fee"`). The actor does not decide what a fee *means*, and it never estimates a fee that Airbnb did not print. A recognized tax line is **never** put in `fees[]`. The one exception is a non-English `locale`, where labels are not classified at all: then every additive line stays in `fees[]` unclassified and `taxScope` is `not-classified-locale`.
- **Unclassifiable prices stay `null`.** If a response does not carry the price-detail lines (or their stay length disagrees with the window that was requested), the breakdown fields are `null` rather than a plausible-looking guess. On hotel-style listings, where the headline price is read from the display components rather than a flat price field, that price is additionally cross-checked against the price-detail lines: it must be the ceiling of their sum. If it is not, **no quote is delivered and none is charged** for that window — the mismatch is logged instead of shipping a number we cannot stand behind.
- **Room type / rate plan is Airbnb's default, not a choice you make.** The actor does not send a `selectedRatePlanId`, so Airbnb quotes whatever room type and rate plan it serves by default for that listing and window. Some listings (hotels in particular) label their own breakdown *"Price may vary by room type"*, and the actor passes that reality through rather than hiding it: the quote is the default-room price, not the cheapest or a specific room. Selecting a rate plan is **not** supported in this version and no field claims otherwise.
- **`maxGuests`, `cancellationPolicy` and `canInstantBook` are read from the booking-quote response** (the listing's PDP sidebar), not from the calendar. That means they are populated **only when at least one price quote is requested for that listing**. If a listing has no priceable dates in the selected window, or you run with `enrichPrices: false`, these fields stay `null`. This is deliberate: the actor does not fire an extra request (and an extra charge) just to fill them.
- **When capacity is reduced** (`adultsReducedToFitCapacity: true`), the quoted price is for the reduced guest count that fits the listing, **not** the number of adults you requested. The reduction is always flagged on the quote (`requestedAdults` vs `effectiveAdults`), never hidden.
- **On hotel / multi-room listings, `maxGuests` describes the default room, not the listing.** Airbnb's own response can report `maxGuestCapacity: 1` for a hotel whose booking form happily accepts — and prices — three guests. The number is passed through because it is real, but the claim is lowered honestly: `maxGuestsReliable` is `false`, `maxGuestsScope` is `"default-room"` (versus `"listing"`), and `maxGuestsCaveat` states why. Do not treat it as the listing's guest limit.
- **A host discount on your dates does not confuse the breakdown.** Airbnb sometimes prints the *accommodation line itself* as a discounted line (`2 nights × $190.91` → `$381.81`, with a struck-through `$448.11` and **no** minus sign). That is a stay charged at a discounted rate, not a deduction: it is reported as `nightlyBase` / `accommodationSubtotal`, the struck-through original goes to `accommodationOriginalSubtotal` / `accommodationDiscount`, and only a line that really carries a minus is ever subtracted.
- Availability and price come from Airbnb's public web responses; if Airbnb changes its response format the actor may need an update.

### Input

```json
{
    "listings": ["https://www.airbnb.com/rooms/14926879", "48819442"],
    "startDate": "2026-09-01",
    "endDate": "2026-09-30",
    "pricingMode": "sampled",
    "maxQuotesPerListing": 6,
    "quoteNights": 0,
    "adults": 2,
    "currency": "EUR"
}
```

Key inputs:

- `listings` (required) — Airbnb room URLs or plain listing IDs.
- `startDate` + `endDate` — optional exact ISO window (`YYYY-MM-DD`). Provide **both** or neither. When set, `calendarMonths` is ignored and emitted `days[]` are filtered to that window. `endDate` must be after `startDate` and within the 12-month availability horizon. The window itself is always quoted directly (marked `quoteSource: "exact-range-direct"`), because Airbnb's availability calendar can report a window as unavailable that the booking endpoint still prices happily. That quote is skipped when the same window was already quoted, counts against `maxQuotesPerListing`, and — like every quote — is charged only if a price actually came back. With the default `quoteNights: 0` this is an **explicit stay**: it is the *only* quote you get and the only one you pay for (see *Asking for one stay vs. asking for a price curve*).
- `calendarMonths` (1–12, default 3) — months of availability to fetch when no exact range is given.
- `pricingMode` — `sampled` (default) or `allAvailableDates`.
- `maxPricedDates` — **required** when `pricingMode = allAvailableDates`; the per-listing cost budget. It has no default on purpose, so cost is a contract rather than a surprise.
- `onCostLimitExceeded` — `error` (default) or `truncate`.
- `maxQuotesPerListing` (default 4) — **hard cap** on quotes delivered *and charged* per listing in `sampled` mode; spread one-per-month first. The exact-range direct quote counts against it too, so the cap is never exceeded by one.
- `quoteNights` (default 0) — stay length for sampled quotes; `0` uses each date's own minimum-nights rule. Together with `startDate`/`endDate` it also decides whether your request is one explicit stay or a price curve — see the section above.
- `adults` (default 2) — guest count for quotes.
- `currency` (default `USD`) — ISO 4217 currency code for all prices. Case and spacing do not matter (`usd`, `" try "`), and every record reports the **normalized** code that was actually used. Anything that is not a real 3-letter currency code — a country code like `TR`, a symbol like `$`, a name like `US Dollar`, or an empty value — **stops the run before any request is made and before anything is charged**, rather than letting Airbnb price in a currency of its own choosing and labelling the result wrongly. There is no "no currency" mode: omit the field to take the default.
- `marketCountry` (default `US`) — ISO 3166-1 alpha-2 country the requests exit from. Airbnb prices the market you ask from, so the same listing and dates can return a different nightly rate or an extra tax line from a different country. Pinning it fixes **the country dimension only** — it does not make a price permanently reproducible: prices are still live and change over time, and the city, the exit IP and whatever experiment Airbnb serves are not pinned. `US` is an **arbitrary but fixed** default (Airbnb's largest single market); a fixed default beats a random one, but it is **not** "the real price" — set `marketCountry` to whichever market you actually want priced. **Independent of `currency`** — `currency` picks the money, `marketCountry` picks the market (`EUR` + `US` is a valid pair). An invalid code stops the run before anything is spent. If your `proxyConfiguration` already pins `apifyProxyCountry`, that explicit pin wins, is logged, and is what gets reported.
- `locale` (default `en`) — language for localized strings (`en`, `de`, `tr`, `en-GB`). A value that is not a language tag at all stops the run before anything is requested or charged; valid tags are passed through unchanged.
- `proxyConfiguration` — Apify Proxy with residential groups is the default.

### Output (one item per listing)

```json
{
    "listingId": "14926879",
    "url": "https://www.airbnb.com/rooms/14926879",
    "status": "ok",
    "currency": "EUR",
    "marketCountry": "US",
    "calendarMonths": 3,
    "daysTotal": 30,
    "daysAvailable": 12,
    "availabilityRate": 0.4,
    "firstAvailableCheckin": "2026-09-03",
    "dateRange": { "startDate": "2026-09-01", "endDate": "2026-09-30" },
    "pricingMode": "sampled",
    "maxGuests": 4,
    "maxGuestsSource": "sidebar",
    "cancellationPolicy": { "id": 44, "name": "Firm", "tooltip": "Free cancellation before ..." },
    "canInstantBook": true,
    "days": [
        { "date": "2026-09-03", "available": true, "availableForCheckin": true, "availableForCheckout": true, "minNights": 2, "maxNights": 365 }
    ],
    "quotesAttempted": 1,
    "quotes": [
        {
            "checkIn": "2026-09-03",
            "checkOut": "2026-09-05",
            "nights": 2,
            "currency": "EUR",
            "requestedAdults": 2,
            "effectiveAdults": 2,
            "adultsReducedToFitCapacity": false,
            "maxGuests": 4,
            "maxGuestsReliable": true,
            "maxGuestsScope": "listing",
            "maxGuestsCaveat": null,
            "maxGuestsSource": "sidebar",
            "capacityModel": "requested",
            "priceRaw": "€284",
            "priceQualifier": "for 2 nights",
            "priceTotal": 284,
            "perNight": 141.99,
            "priceIsPeriodRate": false,
            "pricePeriod": null,
            "priceCoversRequestedWindow": true,
            "priceSource": "primaryLine",
            "quoteSource": "calendar-candidate",
            "priceBreakdown": {
                "nightlyBase": 129.99,
                "accommodationRate": 129.99,
                "accommodationRatePeriod": "night",
                "nights": 2,
                "accommodationSubtotal": 259.98,
                "accommodationOriginalSubtotal": null,
                "accommodationDiscount": null,
                "subtotal": 271.98,
                "fees": [{ "label": "Resort fee", "amount": 12.00 }],
                "discount": null,
                "taxes": 11.99,
                "taxesAndFeesCombined": null,
                "taxesDeferred": false,
                "totalIncludesTaxes": true,
                "taxScope": "taxes-itemized",
                "totalPrice": 283.97,
                "currency": "EUR",
                "breakdownAnomaly": null
            }
        }
    ],
    "scrapedAt": "2026-08-09T00:00:00.000Z"
}
```

The breakdown always adds up, so you can check it yourself:

```
subtotal   = accommodationSubtotal + sum(fees) - discount          # the pre-tax total
totalPrice = subtotal + (taxes + taxesAndFeesCombined, only when totalIncludesTaxes is true)
```

Field meanings, so none of them can be mistaken for another:

| field | meaning |
|---|---|
| `nightlyBase` | the per-**night** rate **read** from Airbnb's `"N nights × rate"` line (never a rounded total divided by nights). `null` on a long stay, where Airbnb prints no nightly rate — see *Long stays* below |
| `accommodationRate` / `accommodationRatePeriod` | the same rate together with the period one unit of it buys: `"night"`, or `"month"` / `"week"` on a long stay. `nightlyBase` is the night-only view of it and stays `null` for any other period, so a monthly figure can never be read as a nightly one |
| `accommodationSubtotal` | the amount of that accommodation line on its own, before fees, discounts and taxes. `null` when the line is not readable — never a guess. (It equals `nightlyBase × nights` up to Airbnb's own cent rounding; the printed line amount is what is reported.) |
| `accommodationOriginalSubtotal` | when the host discounts those dates, Airbnb prints the stay line with a struck-through original (e.g. `$448.11` → `$381.81`). This is that original. `null` when Airbnb prints none — the actor makes no discount claim it cannot see. |
| `accommodationDiscount` | `accommodationOriginalSubtotal − accommodationSubtotal`, reported for information only. It is **not** part of the arithmetic below, because the accommodation amount is already the discounted one. |
| `discount` | a separate, explicitly negative discount line (e.g. `Special offer −$40.05`). Only a line that really carries a minus is subtracted. |
| `subtotal` | the **pre-tax total**: accommodation + fees − discount |
| `taxes` / `taxesAndFeesCombined` | the tax figures actually printed, kept apart (see `taxScope`) |
| `totalPrice` | the exact total of the price-detail lines; `totalIncludesTaxes` says whether tax is inside it |
| `priceTotal` / `priceRaw` | Airbnb's **displayed** headline price, which Airbnb rounds up to the whole unit |
| `perNight` (on the quote, not in `priceBreakdown`) | the average cost of one night of this window, containing exactly what `totalPrice` contains. It is `totalPrice ÷ nights` — the **exact** total, not the rounded headline — and `null` when there is no exact total to divide (including a long stay whose window is not one period) |
| `priceIsPeriodRate` / `pricePeriod` / `priceCoversRequestedWindow` (on the quote) | whether Airbnb's **displayed** headline is a rate per month/week rather than the total for your window — see *Long stays* |

The one exception to the arithmetic above is `taxScope: "ambiguous"`: there Airbnb's own lines did not reconcile with the total it printed, and reporting that fact is the point of the value — no number is bent to make the sum work.

**Prices are never negative.** Money cannot be negative, so a negative value anywhere in the breakdown is a classification error, not a price. **Every** money field is checked — `nightlyBase`, `accommodationRate`, `accommodationSubtotal`, `accommodationOriginalSubtotal`, `accommodationDiscount`, `subtotal`, `discount`, `taxes`, `taxesAndFeesCombined`, `totalPrice` and every `fees[].amount`. Each offending field is reported as `null` (a fee keeps its label, which is real observed data), the run logs it loudly naming the exact fields, and the quote carries `breakdownAnomaly`: `"negative-total"` when a total was affected, `"negative-money"` when only other fields were. When `breakdownAnomaly` is set, the arithmetic identities above no longer hold — that is the point of the flag. You may get no number; you will not get a negative one.

`breakdownAnomaly` is `null` on a healthy quote. Its other values, most severe last: `"long-stay-unreconciled"` (a period-priced response whose own lines do not add up), `"period-rate-window-mismatch"` (the response is priced per period and your window is not one period — see *Long stays*), `"negative-money"` and `"negative-total"`.

#### Long stays (monthly / weekly pricing)

On a long stay Airbnb stops printing `"N nights × rate"` and prices the accommodation **by the period** instead, with a single line such as `Average monthly price  $1,922.48`. When it does that, **every figure in the response is a per-period figure** — the accommodation line, the discount lines and Airbnb's own `Price after discount` total alike. That matters, because a monthly total is not a 46-night total.

- `accommodationRate` carries the printed rate and `accommodationRatePeriod` says `"month"` (or `"week"`). These two are a direct read of what Airbnb printed and are **always** reported.
- **`nightlyBase` stays `null`.** Airbnb printed no per-night rate, and the actor does not manufacture one by dividing the monthly rate — the same rule that keeps `nightlyBase` off the rounded display total.
- The period line is **not** a fee. It is the accommodation charge, so `fees[]` does not carry it and `taxScope` is free to report `no-tax-line` when the response really has no tax line.
- Lines whose label merely *mentions* the period (`Monthly stay discount`, `Airbnb monthly stay savings`) are still discounts and are still subtracted.

**When the window is exactly one period** (28–31 nights for monthly, 7 for weekly) the period figure *is* the window figure, and the full breakdown is published as usual — `accommodationSubtotal`, `subtotal`, `totalPrice` and `perNight` all mean what they always mean.

**When the window is not one period** — a 46-night stay priced monthly, or a 4-night stay carrying a monthly line — the window-scoped fields are **withheld rather than guessed**:

| | |
|---|---|
| withheld (`null`) | `accommodationSubtotal`, `accommodationOriginalSubtotal`, `accommodationDiscount`, `subtotal`, `discount`, `taxes`, `taxesAndFeesCombined`, `totalPrice`, every `fees[].amount`, and `perNight` |
| still reported | `accommodationRate` + `accommodationRatePeriod` (the rate Airbnb printed), the fee **labels**, `priceRaw` / `priceTotal` verbatim, and the raw lines in `quote.breakdown` |
| flag | `breakdownAnomaly: "period-rate-window-mismatch"`, plus a loud line in the run log |

**No window total is ever computed from the rate.** Airbnb did not publish one for that window, so neither does the actor — not `rate × nights ÷ 30`, not by any other arithmetic. You get the real monthly rate and the honest statement that it is not your window's total; the multiplication, with all the assumptions it carries, is yours to make or not.

**The displayed headline price is labelled too.** `priceRaw` / `priceTotal` are whatever Airbnb showed — on a long stay that is a *rate*, e.g. `"$1,340"` displayed as **monthly**. `priceRaw` is never rewritten; three additive fields on the quote say what it means:

| field | meaning |
|---|---|
| `priceIsPeriodRate` | `true` when the headline is a rate for a period rather than the total for the requested window |
| `pricePeriod` | `"month"` / `"week"`, or `null` on an ordinary stay |
| `priceCoversRequestedWindow` | whether one such period spans the window you asked for |

*Why length and nothing else decides this:* the same listing quoted at 30 nights (correct) and at 46 nights (wrong) returns byte-identical scope wording — qualifier `monthly`, headline `"… monthly, originally …"`, highlight label `"… per month"` — and the lines reconcile perfectly in **both**, because all of them are monthly. No text and no arithmetic check can tell the two apart. What separates them is that 30 nights fit inside one month and 46 do not. The bands are calendar facts, not tuned values: a calendar month is never shorter than 28 or longer than 31 days (and 28 is also the shortest stay Airbnb prices monthly), and a week is exactly 7.

*Billing:* these quotes **are** delivered and charged, like any other. Airbnb returned a real, bookable market price — a monthly rate is precisely the answer for a monthly rental — and the record no longer claims it is anything else. Refusing to deliver would return nothing at all for the entire long-stay segment, which is worse than returning a correctly labelled rate. The rule stays what it always was: a quote is charged when, and only when, a usable price was delivered.

**`marketCountry` on the record is the country actually used**, not the one asked for: it is read back off the proxy that minted the requests. It is `null` — never a guessed code — when no exit country could be pinned (custom proxy URLs, no Apify Proxy, or a local run).

**`quotesAttempted` vs `quotes`.** `quotesAttempted` counts every window the actor asked Airbnb about; `quotes[]` holds only the ones that came back with a usable price. When the two differ, the missing windows are named in the run log with the reason — and the difference costs you nothing: only the entries in `quotes[]` are charged.

In `allAvailableDates` mode, when a listing has more eligible dates than `maxPricedDates`, the record carries cost-budget meta: either `status: "cost-limit-exceeded"` with `plannedQuoteCount` and `maxPricedDates` (under `onCostLimitExceeded: "error"`), or `pricedDatesTruncated: true` with `pricedDatesOmitted` and `eligibleDatesTotal` (under `"truncate"`).

### Use cases

- **Competitor rate research** — real nightly prices per date for a set of listings you choose.
- **Occupancy analysis** — availability rate per listing per month for market studies.
- **STR pricing input** — a real price curve plus availability as raw data for your own pricing work.
- **Change tracking** — put it on an Apify Schedule to re-pull price and availability for the same listings over time.

### Pricing — pay only for delivered data

Pay-per-event billing:

- **calendar** — charged once per listing, only when calendar days were actually delivered.
- **price-quote** — charged only per successfully delivered price quote.

Failed listings, blocked requests, and empty results are **never charged**. When `onCostLimitExceeded: "error"` stops a listing, no quote events are charged for it.

**What counts as a "successfully delivered price quote".** A quote is delivered — and charged — only when it carries a real, readable price: a price value from Airbnb's response that parses to a positive number and whose source is named on the quote (`priceSource`). Airbnb sometimes answers with a price block that contains no usable amount (the dates were just booked, the stay length is not offered, the number could not be verified against the price-detail lines). Those attempts are **not delivered and not charged**, on every quote path. They still appear in `quotesAttempted`, and each one is logged with its window and the reason, so a listing that returns no prices costs you the calendar event and **nothing** in price-quote events.

You are never charged for quotes you did not ask for:

- asking for **one stay** (`startDate` + `endDate`, default settings) costs **one** price-quote event per listing;
- in `sampled` mode the total number of charged quotes per listing never exceeds `maxQuotesPerListing`;
- in `allAvailableDates` mode it never exceeds `maxPricedDates`.

### FAQ

**Do I need an Airbnb login or API key?** No. The actor uses Airbnb's public, unauthenticated web endpoints.

**Which proxies?** Apify Proxy with residential groups is the default configuration.

**I gave a date range and only got one quote — where are the others?** That is the intended behavior: a `startDate` + `endDate` with the default `quoteNights: 0` is read as *one explicit stay*, so you get (and pay for) exactly that stay. To price many dates, use `pricingMode: "allAvailableDates"` with `maxPricedDates`, or drop the range and use `calendarMonths`, or set a `quoteNights` different from the length of your range. See *Asking for one stay vs. asking for a price curve*.

**Why does `subtotal` include the fees?** Because `subtotal` is the **pre-tax total** (accommodation + fees − discount). If you want the accommodation line on its own, use `accommodationSubtotal`, which is reported separately on every quote.

**Can I pick a specific room type or rate plan?** No. The actor quotes whatever room type and rate plan Airbnb serves by default for that listing and window; some listings even print *"Price may vary by room type"* in their own breakdown. See "Honest limits".

**Why is `taxes` null on some quotes?** Because that response had no separate tax line. Check `taxScope`: `no-tax-line` means Airbnb printed none, `before-taxes-declared` means Airbnb's own total is stated before taxes, and `taxes-and-fees-combined` means the tax was bundled into one `Taxes and fees` line that we refuse to split — the amount is in `taxesAndFeesCombined`. The actor never calls the checkout endpoint, so it only ever reports what the listing response contains.

**Why did the same listing and dates return a different total than before?** Airbnb's own response varies: we have seen the same listing/window come back with an itemized tax line on one run and with no tax line at all on another, which changes the total by exactly that line (e.g. $283.97 vs $305.75). We do not claim to know what drives it. Each quote reports `taxScope` and `totalIncludesTaxes` so you can tell which one you received, and a live re-run is always the current answer.

**Why does `priceTotal` differ from `priceBreakdown.totalPrice` by a few cents?** `priceTotal` is Airbnb's displayed headline price, which Airbnb rounds **up** to the whole unit. `priceBreakdown.totalPrice` is the exact sum of the price-detail lines. Both are returned; use the breakdown when you need cents.

**The listing page shows a struck-through price — where is it in the output?** In `accommodationOriginalSubtotal` (the pre-discount stay amount Airbnb printed) and `accommodationDiscount` (the difference). `accommodationSubtotal`, `subtotal` and `totalPrice` are already the discounted amounts, so the original is reported beside them rather than folded into them. If Airbnb prints no original, both fields are `null`.

**`maxGuests` says 1 but the listing clearly sleeps more — why?** That is a hotel / multi-room listing, where Airbnb reports the capacity of the default room. Check `maxGuestsScope`: `"default-room"` means exactly this, `maxGuestsReliable` is `false`, and `maxGuestsCaveat` spells it out.

**Why are `maxGuests` / `canInstantBook` null on some listings?** They are read from the booking-quote response, so they are filled only when at least one quote is requested for that listing. See "Honest limits".

**Is scraping Airbnb legal?** The actor only accesses publicly available data. You are responsible for using the data in compliance with applicable laws and Airbnb's terms.

# Actor input Schema

## `listings` (type: `array`):

Airbnb room URLs (e.g. "https://www.airbnb.com/rooms/14926879") or plain listing IDs.

## `calendarMonths` (type: `integer`):

How many months of availability to fetch per listing, starting from the current month (1-12).

## `enrichPrices` (type: `boolean`):

For available date windows, fetch the bookable price Airbnb quotes for those exact dates, with a per-night breakdown. Airbnb's calendar response carries no price (price is null), so this comes from a separate booking quote. Whether the total includes tax depends on what Airbnb returns; every quote reports it via taxScope and totalIncludesTaxes.

## `maxQuotesPerListing` (type: `integer`):

HARD cap on price quotes delivered and charged per listing in sampled mode - including the direct quote for an exact startDate/endDate window. Quotes are spread across months first (one per month), so with 12 months + 12 quotes you get a full-year price curve. Not used in allAvailableDates mode, where maxPricedDates is the budget.

## `quoteNights` (type: `integer`):

Stay length used for price quotes. 0 = use each date's own minimum-nights rule (recommended: a window shorter than the date's minimum stay is not bookable, so Airbnb returns no price for it). NOTE: with startDate+endDate in sampled mode, 0 - or a value equal to the length of that range - means "price this one stay": you get exactly ONE quote for the requested window and are charged once. Set a DIFFERENT value (e.g. 3 nights inside a 30-day window) if you want a sampled price curve across the range instead.

## `startDate` (type: `string`):

Optional ISO date (YYYY-MM-DD). Provide BOTH startDate and endDate to fetch an exact window instead of whole months. When set, calendarMonths is ignored and emitted days\[] are filtered to \[startDate, endDate]. With the default quoteNights (0) in sampled mode this is an explicit stay: exactly ONE price quote is delivered and charged, for exactly this window.

## `endDate` (type: `string`):

Optional ISO date (YYYY-MM-DD), must be after startDate and within 12 months. Provide together with startDate.

## `pricingMode` (type: `string`):

sampled (default): a handful of quotes spread across months (maxQuotesPerListing). allAvailableDates: price EVERY eligible check-in date (requires maxPricedDates as a cost budget).

## `maxPricedDates` (type: `integer`):

REQUIRED when pricingMode = allAvailableDates. Hard budget on how many date quotes may be priced per listing. No default - you must set it so the cost is a contract, not a surprise.

## `onCostLimitExceeded` (type: `string`):

When a listing has more eligible dates than maxPricedDates. error (default): price nothing for that listing (0 charge) and flag status=cost-limit-exceeded. truncate: price the first maxPricedDates chronologically and flag the omission loudly.

## `adults` (type: `integer`):

Guest count for price quotes. If a listing's capacity is smaller than this, the actor re-quotes at the listing's maximum guest count and flags the change on the record (requestedAdults, effectiveAdults, adultsReducedToFitCapacity).

## `currency` (type: `string`):

ISO 4217 currency code for all prices (USD, EUR, GBP, TRY, ...). Prices are returned in THIS currency - not whatever the listing happens to use. Case and spacing do not matter: "usd" and " try " are normalized to USD and TRY, and every output record reports the NORMALIZED code that was actually used. Anything that is not a real 3-letter currency code - a country code like TR, a symbol like $, a name like "US Dollar", or an empty value - stops the run before any request is made and before anything is charged, instead of letting Airbnb price in a currency of its own choosing and labelling the result wrongly. Omit the field to use the default USD; there is no "no currency" mode.

## `marketCountry` (type: `string`):

ISO 3166-1 alpha-2 country code the requests exit from (US, DE, TR, ...). Airbnb prices the market you ask from: the same listing, dates and guests can come back with a different nightly rate or an extra tax line depending on the country. Pinning it fixes the country dimension only - it does not make a price permanently reproducible, because prices are still live and the city, exit IP and Airbnb experiments are not pinned. Default US is arbitrary but fixed (Airbnb's largest single market): a fixed default beats a random one, but it is NOT 'the real price' - set marketCountry to whichever market you actually want priced. Independent of Currency: marketCountry decides WHICH market's price, currency decides which money it is shown in. Leave empty only if you accept a random exit country and non-reproducible prices. If your own Proxy configuration already pins a country, that pin wins and is reported.

## `locale` (type: `string`):

Language for localized strings (e.g. "en", "de", "tr", "en-GB"). It must be a language tag: a value that is not one at all - an empty string, null, or anything else that is not a language tag - stops the run before any request is made and before anything is charged, instead of being spent on a request that quietly asks Airbnb for the literal locale "null". Valid tags are passed through unchanged: no trimming, no case folding.

## `proxyConfiguration` (type: `object`):

Apify Proxy with residential groups is the default configuration.

## Actor input object example

```json
{
  "listings": [
    "https://www.airbnb.com/rooms/14926879"
  ],
  "calendarMonths": 3,
  "enrichPrices": true,
  "maxQuotesPerListing": 4,
  "quoteNights": 0,
  "pricingMode": "sampled",
  "onCostLimitExceeded": "error",
  "adults": 2,
  "currency": "USD",
  "marketCountry": "US",
  "locale": "en",
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "listings": [
        "https://www.airbnb.com/rooms/14926879"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("mavilinklabs/airbnb-calendar-price").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "listings": ["https://www.airbnb.com/rooms/14926879"],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("mavilinklabs/airbnb-calendar-price").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "listings": [
    "https://www.airbnb.com/rooms/14926879"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call mavilinklabs/airbnb-calendar-price --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,mavilinklabs/airbnb-calendar-price"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/a4TBYHPqAElio10Fx/builds/xactTmzZuJnaVGkLX/openapi.json
