# Step 1 — declare what you are about to upload.
curl --fail-with-body -X POST https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/presign \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"files": [{"content_type": "image/jpeg", "size": 482113}]}'
# Step 2 — POST the bytes to the returned url, sending every returned
# field first and `file` LAST.
curl --fail-with-body -X POST "$UPLOAD_URL" \
-F key="$FIELD_KEY" \
-F policy="$FIELD_POLICY" \
-F x-amz-signature="$FIELD_SIGNATURE" \
-F file=@living-room.jpg
import { readFile } from 'node:fs/promises';
const bytes = await readFile('living-room.jpg');
// Step 1 — declare the file. `size` must be the REAL byte length: the
// form's length range is pinned to it.
const presign = await fetch(
'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/presign',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [{ content_type: 'image/jpeg', size: bytes.byteLength }],
}),
},
);
const body = await presign.json();
if (!presign.ok) throw new Error(`${body.code}: ${body.detail}`);
const [upload] = body.uploads;
// Step 2 — POST the bytes. Every returned field first, in order, then the
// file. Anything after the file part is ignored.
const form = new FormData();
for (const [name, value] of Object.entries(upload.fields)) form.append(name, value);
form.append('file', new Blob([bytes], { type: 'image/jpeg' }), 'living-room.jpg');
const put = await fetch(upload.url, { method: 'POST', body: form });
if (!put.ok) throw new Error(`upload rejected: ${put.status}`);
// Step 3 — the photo is `pending`. Poll GET …/photos until it is `ready`.
console.log(upload.photo_id, body.quota.remaining_today);
import os
from pathlib import Path
import requests
image = Path("living-room.jpg")
data = image.read_bytes()
# Step 1 — declare the file. `size` must be the REAL byte length: the
# form's length range is pinned to it.
presign = requests.post(
"https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/presign",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
json={"files": [{"content_type": "image/jpeg", "size": len(data)}]},
timeout=30,
)
body = presign.json()
if presign.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
upload = body["uploads"][0]
# Step 2 — POST the bytes. Every returned field first, in order, then the
# file. Anything after the file part is ignored.
sent = requests.post(
upload["url"],
data=upload["fields"],
files={"file": (image.name, data, "image/jpeg")},
timeout=120,
)
sent.raise_for_status()
# Step 3 — the photo is `pending`. Poll GET …/photos until it is `ready`.
print(upload["photo_id"], body["quota"]["remaining_today"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
)
type upload struct {
PhotoID string `json:"photo_id"`
URL string `json:"url"`
Fields map[string]string `json:"fields"`
ExpiresAt string `json:"expires_at"`
}
func main() {
data, err := os.ReadFile("living-room.jpg")
if err != nil {
log.Fatal(err)
}
// Step 1 — declare the file. `size` must be the REAL byte length:
// the form's length range is pinned to it.
payload, _ := json.Marshal(map[string]any{
"files": []map[string]any{
{"content_type": "image/jpeg", "size": len(data)},
},
})
req, _ := http.NewRequest(http.MethodPost,
"https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/presign",
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("presign failed: %d", res.StatusCode)
}
var body struct {
Uploads []upload `json:"uploads"`
Quota struct {
RemainingToday int `json:"remaining_today"`
} `json:"quota"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
u := body.Uploads[0]
// Step 2 — POST the bytes. Every returned field first, in order,
// then the file. Anything after the file part is ignored.
var form bytes.Buffer
writer := multipart.NewWriter(&form)
for name, value := range u.Fields {
_ = writer.WriteField(name, value)
}
part, _ := writer.CreateFormFile("file", "living-room.jpg")
if _, err := io.Copy(part, bytes.NewReader(data)); err != nil {
log.Fatal(err)
}
writer.Close()
sent, err := http.Post(u.URL, writer.FormDataContentType(), &form)
if err != nil {
log.Fatal(err)
}
defer sent.Body.Close()
if sent.StatusCode >= 400 {
log.Fatalf("upload rejected: %d", sent.StatusCode)
}
// Step 3 — the photo is `pending`. Poll GET …/photos until it is ready.
fmt.Println(u.PhotoID, body.Quota.RemainingToday)
}
Get upload forms for new photos
Declare the files you are about to upload and receive one signed form each.
Two steps, and the second one does not come back to us. You POST the bytes
straight to the returned url as a multipart form: every field in fields
first, verbatim and in order, then a file part LAST. Anything after file is
ignored. The bytes never pass through this API, which is why the form pins the
destination, the exact Content-Type and a length range around the size you
declared — a file that ships more bytes than it declared is refused by the
storage service after you have already sent them.
Each declared file reserves a photo immediately. It appears in
GET …/photos as pending, and stays there until we have processed and
reviewed it. A successful upload is not a published image: poll the list.
This operation is not idempotent and cannot be made so: there is no dedup
field on the request. A retry mints new photo_ids, spends the daily photo
allowance again, and the abandoned rows occupy the property’s 30-photo cap for
up to forty minutes before being swept to rejected / upload_missing. If a
call times out, list the property’s photos before retrying.
Uploading photos does not put a property on sale. The order is: upload the
photos, wait for them to reach ready, then send
PATCH /v1/listings/{listing_id} with status: "listed" — that operation is
the one that runs the validity gate, and it is the only one that does. Photos
are usually what a missing: ["photos"] was waiting for, but nothing publishes
a property behind your back.
You do not choose where a photo goes. There is no object key, no bucket, no
category and no URL field, in either direction. Position decides the rest:
the first photo on an empty property becomes the cover, everything after it is
a highlight, and PUT …/photos/order is how you change that.
Two ceilings. A property holds at most thirty photos, counting the ones
still uploading — a batch that would cross it is a 400 and reserves nothing.
And each API key may presign 200 photos per UTC day — today’s default, not
contract, and the changelog will say so if it moves. Crossing it is
429 photo_quota_exceeded, which is NOT the hourly rate_limited and does not
clear within the hour. quota.remaining_today comes back on every success so
you can pace an import instead of discovering the wall.
Nothing is reserved on any refusal. The ceilings are checked before a photo exists, so a refused call leaves the property exactly as it was.
A property you create with PUT /properties/{provider_id} is refused with
409 conflict: that push replaces the property’s whole photo set on every call,
so a photo uploaded here would be silently undone by your next one. Send its
photo URLs on the push instead — we fetch them.
Live in a sandbox workspace. Returns 501 against a production one.
# Step 1 — declare what you are about to upload.
curl --fail-with-body -X POST https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/presign \
-H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"files": [{"content_type": "image/jpeg", "size": 482113}]}'
# Step 2 — POST the bytes to the returned url, sending every returned
# field first and `file` LAST.
curl --fail-with-body -X POST "$UPLOAD_URL" \
-F key="$FIELD_KEY" \
-F policy="$FIELD_POLICY" \
-F x-amz-signature="$FIELD_SIGNATURE" \
-F file=@living-room.jpg
import { readFile } from 'node:fs/promises';
const bytes = await readFile('living-room.jpg');
// Step 1 — declare the file. `size` must be the REAL byte length: the
// form's length range is pinned to it.
const presign = await fetch(
'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/presign',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [{ content_type: 'image/jpeg', size: bytes.byteLength }],
}),
},
);
const body = await presign.json();
if (!presign.ok) throw new Error(`${body.code}: ${body.detail}`);
const [upload] = body.uploads;
// Step 2 — POST the bytes. Every returned field first, in order, then the
// file. Anything after the file part is ignored.
const form = new FormData();
for (const [name, value] of Object.entries(upload.fields)) form.append(name, value);
form.append('file', new Blob([bytes], { type: 'image/jpeg' }), 'living-room.jpg');
const put = await fetch(upload.url, { method: 'POST', body: form });
if (!put.ok) throw new Error(`upload rejected: ${put.status}`);
// Step 3 — the photo is `pending`. Poll GET …/photos until it is `ready`.
console.log(upload.photo_id, body.quota.remaining_today);
import os
from pathlib import Path
import requests
image = Path("living-room.jpg")
data = image.read_bytes()
# Step 1 — declare the file. `size` must be the REAL byte length: the
# form's length range is pinned to it.
presign = requests.post(
"https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/presign",
headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
json={"files": [{"content_type": "image/jpeg", "size": len(data)}]},
timeout=30,
)
body = presign.json()
if presign.status_code >= 400:
raise RuntimeError(f"{body['code']}: {body.get('detail')}")
upload = body["uploads"][0]
# Step 2 — POST the bytes. Every returned field first, in order, then the
# file. Anything after the file part is ignored.
sent = requests.post(
upload["url"],
data=upload["fields"],
files={"file": (image.name, data, "image/jpeg")},
timeout=120,
)
sent.raise_for_status()
# Step 3 — the photo is `pending`. Poll GET …/photos until it is `ready`.
print(upload["photo_id"], body["quota"]["remaining_today"])
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
)
type upload struct {
PhotoID string `json:"photo_id"`
URL string `json:"url"`
Fields map[string]string `json:"fields"`
ExpiresAt string `json:"expires_at"`
}
func main() {
data, err := os.ReadFile("living-room.jpg")
if err != nil {
log.Fatal(err)
}
// Step 1 — declare the file. `size` must be the REAL byte length:
// the form's length range is pinned to it.
payload, _ := json.Marshal(map[string]any{
"files": []map[string]any{
{"content_type": "image/jpeg", "size": len(data)},
},
})
req, _ := http.NewRequest(http.MethodPost,
"https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/presign",
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("presign failed: %d", res.StatusCode)
}
var body struct {
Uploads []upload `json:"uploads"`
Quota struct {
RemainingToday int `json:"remaining_today"`
} `json:"quota"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
log.Fatal(err)
}
u := body.Uploads[0]
// Step 2 — POST the bytes. Every returned field first, in order,
// then the file. Anything after the file part is ignored.
var form bytes.Buffer
writer := multipart.NewWriter(&form)
for name, value := range u.Fields {
_ = writer.WriteField(name, value)
}
part, _ := writer.CreateFormFile("file", "living-room.jpg")
if _, err := io.Copy(part, bytes.NewReader(data)); err != nil {
log.Fatal(err)
}
writer.Close()
sent, err := http.Post(u.URL, writer.FormDataContentType(), &form)
if err != nil {
log.Fatal(err)
}
defer sent.Body.Close()
if sent.StatusCode >= 400 {
log.Fatalf("upload rejected: %d", sent.StatusCode)
}
// Step 3 — the photo is `pending`. Poll GET …/photos until it is ready.
fmt.Println(u.PhotoID, body.Quota.RemainingToday)
}
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
One entry per file you intend to upload. The batch is validated whole and refused whole.
1 - 30 elementsShow child attributes
Show child attributes