curl --fail-with-body -X POST "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks" \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"dates":["2026-09-14","2026-09-15"],"category":"maintenance"}'const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ dates: ['2026-09-14', '2026-09-15'], category: 'maintenance' }),
});
const body = await res.json();
if (res.status === 409) throw new Error(`still on sale: ${body.conflicts.join(', ')}`);
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
console.log(body.blocked, body.already_held);
import os
import requests
res = requests.post(
"https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
json={"dates": ["2026-09-14", "2026-09-15"], "category": "maintenance"},
timeout=30,
)
body = res.json()
if res.status_code == 409:
raise RuntimeError("still on sale: " + ", ".join(body["conflicts"]))
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
print(body["blocked"], body["already_held"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type receipt struct {
Blocked []string `json:"blocked"`
AlreadyHeld []string `json:"already_held"`
Conflicts []string `json:"conflicts"`
}
func main() {
payload, _ := json.Marshal(map[string]any{
"dates": []string{"2026-09-14", "2026-09-15"},
"category": "maintenance",
})
req, _ := http.NewRequest(http.MethodPost, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks", 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 receipt
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
if res.StatusCode == http.StatusConflict {
log.Fatalf("still on sale: %v", body.Conflicts)
}
fmt.Println(body.Blocked, body.AlreadyHeld)
}
{
"listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
"blocked": [
"2026-09-14",
"2026-09-15"
],
"already_held": []
}Close nights with a host block
Close a set of nights on a listing, the way the Maat app’s “Block dates” does. The nights need not be contiguous — this is a set of days, not a range — and up to 366 of them fit in one request.
This is not PUT /v1/blocks/{external_uid}. That contract records a night as
sold in YOUR system, keyed on your own reservation id and releasable only through
that id. This one writes a closure of the kind the Maat app makes — maintenance,
an owner’s stay — with a category, keyed by date. If you are syncing
reservations from your own system, /v1/blocks is your endpoint; this one is for
closing nights by date.
There is no note and no free-text field of any kind. An unrecognised field
is a validation_failed naming it, never a silent drop — the calendar direction
of this API carries no guest data, and the obvious thing anybody writes in a note
is a guest’s name.
Dates must be today or later, in UTC. A property has its own time zone, but a bound derived from it would make the same request behave differently for two listings in one account.
Conflicts refuse the whole range. If any night you name carries a Maat
booking or a live hold, nothing at all is written and the 409 names every
colliding night in conflicts. We never apply the free part of a range: a
partial apply you were told succeeded is the one failure you cannot detect.
A night that is already closed by something else — a calendar feed, or a block you
pushed under your own external_uid — is not a conflict. It is already unavailable, which is
what you asked for, so it comes back under already_held instead: no host block
was written there, and that closure will lift when its own source lifts.
Re-sending the same nights is a no-op and answers the same receipt.
A listing that sells a room type over several rooms answers
409 listing_pooled_unsupported and nothing is written: a host block here closes
one unit, and such a listing’s room is chosen per booking.
Live in a sandbox workspace. Returns 501 against a production one, where no
night is blocked and everything you named stays on sale.
curl --fail-with-body -X POST "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks" \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"dates":["2026-09-14","2026-09-15"],"category":"maintenance"}'const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ dates: ['2026-09-14', '2026-09-15'], category: 'maintenance' }),
});
const body = await res.json();
if (res.status === 409) throw new Error(`still on sale: ${body.conflicts.join(', ')}`);
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
console.log(body.blocked, body.already_held);
import os
import requests
res = requests.post(
"https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
json={"dates": ["2026-09-14", "2026-09-15"], "category": "maintenance"},
timeout=30,
)
body = res.json()
if res.status_code == 409:
raise RuntimeError("still on sale: " + ", ".join(body["conflicts"]))
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
print(body["blocked"], body["already_held"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type receipt struct {
Blocked []string `json:"blocked"`
AlreadyHeld []string `json:"already_held"`
Conflicts []string `json:"conflicts"`
}
func main() {
payload, _ := json.Marshal(map[string]any{
"dates": []string{"2026-09-14", "2026-09-15"},
"category": "maintenance",
})
req, _ := http.NewRequest(http.MethodPost, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks", 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 receipt
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
if res.StatusCode == http.StatusConflict {
log.Fatalf("still on sale: %v", body.Conflicts)
}
fmt.Println(body.Blocked, body.AlreadyHeld)
}
{
"listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
"blocked": [
"2026-09-14",
"2026-09-15"
],
"already_held": []
}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 listing.
Body
The nights to close, and why. There is no note and no other free-text field —
an unrecognised property is refused by name.
The nights to close, YYYY-MM-DD. Need not be contiguous. Each must be a
real calendar date, today or later in UTC.
1 - 366 elements["2026-09-14", "2026-09-15"]
Why the nights are closed. A closed set, and required.
See HostBlockCategory.
maintenance, personal_use, renovation, cleaning, other "maintenance"
Response
The receipt. Every night you named is closed.
The listing whose nights were blocked.
Every night you named that carries a host block now — including ones that were already blocked before this call. The question is "is it closed?", not "did this call close it?".
Nights you named that are closed by something else — a calendar feed, or a
block you pushed under your own external_uid — and so carry no host block
of their own.
Not a failure: the night is unavailable, which is what you asked for. It
is reported because that closure lifts when its own source lifts.
A night that is BOOKED or HELD never appears here. It refuses the whole
request with 409 conflict instead.