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, 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.
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.
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.
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:
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.
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 andPUTanswerspricing_mode_conflict. Release that source in the Maat app and they come back.retired— the template was withdrawn. The identifier stays published;PUTanswersvalidation_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 withinventory_not_supportedbefore this could appear.insufficient_scope— about your key, not the listing: everypricing.*row reads this when your key lackspricing:write, and itsappliedisnull. Ask whoever issued the key for one that carries the scope. Thebooking.*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 fromitems and warnings says which:
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:
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
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: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
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. 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 is409 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 answers501: 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.