> ## Documentation Index
> Fetch the complete documentation index at: https://developer.maatstays.wtf/llms.txt
> Use this file to discover all available pages before exploring further.

# Pricing rules and rates

> Quick actions are the rule surface, and the rate calendar is the other way to price a listing. What you can switch, what you can supply, and what stays in the app.

A listing's price and its booking constraints come from **rules**. Most of those rules are
drawn by hand, on a canvas, in the Maat app. A small, fixed set of them are **quick
actions**: one-tap switches with at most one number behind them, and they are the whole of
what this API can change.

| Operation | What it does |
| :- | :- |
| `GET /v1/listings/{listing_id}/quick-actions` | Every quick action, and what is switched on. |
| `PUT /v1/listings/{listing_id}/quick-actions/{template_key}` | Switch one on, or move its value. |
| `DELETE /v1/listings/{listing_id}/quick-actions/{template_key}` | Switch one off. |
| `GET /v1/listings/{listing_id}/rules` | Every rule on the listing. **Read-only.** |

There is a second way to price a listing, and it is not a rule at all: **supply the nightly
rates yourself** and become the listing's pricing source. That is
[Pricing a listing for a date range](#pricing-a-listing-for-a-date-range), further down.

## The eight actions

`template_key` is a closed enum. There are eight of them, and the list does not grow
quietly — it is checked in CI against the platform's own seeded templates, so this page and
the reference cannot describe something we do not run.

| `template_key` | Family | Value |
| :- | :- | :- |
| `booking.min_stay` | `booking` | Nights, 1–30 |
| `booking.max_stay` | `booking` | Nights, 1–365 |
| `booking.no_checkout_days` | `booking` | Day names — departures refused on those days |
| `booking.weekend_min_stay` | `booking` | Nights, 1–14 |
| `pricing.weekend_surcharge` | `pricing` | Percent, 1–200 |
| `pricing.long_stay_discount` | `pricing` | Percent, 1–90 |
| `pricing.large_group_surcharge` | `pricing` | Percent, 1–200 |
| `pricing.cleaning_fee` | `pricing` | An amount, in the listing's currency |

`pricing.long_stay_discount` applies **at checkout**, not on the calendar. A guest browsing
nightly prices will not see it; the quote they are charged does.

**A published identifier is permanent.** If a template is ever withdrawn it keeps its
`template_key`, comes back from the list as `available: false, unavailable_reason:
"retired"`, and is refused by `PUT`. Nothing you have stored ever becomes an unrecognised
string.

**There are no labels on this wire.** The names you see in the Maat app are our own copy,
written for a host looking at a switch, with no translation behind them. Render your own
from the `template_key`.

## Typing the value

`param` on each action tells you what it takes: a `kind`, a `default`, and the bounds and
`step` where the kind has them. Omit `value` on a `PUT` to take the default — you do not
have to know the number to switch something on.

```json theme={null}
{
    "template_key": "booking.min_stay",
    "family": "booking",
    "category": "stay_length",
    "param": { "kind": "number", "default": 2, "min": 1, "max": 30, "step": 1 },
    "applied": null,
    "available": true,
    "unavailable_reason": null
}
```

A `PUT` answers **`200` either way**, with `result` saying which happened —
`created` when it switched the action on, `updated` when it moved one that was already on.
The same field the property and block pushes carry; there is no `201` to branch on.

| Kind | What to send |
| :- | :- |
| `number`, `percent` | A number within `min`–`max`, on `step`. `4.5` on a step of 1 is refused. |
| `days_of_week` | Day names from the closed seven, no duplicates. |
| `currency` | A **Money object** whose `currency` equals the listing's. |

### Money, and the currency we will not guess

`pricing.cleaning_fee` takes an amount, and on this API an amount never travels without its
currency:

```json theme={null}
{ "value": { "value": "25.00", "currency": "EGP" } }
```

That `currency` must equal the listing's own. Send a different one and you get a
`validation_failed` naming `body.value.currency` — we do not convert. An amount we
converted for you is a number the host never chose, on every guest's receipt from then on.
Read the listing's currency from `GET /v1/listings/{listing_id}` or from the `param.default`
this endpoint returns.

## Scopes

`rules:write` reaches all four operations. Anything touching a `pricing.*` template needs
**`pricing:write` as well** — on the writes AND on the read.

| Operation | `rules:write` | `pricing:write` |
| :- | :- | :- |
| `GET .../quick-actions` | required | to see `applied` on the `pricing.*` rows |
| `PUT .../quick-actions/{template_key}` | required | required when `template_key` starts `pricing.` |
| `DELETE .../quick-actions/{template_key}` | required | required when `template_key` starts `pricing.` |
| `GET .../rules` | required | to see the PRICING rules at all |

This is the only place in the API where the requirement depends on the path, and the reason
is that these two things are not the same act: `pricing.cleaning_fee` changes what a guest
pays, `booking.min_stay` changes who may book. A key that manages availability rules should
not thereby be able to change money. If your key is missing the second scope on a write you
get the usual `insufficient_scope`, naming it.

**On the reads the same rule applies, and neither errors.** On the quick-action list you
still get all eight catalogue entries with `rules:write` alone — the identifiers, kinds,
bounds and defaults are the same for every listing on Maat, so there is nothing to withhold.
What is withheld is `applied` on the `pricing.*` rows: they come back `applied: null`,
`available: false`, `unavailable_reason: "insufficient_scope"`.

On `GET .../rules` the pricing rows are withheld **entirely**, and `warnings` carries
`pricing_withheld`. There is no catalogue half to keep there: a rule is a host-written
`title` and whether it is live, which between them say what this workspace charges and when.
Note the distinct warning — it is **not** `pricing_unavailable`, which means the pricing
service is down and is worth retrying. This one never is.

The fee a listing charges and the surcharges it runs are pricing, and a key that may not
change them is not thereby entitled to read them.

There is no read-only scope for rules. Seeing which rules price your listing is part of
managing them, and a published scope is forever.

## A quick action edits the rule your workspace already has

A rule row records no author. `PUT` re-points an **existing** rule rather than creating a
second one — two `pricing.cleaning_fee` rules would charge a guest twice — so an apply
overwrites the number set in the Maat app, and `DELETE` removes the rule whoever created it.
Treat a quick action as the same switch the host app shows, because it is.

## When an action is unavailable

`available: false` means a `PUT` would be refused right now. `unavailable_reason` says why:

* **`external_pricing`** — the listing's price is set by another source. Rules on it would
  never be evaluated, so every action reads unavailable and `PUT` answers
  `pricing_mode_conflict`. Release that source in the Maat app and they come back.
* **`retired`** — the template was withdrawn. The identifier stays published; `PUT` answers
  `validation_failed`.
* **`inventory_kind`** — the action does not apply to this listing's kind. Published for
  completeness; a listing that is not a stay is refused outright with
  `inventory_not_supported` before this could appear.
* **`insufficient_scope`** — about your key, not the listing: every `pricing.*` row reads
  this when your key lacks `pricing:write`, and its `applied` is `null`. Ask whoever issued
  the key for one that carries the scope. The `booking.*` rows are unaffected. Never retry —
  no repetition of the request changes what your key holds.

## A partial list says so

The two families are served by two of our services, which deploy independently. If one is
unreachable, its actions are **absent from `items`** and `warnings` says which:

```json theme={null}
{ "items": [/* pricing only */], "warnings": ["booking_unavailable"] }
```

Read `warnings` before you act on a short list. An empty pricing family and a pricing
service that is restarting look identical otherwise — and one of those means "this listing
has no surcharges", which is a very different thing to tell somebody.

A family that could not be read is never reported as retired, for the same reason: a
five-minute deploy must not look like a permanent withdrawal.

## Reading the rules

`GET /v1/listings/{listing_id}/rules` answers "why is my price what it is". It lists every
rule attached to the listing — quick actions and hand-drawn ones alike — with its `title`,
whether it is `active`, and its `source`:

```json theme={null}
{
    "items": [
        {
            "id": "…",
            "family": "pricing",
            "title": "Cleaning fee",
            "active": true,
            "source": "quick_action",
            "template_key": "pricing.cleaning_fee"
        },
        {
            "id": "…",
            "family": "pricing",
            "title": "Summer weekends",
            "active": true,
            "source": "authored",
            "template_key": null
        }
    ],
    "warnings": []
}
```

A `quick_action` rule names the action it came from and can be changed through the
endpoints above. An `authored` one cannot be changed here at all.

**Without `pricing:write` the `family: "pricing"` rows are absent and `warnings` carries
`pricing_withheld`** — the example above is what a key holding both scopes sees. The booking
rules come back either way. Do not treat `pricing_withheld` as an outage: unlike
`pricing_unavailable` beside it, no retry will ever change the answer.

## Pricing a listing for a date range

Everything above changes rules that Maat's own engine then evaluates. This section is the
other way to price a listing: **you supply the nightly rates**, and Maat quotes them.

The two are mutually exclusive by design, and the order matters.

### Push first, then take the mode

```bash theme={null}
# 1. Fill the horizon. Stored, and not yet in force.
curl -X PUT "https://maatstays.wtf/v1/listings/$LISTING/rates" \
  -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"rates":[{"first_night":"2026-12-20","last_night":"2027-06-30",
                 "nightly_rate":{"value":"3200.00","currency":"EGP"},"min_stay":3}]}'
# → { "nights_written": 193, "mode": "rules", "binding": false, … }

# 2. Now switch. From here your rates are what a guest is quoted.
curl -X PUT "https://maatstays.wtf/v1/listings/$LISTING/pricing-mode" \
  -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
  -H "Content-Type: application/json" -d '{"mode":"partner"}'
# → { "mode": "partner", "rules_deactivated": {"pricing": 2}, "warnings": [] }
```

Taking the mode first is allowed and the response says so — `warnings` carries
`no_rates_next_30_nights` — but it leaves a real listing with nights nobody can book until
your first push lands. Fill the horizon, then switch.

### `first_night` and `last_night` are both NIGHTS

Both inclusive, both nights. They are not called `from`/`to` precisely so there is no
exclusive checkout day to misread: `2026-12-20 … 2026-12-31` prices twelve nights, and the
twelfth is the night of the 31st.

Ranges in one request may not overlap, every night must be today or later, and one request
covers at most **730 nights** in total. Two *separate* requests naming the same night are
fine and the later one wins — that is how a correction works.

**This `PUT` merges; it does not replace.** A night you do not mention keeps the rate you
last sent for it. That is the opposite of every other `PUT` on this API, where an omitted
member is removed, and it is the one place the difference matters: there is no way to *clear*
a night once you have priced it, only to re-price it. If you hold the pricing mode, a night
you sent last month is still quoted this month unless you send another rate for it.

### What a night with no rate is worth is your choice

This is the single most important thing on this page.

By default, once you hold the pricing mode, a night your calendar does not cover is **not
sold at the host's base rate and not sold cheaply — it is refused**. A gap means the feed is
broken, and inventing a price for a broken feed is how a peak night gets sold for a fraction
of its worth without anybody noticing.

That default suits a feed that covers every night you are contracted for. It is a cliff for
one that covers a **rolling horizon** — the night after your last pushed one falls off sale,
and it moves a day closer every day.

So the choice is yours, per listing, on the take:

```json theme={null}
PUT /v1/listings/{listing_id}/pricing-mode
{ "mode": "partner", "fallback_to_base_price": true }
```

With it on, a night you have not priced is quoted at **the host's own base price** instead of
refusing. Your rates still win on every night you have sent one for — the base price fills
gaps and nothing else, and it carries no minimum stay. A listing whose host has no usable
base price is refused either way: a fallback that could reach zero would put a stay on sale
free, which is worse than a night that cannot be booked.

A `PUT` states the whole mode, so **send the field on every take, not only the one that
turns it on** — omitting it means `false`. Changing it on a listing you already price is
allowed and takes effect on the next quote; you do not need to release first.

Either way, `warnings`, `coverage_until` and `gap_nights_next_90_days` keep telling you the
truth about what you have actually sent. Fallback changes what an unpriced night costs, not
whether you should care about one.

So `coverage_until` is the last night of the **contiguous** run that starts today, not the
furthest night you have priced. A horizon with a hole in it is not covered past the hole,
and `gap_nights_next_90_days` counts what is missing in the coming quarter. Both are on
every push response; watch them.

It always describes **your own** calendar, whether or not you hold the pricing mode — so a
fill sent before you take it reports a real horizon rather than `null`, and
`GET /v1/listings/{listing_id}/rates` answers the same number. Whether those nights are in
force is `binding` and `mode`, never `coverage_until`.

### One currency, never converted

`nightly_rate.currency` must equal the listing's own currency. A mismatch is
`400 validation_failed` naming the field, and nothing is converted — a rate accepted in one
currency and charged in another is a real mis-charge, not a rounding difference.

If the host changes the listing's currency, **every future rate you sent is dropped**, and
the listing stays unquotable until you push again in the new currency. Nothing re-pushes on
your behalf.

There is **no webhook for this yet.** Your signal is the push response: send a range and read
the `currency` and `binding` it comes back with. A push that answers a currency you did not
send is a currency change you missed, and `coverage_until: null` on a listing you had filled
is the same fact from the other direction. Poll `GET /v1/listings/{listing_id}/pricing-mode`
on whatever cadence your rate plan changes on.

### `min_stay` rides the quote

A `min_stay` you set on a night binds any stay that includes it — the maximum over the
stay's nights wins. Guests are refused at checkout with the same message they would get from
a host's own minimum-stay rule; there is nothing extra to configure.

### Taking it back

```bash theme={null}
curl -X DELETE "https://maatstays.wtf/v1/listings/$LISTING/pricing-mode" \
  -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"   # 204
```

Your rate rows are **kept** — they are your submission and your record of what you quoted,
and a re-take resumes on them. The listing's own pricing rules are **not** switched back on;
the host re-enables the ones they still want.

**The host can release at any time, from their own app, without your agreement.** They see
who prices each listing, how far your horizon reaches and when you last sent a rate. A
credential of yours being revoked does NOT release the mode — a listing's prices must not
silently change hands because a key expired — so a quiet feed simply runs the horizon down
night by night, which is exactly what the host is looking at.

There is **no webhook for a release yet** either, and — importantly — a release does not
start refusing you. After a host takes the listing back, your next push still answers
`200`, with `binding: false` and `mode: "rules"`: the rows are stored and inert, exactly as
they were before you first took the mode. So **branch on `binding`, not on the status code.**
An integration that only checks for a `409` will keep pushing into a calendar nobody reads.

### Reading it back

`GET /v1/listings/{listing_id}/rates?from&to` returns **your own** nights in `items`, at most
366 per read. It is a receipt: nothing on it is something Maat holds that you did not send. It
is not paged — the window is the bound — so
there is no `has_more` on it.

`GET /v1/listings/{listing_id}/pricing-mode` answers `rules`, `partner` or `external`. Here
`external` is a **third-party pricer** — a pricing source that is neither you nor Maat's
own rule engine. It is the one sense of *external* in this API that does not mean "yours":
`external_uid` on a calendar block is your own identifier. All four senses are set beside
each other in the [glossary](/guides/glossary#the-word-external). It is undifferentiated:
this endpoint never names which source it is. You see your own horizon or you see nothing.

### While another source prices the listing

Every write is `409 pricing_mode_conflict` and **nothing is half-applied**. Your own rows
stay readable and inert. Quick actions on the same listing are refused with the same code,
and the rules list shows them unavailable — a rule under an external pricing source would
never be evaluated, so offering the switch would be offering a switch that does nothing.

## What is not on this API, and will not be

**Rule authoring.** A rule's logic is a node graph drawn on a canvas. Publishing that graph
would freeze an internal shape as a contract on the day it shipped, and a contract that
carried it would invite writes against it. So there is no create, no link, no unlink and no
pausing anywhere here — the rules list carries a rule's identity and its state, and nothing
about what it does.

**Rule templates.** Those are an administrative surface, not an integration one.

**Turning a rule on or off.** `active` is readable and not writable. A rule paused in the
app stays paused.

**Deleting a rate range.** There is no per-range delete. A night is re-priced by sending a
new rate for it, or the whole calendar stops being read when you release the pricing mode.
A delete would be a third way to make a night unquotable, and two is already one more than an
integration should have to reason about.

**Closing check-in or check-out on a date.** Not on the rate calendar. Maat does not enforce
those today, and a field you could set expecting enforcement is worse than an absent one.

## Current status

Live in a sandbox workspace — the quick actions, the rules read, the rate calendar and the
pricing mode alike. Against a production workspace every request is authenticated,
rate-limited and validated in full, then answers `501`: no rule is applied, no rule is
removed, no rate is stored, no mode is taken, and the listing prices exactly as it did
before.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.