curl --fail-with-body "https://maatstays.wtf/v1/calendar/holds?page_size=200" \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN"const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };
for (let page = 1; ; page += 1) {
const url = new URL('https://maatstays.wtf/v1/calendar/holds');
url.searchParams.set('page', String(page));
url.searchParams.set('page_size', '200');
const res = await fetch(url, { headers });
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
for (const hold of body.items) {
console.log(hold.unit_id, hold.first_night, hold.last_night, hold.until);
}
if (!body.has_more) break;
}
import os
import requests
headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}
page = 1
while True:
res = requests.get(
"https://maatstays.wtf/v1/calendar/holds",
headers=headers,
params={"page": page, "page_size": 200},
timeout=30,
)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
for hold in body["items"]:
print(hold["unit_id"], hold["first_night"], hold["last_night"], hold["until"])
if not body["has_more"]:
break
page += 1
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
)
type hold struct {
HoldReference string `json:"hold_reference"`
UnitID string `json:"unit_id"`
FirstNight string `json:"first_night"`
LastNight string `json:"last_night"`
Until string `json:"until"`
}
type page struct {
Items []hold `json:"items"`
HasMore bool `json:"has_more"`
}
func main() {
token := os.Getenv("MAAT_PARTNER_TOKEN")
for pageNum := 1; ; pageNum++ {
query := url.Values{}
query.Set("page", strconv.Itoa(pageNum))
query.Set("page_size", "200")
req, _ := http.NewRequest(http.MethodGet,
"https://maatstays.wtf/v1/calendar/holds?"+query.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
var body page
err = json.NewDecoder(res.Body).Decode(&body)
res.Body.Close()
if err != nil {
log.Fatal(err)
}
if res.StatusCode >= 400 {
log.Fatalf("list failed: %d", res.StatusCode)
}
for _, h := range body.Items {
fmt.Println(h.UnitID, h.FirstNight, h.LastNight, h.Until)
}
if !body.HasMore {
break
}
}
}
{
"items": [
{
"hold_reference": "b3e1a7c4-6d29-4f08-9a51-0c7e2d4b8f36",
"listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
"unit_id": "8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804",
"first_night": "2026-09-12",
"last_night": "2026-09-15",
"until": "2026-08-30T18:56:02Z"
}
],
"has_more": false
}What is held right now
Every hold currently holding nights across your workspace, the one that lapses soonest first.
A hold is what a guest takes when they reach checkout: the nights come off
sale on Maat while the payment completes, before there is a booking. It is the
same object the
calendar.held delivery carries, in
the same shape — this is the reconciliation surface for it. Ask this when your
integration starts up, after an outage, or whenever you want the current answer
rather than the last one you were pushed.
An expired hold is never in the result. The live check is made against the
clock at the moment you ask, so a hold whose until has passed is gone from
this list whether or not its calendar.released has reached you yet. What you
get back is what is held now.
There are no filters, and that is the point. No listing_id, no date
window, no status: the answer is the whole workspace, and any subset would be a
way to be told less while believing you had been told everything — which is the
double-booking window this contract exists to close. Sending an undeclared
parameter is 400 validation_failed naming it, not a silently unfiltered
200.
Paged with page and page_size; has_more tells you whether to ask for the
next one. There is no total. Holds appear and vanish in minutes, so a walk over
several pages is a moving set — treat it as a snapshot, not a transaction.
Nothing about the guest is here. There is no name, no identifier and no amount: somebody may be about to buy these nights, or may walk away in a minute, and what you need is that they cannot be sold twice.
A 403 here carries one of two codes — insufficient_scope (the key lacks
calendar:read) or workspace_scope_mismatch. Branch on code.
Live in a sandbox workspace. Returns 501 against a production one, where
nothing is read and no availability is disclosed.
curl --fail-with-body "https://maatstays.wtf/v1/calendar/holds?page_size=200" \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN"const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };
for (let page = 1; ; page += 1) {
const url = new URL('https://maatstays.wtf/v1/calendar/holds');
url.searchParams.set('page', String(page));
url.searchParams.set('page_size', '200');
const res = await fetch(url, { headers });
const body = await res.json();
if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
for (const hold of body.items) {
console.log(hold.unit_id, hold.first_night, hold.last_night, hold.until);
}
if (!body.has_more) break;
}
import os
import requests
headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}
page = 1
while True:
res = requests.get(
"https://maatstays.wtf/v1/calendar/holds",
headers=headers,
params={"page": page, "page_size": 200},
timeout=30,
)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
for hold in body["items"]:
print(hold["unit_id"], hold["first_night"], hold["last_night"], hold["until"])
if not body["has_more"]:
break
page += 1
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
)
type hold struct {
HoldReference string `json:"hold_reference"`
UnitID string `json:"unit_id"`
FirstNight string `json:"first_night"`
LastNight string `json:"last_night"`
Until string `json:"until"`
}
type page struct {
Items []hold `json:"items"`
HasMore bool `json:"has_more"`
}
func main() {
token := os.Getenv("MAAT_PARTNER_TOKEN")
for pageNum := 1; ; pageNum++ {
query := url.Values{}
query.Set("page", strconv.Itoa(pageNum))
query.Set("page_size", "200")
req, _ := http.NewRequest(http.MethodGet,
"https://maatstays.wtf/v1/calendar/holds?"+query.Encode(), nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
var body page
err = json.NewDecoder(res.Body).Decode(&body)
res.Body.Close()
if err != nil {
log.Fatal(err)
}
if res.StatusCode >= 400 {
log.Fatalf("list failed: %d", res.StatusCode)
}
for _, h := range body.Items {
fmt.Println(h.UnitID, h.FirstNight, h.LastNight, h.Until)
}
if !body.HasMore {
break
}
}
}
{
"items": [
{
"hold_reference": "b3e1a7c4-6d29-4f08-9a51-0c7e2d4b8f36",
"listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
"unit_id": "8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804",
"first_night": "2026-09-12",
"last_night": "2026-09-15",
"until": "2026-08-30T18:56:02Z"
}
],
"has_more": false
}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.
Query Parameters
1-based page number. Defaults to the first page.
x >= 1Rows per page. The same default and the same ceiling on every list in this API.
A page_size above the maximum is refused with validation_failed naming
the parameter — never silently clamped, because a caller who believes they asked
for 500 and received 200 will stop walking a set they have not finished.
1 <= x <= 200