curl --fail-with-body -X PUT https://maatstays.wtf/v1/properties/my-prop-9001 \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Courtyard flat with the blue door",
"property_type": "apartment",
"address": {
"line1": "14 Example Street",
"city": "Alexandria",
"country": "EG",
"latitude": 31.2001,
"longitude": 29.9187
},
"occupancy": { "max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1 },
"base_rate": { "value": "1250.00", "currency": "EGP" },
"amenities": ["wifi", "air-conditioning"],
"photos": [
{ "url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0 }
],
"status": "listed"
}'const property = {
name: 'Courtyard flat with the blue door',
property_type: 'apartment',
address: {
line1: '14 Example Street',
city: 'Alexandria',
country: 'EG',
latitude: 31.2001,
longitude: 29.9187,
},
occupancy: { max_guests: 4, bedrooms: 2, beds: 3, bathrooms: 1 },
base_rate: { value: '1250.00', currency: 'EGP' },
amenities: ['wifi', 'air-conditioning'],
photos: [
{ url: 'https://images.example-partner.invalid/p/9001/1.jpg', position: 0 },
],
status: 'listed',
};
const res = await fetch(
'https://maatstays.wtf/v1/properties/my-prop-9001',
{
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(property),
},
);
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
// photos_processing clears on its own — wait, do not re-send.
console.log(body.publishable, body.missing);
import os
import requests
property_payload = {
"name": "Courtyard flat with the blue door",
"property_type": "apartment",
"address": {
"line1": "14 Example Street",
"city": "Alexandria",
"country": "EG",
"latitude": 31.2001,
"longitude": 29.9187,
},
"occupancy": {"max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1},
"base_rate": {"value": "1250.00", "currency": "EGP"},
"amenities": ["wifi", "air-conditioning"],
"photos": [
{"url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0}
],
"status": "listed",
}
res = requests.put(
"https://maatstays.wtf/v1/properties/my-prop-9001",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
json=property_payload,
timeout=30,
)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
# photos_processing clears on its own — wait, do not re-send.
print(body["publishable"], body["missing"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]any{
"name": "Courtyard flat with the blue door",
"property_type": "apartment",
"address": map[string]any{
"line1": "14 Example Street",
"city": "Alexandria",
"country": "EG",
"latitude": 31.2001,
"longitude": 29.9187,
},
"occupancy": map[string]any{
"max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1,
},
"base_rate": map[string]string{"value": "1250.00", "currency": "EGP"},
"amenities": []string{"wifi", "air-conditioning"},
"photos": []map[string]any{
{"url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0},
},
"status": "listed",
})
req, _ := http.NewRequest(http.MethodPut,
"https://maatstays.wtf/v1/properties/my-prop-9001",
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()
var body map[string]any
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
if res.StatusCode >= 400 {
log.Fatalf("%v: %v", body["code"], body["detail"])
}
// photos_processing clears on its own — wait, do not re-send.
fmt.Println(body["publishable"], body["missing"])
}
{
"provider_id": "my-prop-9001",
"result": "updated",
"property_id": "3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7",
"unit_id": "8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804",
"listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
"status": "listed",
"publishable": true,
"missing": [],
"unknown_amenities": [],
"photos_rejected": []
}Create or replace a property
Creates the property if we have not seen this provider_id from you before, and
replaces it otherwise. Your provider_id is yours: it is unique within your
integration and never across the platform.
Replace, not merge. The payload you send is the whole property. An amenity you omit is removed; a photo you omit is dropped. This mirrors how listings are edited everywhere else on Maat, and it is the only version of “update” in which “remove this amenity” is expressible.
A property that cannot yet be sold — no price, no photos, no amenities — is
accepted and held in an incomplete state rather than published at a placeholder
price. publishable and missing tell you exactly what it is waiting for, and
listing_id stays null until it can actually be shown to a guest.
property_id and unit_id come back on every push, publishable or not, and
they are the handles the targeted edits take — the address, the amenity set, the
unit’s occupancy and its house rules. Store them beside your own provider_id
and you never have to look one up.
Photos are fetched asynchronously, so the FIRST push of a complete property
comes back publishable: false with photos_processing. That is not a failure
and there is nothing to do: we fetch each URL, process it and publish the
property on its own. Anything we could not turn into a photo is named in
photos_rejected. Sending the same URL again is free — an unchanged URL is never
re-fetched, so a nightly catalogue sync costs nothing for the photos that did not
change. Change the bytes behind a URL and publish it at a new URL.
Send only the address fields listed here. Delivery detail (building, floor, apartment number, directions) is not part of this contract in either direction.
Live in a sandbox workspace. Returns 501 against a production one, where
the payload is authenticated and validated in full and no property is written. A
sandbox token creates real properties, excluded from every public surface —
search, the map, the guest feed.
curl --fail-with-body -X PUT https://maatstays.wtf/v1/properties/my-prop-9001 \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Courtyard flat with the blue door",
"property_type": "apartment",
"address": {
"line1": "14 Example Street",
"city": "Alexandria",
"country": "EG",
"latitude": 31.2001,
"longitude": 29.9187
},
"occupancy": { "max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1 },
"base_rate": { "value": "1250.00", "currency": "EGP" },
"amenities": ["wifi", "air-conditioning"],
"photos": [
{ "url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0 }
],
"status": "listed"
}'const property = {
name: 'Courtyard flat with the blue door',
property_type: 'apartment',
address: {
line1: '14 Example Street',
city: 'Alexandria',
country: 'EG',
latitude: 31.2001,
longitude: 29.9187,
},
occupancy: { max_guests: 4, bedrooms: 2, beds: 3, bathrooms: 1 },
base_rate: { value: '1250.00', currency: 'EGP' },
amenities: ['wifi', 'air-conditioning'],
photos: [
{ url: 'https://images.example-partner.invalid/p/9001/1.jpg', position: 0 },
],
status: 'listed',
};
const res = await fetch(
'https://maatstays.wtf/v1/properties/my-prop-9001',
{
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(property),
},
);
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
// photos_processing clears on its own — wait, do not re-send.
console.log(body.publishable, body.missing);
import os
import requests
property_payload = {
"name": "Courtyard flat with the blue door",
"property_type": "apartment",
"address": {
"line1": "14 Example Street",
"city": "Alexandria",
"country": "EG",
"latitude": 31.2001,
"longitude": 29.9187,
},
"occupancy": {"max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1},
"base_rate": {"value": "1250.00", "currency": "EGP"},
"amenities": ["wifi", "air-conditioning"],
"photos": [
{"url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0}
],
"status": "listed",
}
res = requests.put(
"https://maatstays.wtf/v1/properties/my-prop-9001",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
json=property_payload,
timeout=30,
)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
# photos_processing clears on its own — wait, do not re-send.
print(body["publishable"], body["missing"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]any{
"name": "Courtyard flat with the blue door",
"property_type": "apartment",
"address": map[string]any{
"line1": "14 Example Street",
"city": "Alexandria",
"country": "EG",
"latitude": 31.2001,
"longitude": 29.9187,
},
"occupancy": map[string]any{
"max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1,
},
"base_rate": map[string]string{"value": "1250.00", "currency": "EGP"},
"amenities": []string{"wifi", "air-conditioning"},
"photos": []map[string]any{
{"url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0},
},
"status": "listed",
})
req, _ := http.NewRequest(http.MethodPut,
"https://maatstays.wtf/v1/properties/my-prop-9001",
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()
var body map[string]any
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
if res.StatusCode >= 400 {
log.Fatalf("%v: %v", body["code"], body["detail"])
}
// photos_processing clears on its own — wait, do not re-send.
fmt.Println(body["publishable"], body["missing"])
}
{
"provider_id": "my-prop-9001",
"result": "updated",
"property_id": "3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7",
"unit_id": "8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804",
"listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
"status": "listed",
"publishable": true,
"missing": [],
"unknown_amenities": [],
"photos_rejected": []
}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
Your own identifier for the property. Unique to your workspace — it never collides with another workspace's.
1 - 128Body
The whole property. Anything you omit is removed — this replaces rather than merges.
The property's title, as guests will see it.
1 - 120A slug from GET /catalog/property-types. An unknown slug is a 400, never a silent default.
60^[a-z0-9][a-z0-9_-]*$"apartment"
Where the property is, as your system pushes it. Delivery detail — building,
floor, apartment number, directions — is not part of the push: write it with
PUT /properties/{property_id}/address and read it back on
PropertyReadAddress.
Show child attributes
Show child attributes
How many people the property sleeps, and in what.
Show child attributes
Show child attributes
An amount and its currency, always together.
value is a decimal string, not a number. 1250.10 has no exact IEEE-754
representation, and a JSON parser that rounds it costs a cent on every booking.
Parse it with a decimal type, not a float.
There is no bare amount anywhere in this API and no currency field sitting beside one. Two fields can be read separately; one object cannot.
Show child attributes
Show child attributes
Free text about the property. Never about a guest — see the data boundary guide.
4000Amenity slugs from GET /catalog/amenities. Replaced whole on every push: a slug you omit is removed.
6060^[a-z0-9][a-z0-9_-]*$Photos by URL, in the order you want them shown. Replaced whole on every push.
30Show child attributes
Show child attributes
Local time, 24-hour.
^([01]\d|2[0-3]):[0-5]\d$"15:00"
Local check-out time, 24-hour HH:MM. Omitted means the platform default, 12:00.
^([01]\d|2[0-3]):[0-5]\d$"12:00"
Whether you want the property sellable on Maat right now.
See ListingStatus.
listed, unlisted Response
The property was created or replaced. Sandbox only — a production token
receives 501.
The identifier you addressed, echoed back so a queued write can be matched to its response.
Whether this push created the property or replaced one you had already pushed.
See WriteResult.
created, updated Maat's own identifier for the property. Always present, unlike
listing_id: the row exists from the moment this push commits, whether
or not the property is publishable.
It is the handle every sub-resource write takes —
PUT /properties/{property_id}/address and …/amenities — so a caller
who created a property by their own reference never has to go and look
ours up before editing it.
Maat's identifier for the physical unit this push created or replaced. Always present, for the same reason.
A property push writes exactly one unit today, and this is the handle
PATCH /units/{unit_id} and PUT /units/{unit_id}/house-rules take.
Maat's listing identifier, once the property is complete enough to have
one. null while it is not — never a placeholder.
Deliberately NOT unconditional like the two ids above, even though the row exists just as early: a listing addresses something a guest can reach, and handing it back before the property is publishable would read as "we are live". The other two address our own inventory and carry no such claim.
Whether the listing is on sale right now. A push that is missing something publication needs comes back unlisted.
See ListingStatus.
listed, unlisted Whether the property can be shown to guests as it stands.
Machine-readable reasons publishable is false. Empty when it is true.
photos_processing is not a deficiency — your images are being fetched
and processed, and it clears on its own. It is separate from photos
precisely so you can tell "wait" from "send us more".
unlisted restates your own status, and it is reported alongside any
real deficiency rather than instead of it, so you know whether the
property is otherwise complete before you re-list it.
See MissingReason.
Why a listing is not publishable. A closed vocabulary.
price, amenities, cover_photo, photos, photos_processing, unlisted ["cover_photo", "photos_processing"]
Amenity slugs we do not recognise, echoed back rather than silently dropped. They are not linked to the property. One unmapped slug does not fail the push — an otherwise-good property should not be blocked while you map your vocabulary onto ours.
["wi-fi"]
Photo URLs that did not become photos. Empty when every one did.
missing: ["photos"] on its own is not actionable across a
thirty-photo push, so each failure names your own URL and a reason.
The reason vocabulary is deliberately coarse: a refused URL never says
what the name resolved to.
Show child attributes
Show child attributes