> ## 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.

# The resource model

> The three-level resource model, how to read a catalogue back, and how to edit one.

Maat models inventory in three levels, and the API publishes all three:

| Level | What it is | Addressed by |
| :- | :- | :- |
| **Property** | The building or the plot. It has an address, a time zone and building amenities. | `property_id` |
| **Unit** | The physical thing somebody sleeps in. Beds, bathrooms, door times, house rules. | `unit_id` |
| **Listing** | The sellable view of a unit. Title, rate, badges, and whether it is on sale. | `listing_id` |

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.

```bash cURL theme={null}
curl "https://maatstays.wtf/v1/properties?page=1&page_size=100" \
  -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
```

```javascript Node.js theme={null}
const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

for (let page = 1; ; page += 1) {
    const url = new URL('https://maatstays.wtf/v1/properties');
    url.searchParams.set('page', String(page));
    url.searchParams.set('page_size', '100');

    const res = await fetch(url, { headers });
    const body = await res.json();
    if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);

    for (const property of body.items) {
        for (const unit of property.units) {
            for (const listing of unit.listings) {
                reconcile(property.provider_id, listing.listing_id, listing.status);
            }
        }
    }
    if (!body.has_more) break;
}
```

```python Python theme={null}
import os

import requests

headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

page = 1
while True:
    res = requests.get(
        "https://maatstays.wtf/v1/properties",
        headers=headers,
        params={"page": page, "page_size": 100},
        timeout=30,
    )
    body = res.json()
    if res.status_code >= 400:
        raise RuntimeError(f"{body['code']}: {body.get('detail')}")

    for property_ in body["items"]:
        for unit in property_["units"]:
            for listing in unit["listings"]:
                reconcile(property_["provider_id"], listing["listing_id"], listing["status"])
    if not body["has_more"]:
        break
    page += 1
```

Paging is the usual loop — see [Pagination](/guides/pagination). There is no total, and
`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 no `provider_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

```bash cURL theme={null}
curl "https://maatstays.wtf/v1/properties/by-id/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7" \
  -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
```

```javascript Node.js theme={null}
const res = await fetch(
    'https://maatstays.wtf/v1/properties/by-id/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7',
    { headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` } },
);

const property = await res.json();
if (!res.ok) throw new Error(`${property.code}: ${property.detail}`);
console.log(property.property_type, property.units.length);
```

```python Python theme={null}
import os

import requests

res = requests.get(
    "https://maatstays.wtf/v1/properties/by-id/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7",
    headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
    timeout=30,
)
body = res.json()
if res.status_code >= 400:
    raise RuntimeError(f"{body['code']}: {body.get('detail')}")
print(body["property_type"], len(body["units"]))
```

The `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:

| `missing` value | What to do |
| :- | :- |
| `price` | The rate is not above zero. Set one. |
| `amenities` | No amenity we recognise. Check your mapping against `GET /v1/catalog/amenities`. |
| `cover_photo` | No usable cover image. |
| `photos` | Fewer usable photos than we require. Send more. |
| `photos_processing` | **Nothing.** Photos are still being fetched and processed; this clears on its own. |
| `unlisted` | You asked for it. Your choice, restated. |

`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` — every `property_type` a push accepts.
* `GET /v1/catalog/amenities` — every amenity slug, with its section.
* `GET /v1/catalog/house-rules` — every house rule, with its `kind` and `sort_order`.

They publish **slugs and structure, never labels**. Every label on this platform is
resolved in the reader's own language, so an English string here would be a translation
that goes stale — map our slug onto your vocabulary instead.

Two things about house rules are worth reading before you render one:

`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:

```json theme={null}
{
    "listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
    "items": [
        { "night": "2026-09-01", "status": "booked", "blocked_by": null, "external_uid": null },
        { "night": "2026-09-02", "status": "blocked", "blocked_by": "host", "external_uid": null },
        {
            "night": "2026-09-03",
            "status": "blocked",
            "blocked_by": "partner",
            "external_uid": "my-res-55123"
        },
        { "night": "2026-09-04", "status": "available", "blocked_by": null, "external_uid": null }
    ]
}
```

`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:

| Operation | Scope | Changes |
| :- | :- | :- |
| `PUT /v1/properties/{property_id}/address` | `catalog:write` | The whole address, delivery detail included. |
| `PUT /v1/properties/{property_id}/amenities` | `catalog:write` | The amenity set. |
| `PATCH /v1/units/{unit_id}` | `catalog:write` | Occupancy, unit type, door times. |
| `PUT /v1/units/{unit_id}/house-rules` | `catalog:write` | The house-rule answers, and the door times. |
| `PATCH /v1/listings/{listing_id}` | `catalog:write` | Title, description, badges, and whether it is on sale. |
| `PUT /v1/listings/{listing_id}/pricing` | `pricing:write` | The nightly rate and the cleaning fee. |

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 **`PUT`** on 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 omitted `building` or `floor` is 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 **`PATCH`** is **partial**. An absent field is untouched. Send at least one field —
  an empty body is a `400`, not a silent no-op that reads as success.

There is no merge engine on either, and there is nothing in between the two.

Every body is **strict**. A field this API does not declare is a `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](/guides/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](/guides/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.

```json theme={null}
{
    "listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
    "status": "unlisted",
    "publishable": false,
    "missing": ["cover_photo", "photos"]
}
```

`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 answer `501` against a
production one, where nothing is read, nothing is written and nothing is disclosed. A
`501` means your integration is correct — see [Sandbox](/guides/sandbox).


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