curl --fail-with-body -X PUT https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"amenities": ["wifi", "air-conditioning", "washing-machine"]}'const res = await fetch(
'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities',
{
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amenities: ['wifi', 'air-conditioning', 'washing-machine'],
}),
},
);
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
// `amenities` is what is now stored; anything we could not map is echoed.
console.log(body.amenities, body.unknown_amenities);
import os
import requests
res = requests.put(
"https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
json={"amenities": ["wifi", "air-conditioning", "washing-machine"]},
timeout=30,
)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
# `amenities` is what is now stored; anything we could not map is echoed.
print(body["amenities"], body["unknown_amenities"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type amenitySet struct {
Amenities []string `json:"amenities"`
UnknownAmenities []string `json:"unknown_amenities"`
}
func main() {
payload, _ := json.Marshal(map[string]any{
"amenities": []string{"wifi", "air-conditioning", "washing-machine"},
})
req, _ := http.NewRequest(http.MethodPut,
"https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities",
bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
log.Fatalf("amenity write failed: %d", res.StatusCode)
}
var body amenitySet
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
fmt.Println(body.Amenities, body.UnknownAmenities)
}
{
"amenities": [
"wifi",
"air-conditioning",
"washing-machine"
],
"unknown_amenities": []
}Replace a property's amenities
Replaces the amenity set of the property’s single unit — the set Maat’s own validity gate counts, and the same set a whole-property push replaces.
Replace, not merge. A slug you omit is removed. Send at least one and at most
sixty, with no repeats. The vocabulary is GET /catalog/amenities; map yours onto
it once at the start of a sync rather than discovering it one property at a
time.
An unknown slug is echoed, not refused. amenities in the response is the set
now stored — only what resolved — and unknown_amenities is the rest. One
unmapped slug does not block an otherwise good property while you finish your
mapping.
If none of them resolved, the whole call is a 400 and nothing is written.
Applying that result would clear the set, and a property with no amenity fails
the validity gate — so an unmapped vocabulary would take a live listing off sale
as a side effect of an edit nobody meant that way.
The body is strict. An undeclared field is a 400 validation_failed naming it,
never a silent drop, and a stray query string is refused the same way.
A property with more than one unit, or more than one listing, is refused with
409 property_not_simple; a listing that is not a stay with
409 inventory_not_supported. Nothing is written either way.
Live in a sandbox workspace. Returns 501 against a production one.
curl --fail-with-body -X PUT https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"amenities": ["wifi", "air-conditioning", "washing-machine"]}'const res = await fetch(
'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities',
{
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amenities: ['wifi', 'air-conditioning', 'washing-machine'],
}),
},
);
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
// `amenities` is what is now stored; anything we could not map is echoed.
console.log(body.amenities, body.unknown_amenities);
import os
import requests
res = requests.put(
"https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
json={"amenities": ["wifi", "air-conditioning", "washing-machine"]},
timeout=30,
)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
# `amenities` is what is now stored; anything we could not map is echoed.
print(body["amenities"], body["unknown_amenities"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type amenitySet struct {
Amenities []string `json:"amenities"`
UnknownAmenities []string `json:"unknown_amenities"`
}
func main() {
payload, _ := json.Marshal(map[string]any{
"amenities": []string{"wifi", "air-conditioning", "washing-machine"},
})
req, _ := http.NewRequest(http.MethodPut,
"https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities",
bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
log.Fatalf("amenity write failed: %d", res.StatusCode)
}
var body amenitySet
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
fmt.Println(body.Amenities, body.UnknownAmenities)
}
{
"amenities": [
"wifi",
"air-conditioning",
"washing-machine"
],
"unknown_amenities": []
}Authorizations
A short-lived partner token, obtained by exchanging your API key at
POST /token. Send it as Authorization: Bearer <access_token>.
Tokens live for fifteen minutes by default and never for more than an hour. Mint one per batch of work, not one per request.
The token carries the workspace and the environment its key was issued for. You cannot change either by asking: the exchange ignores everything in the request body for exactly that reason.
It also carries the key's scopes (scope, a list). Every operation names a
scope as x-maat-scope, and a token whose key lacks it is refused with
403 insufficient_scope, naming the scope. Scopes are fixed when a key is
issued and are re-read from the key on every request, so a key narrowed today is
narrowed on its next call — the token's scope is what the key held at exchange
time, for your own reading, never what authorizes a request.
x-maat-scope is the floor, and on the quick-action operations it is not the
whole requirement. PUT and DELETE /listings/{listing_id}/quick-actions/{template_key}
carry rules:write, and additionally require pricing:write when
template_key begins pricing. — the capability depends on the template in
the path, because a cleaning fee changes what a guest pays and a minimum stay
does not. The refusal is the same 403 insufficient_scope, naming
pricing:write. The two READS apply the same requirement without erroring:
GET /listings/{listing_id}/quick-actions still lists the pricing.* rows but
withholds their applied state, and GET /listings/{listing_id}/rules omits the
pricing rules entirely and reports pricing_withheld in warnings. These four
are the only operations in this document whose requirement is not exactly their
x-maat-scope, and each of them says so machine-readably:
x-maat-scope-additional lists the second scope it can require and
x-maat-scope-additional-when says what happens without it.
Never send it as a cookie or in a query string, and never store it — store the API key, mint tokens from it.
Path Parameters
Maat's identifier for the property, as returned by GET /properties. Not your
own provider_id — the write endpoint is the one keyed on that, which is why
this read carries a by-id segment.
Body
The amenity set, replaced. A slug you omit is removed.
Amenity slugs, from GET /catalog/amenities. At least one: Maat's
validity gate requires one, so a replace to nothing would take a live
listing off sale as a side effect of an edit nobody meant that way.
1 - 60 elements60^[a-z0-9][a-z0-9_-]*$[
"wifi",
"air-conditioning",
"washing-machine"
]
Response
The amenity set now stored, and anything that did not resolve.
What landed, and what did not.
The set now stored — the slugs that RESOLVED, never an echo of what you sent.
[
"wifi",
"air-conditioning",
"washing-machine"
]
Slugs we do not recognise, echoed rather than silently dropped so you can
see which entries of your own vocabulary did not land. They are not
stored. If NONE of the slugs you sent resolved, the call is a 400
instead and nothing is written — applying that result would clear the
set.
["wi-fi"]