Today every property you can push has exactly one unit with exactly one listing, so the
three levels look like ceremony. They are not: the schema has always been
property → units → listings, and the shape is published now so that a property with two
room types and three rate plans is an addition later rather than a rename of everything
you built against.
Everything you book, block or price is addressed by listing_id. A unit id is the
platform’s own physical inventory and a channel integration never handles one.
Reading a catalogue
Start with the list. Each entry already carries its units and their listings, so a full sync is one walk rather than one call per property.page_size is the same here as on every other list: 25 by default, 200 at most. A property
carries its units and their listings inline, so one page is a large document; ask for fewer
rather than more if you are walking a big portfolio.
What “your properties” means
The whole workspace your key was issued for — every property in it, including the ones created in the Maat app, which have noprovider_id at all.
provider_id in a response is always yours. A property you did not push comes back with
null.
Each property also carries the private delivery fields on its address (building, floor,
apartment, directions — what a host typed for the guest who has to find the door), the
physical unit_id of each unit, and console_url, a deep link into the Maat host console.
Reading one property
by-id segment says which identifier the read means. PUT /v1/properties/{provider_id} writes by your
reference and GET /v1/properties/by-id/{property_id} reads by ours, and those two are
the same URL shape as far as a specification is concerned — so the read says which
identifier it means rather than leaving the write’s path to mean two things.
property_not_found is one answer for “no such property” and “not yours”. So is
listing_not_found, and unit_not_found. We do not tell those apart, because doing so
would let anyone confirm the existence of another customer’s inventory.
Is it actually on sale?
Two fields answer that, and they answer different questions.status is what is true right now: listed means a guest can find and book it, unlisted
means they cannot — for any reason.
publishable and missing say whether the listing satisfies Maat’s own validity gate:
photos_processing and photos are separate on purpose: sending more images when the
answer was “wait” makes the problem worse.
publishable can also be null, which is neither true nor false: it means nothing
was assessed. That happens on a listing whose inventory_kind is service or experience
— something sold by the seat rather than by the night. The gate asks about a rate, amenities, a cover photo and enough photos, and such a listing has no answer to it. Those listings are returned by
GET /v1/listings/{listing_id} with their kind, and are read-only through this contract.
The reference catalogues
Three lists, the same in every workspace, and worth reading once at the start of a sync rather than discovering one property at a time:GET /v1/catalog/property-types— everyproperty_typea push accepts.GET /v1/catalog/amenities— every amenity slug, with its section.GET /v1/catalog/house-rules— every house rule, with itskindandsort_order.
kind decides how a rule reads. A permission has two polarities — “Pets allowed” and
“No pets” are the same rule answered differently. A requirement has only one: it appears
when it is answered true and has no negated form at all, because “No lock up before
leaving” is not a policy anybody has.
Retired rules are included, flagged retired: true. Properties answered them before
they were withdrawn and those answers still come back on a property read, so you need the
entry to explain the slug — but do not offer one on a new push.
A unit’s house_rules carries only rules somebody actually answered. A rule that is absent
was never asked. It does not mean “no”.
Reading the calendar
GET /v1/listings/{listing_id}/calendar?from&to gives you one entry per night:
booked and held are both sales rather than blocks, and they are separate because they
mean different things to you: a hold is a checkout in flight and may expire; a booking will
not. blocked_by (host, ical for a calendar feed subscribed on our side, partner for a
block pushed through this API, or null) is null on both, because nobody blocked those
nights — somebody bought them.
external_uid comes back only on blocks you pushed yourself through
PUT /v1/blocks/{external_uid}. A calendar feed’s own identifiers are never published at all.
from and to are inclusive nights — unlike a block’s check_out, which is the
exclusive departure day. Send both or neither; one alone is refused rather than
half-defaulted, because a window you did not ask for is worse than an error. Omitting both
gives you today plus the next 89 nights, and the window may not exceed 366.
Edits, not upserts
There are two ways to change a property, and they are for different jobs.PUT /v1/properties/{provider_id} is the whole-property push, keyed on your own
reference. It replaces everything — name, type, address, occupancy, rate, amenities,
photos — and it is the right call when your own system is the source of truth and you are
syncing a catalogue you already hold. Send it whole, every time, and an amenity you drop
is an amenity we remove.
The six targeted edits change one thing about a property that already exists, keyed on
Maat’s own ids:
Reach for a targeted edit when the change is small and you know which resource it belongs
to — a rate that moved, a door time that changed, a house rule the owner decided. Reach for
the whole-property push when you are reconciling a catalogue and would otherwise be firing
six calls at one property.
PUT /v1/listings/{listing_id}/pricing is the one operation on this
API that needs pricing:write rather than catalog:write — what a stay costs is a
different capability from what it is, and a key issued for a catalogue sync should not
thereby be able to move a nightly rate.
PUT replaces a set. PATCH touches what you name
The method tells you what an omitted field does, and it is the same rule everywhere:
- A
PUTon a sub-resource is a set replace. The address, the amenity set, the house-rule answers, the price — you send the whole thing, and a member you leave out is removed. It is the only version of “update” in which “take this amenity off” is sayable at all. It is also why an omittedbuildingorflooris cleared rather than kept: a stale floor number left under a new street sends a guest to the right building and the wrong door. - A
PATCHis partial. An absent field is untouched. Send at least one field — an empty body is a400, not a silent no-op that reads as success.
400 validation_failed
naming it, never a silent drop, and a stray query string is refused the same way. Text is
normalised before it is stored — control, zero-width and bidirectional-override characters
removed, whitespace collapsed — and its length re-checked afterwards, so a title that
normalises to nothing is a 400 rather than an empty listing title.
Two refusals apply to all six, both 409, both writing nothing:
property_not_simple when the property is not one unit with one listing, and
inventory_not_supported when the listing is an experience or a service. See
Errors.
Photos
A property’s photographs are their own subject — the two ways bytes reach us, the states a photo moves through while you wait, ordering, the cover, and removal. They are on Photos.Publication is a gate
PATCH /v1/listings/{listing_id} is the only operation that decides whether a listing is
on sale, and status there is not a column write.
status: "listed" runs Maat’s own validity gate — a rate above zero, at least one
amenity, a cover photo and a minimum photo count — and publishes the listing only if it
passes.
If it does not pass, the answer is still a 200. The listing comes back with
publishable: false and missing[] saying exactly what it is waiting for, and nothing
about publication changes: a listing that was off sale stays off sale, and a listing that
was already on sale is not taken off it as a side effect of a request that asked for the
opposite. Acceptance is not publication. Read the response rather than the status line.
status: "unlisted" holds the listing off sale. That one always applies.
A platform suspension outranks your request. If Maat has held a listing off sale for
its own reasons, a listed request does not lift it: the gate can pass and the listing
still comes back status: "unlisted" with publishable: true and an empty missing.
That is not a contradiction and it is not something this API can clear — talk to the
workspace, which can see why.
The same missing vocabulary is the one the property push uses, including
photos_processing, which is not a deficiency and clears on its own.
Current status
Reads and edits alike are live in a sandbox workspace and answer501 against a
production one, where nothing is read, nothing is written and nothing is disclosed. A
501 means your integration is correct — see Sandbox.