Skip to main content
There are two ways a photo reaches a property, and which one to use depends on how the property got here. Both land in the same place and are processed the same way. Neither lets you write to a Maat storage bucket of your choosing, and neither accepts an object key.

URL ingestion, on the push

Send photos: [{ url, position }] with a property. We fetch each one over https, from a public host, with no redirects, and put it through the same pipeline a photo uploaded in the Maat apps goes through. A URL that has not changed since your last push is not re-fetched, so a nightly full sync costs nothing extra. A URL that could not become a photo comes back in photos_rejected[] with a reason — see the vocabulary below.

Presigned upload, in three steps

Needs media:write. You never hold credentials that write into our storage: each form is signed for one file, one size and one content type, and expires. A property you push with PUT /v1/properties/{provider_id} is off limits to these three verbs: 409 conflict, naming the provider_id you pushed it under. The push replaces the property’s whole photo set on every call and re-scores it from its own list, so a photo uploaded here would be undone by your next push — and the property taken off sale on the way past. Change the photo URLs on the push instead. Reading them is still allowed: debugging a sync is exactly when you need to.
  1. Declare the files. POST …/photos/presign with { files: [{ content_type, size }] }. size must be the real byte length. You get back one form per file, each with a photo_id, a url, a fields object and an expires_at.
  2. POST the bytes to that url as a multipart form: every entry of fields first, verbatim and in order, then a file part last. Anything after file is ignored. 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 there, after you have already sent them.
  3. Poll GET …/photos. A successful upload is not a published image: each photo is pending until we have processed and reviewed it.
There is no category and no url field on the presign, and there never will be. Position decides the rest: the first photo on an empty property becomes the cover, everything after it is a highlight.

Reading, arranging and removing

GET /v1/properties/{property_id}/photos returns every photo in the order guests see it:
url is the published image and is null in every other state — including for a photo our automated review declined, which reads as rejected rather than ready because no guest will ever be shown it. PUT …/photos/order takes the complete set of ready photos, each exactly once, and index 0 becomes the cover. A partial list is refused rather than half-applied: the positions of everything you did not name would otherwise have to be invented by us. DELETE …/photos/{photo_id} removes one and closes the gap, so positions stay dense. If you delete the cover, position 0 of what remains becomes the cover. It answers 204 whether or not the photo was still there, so a retry is safe. is_cover is true for at most one photo, and when true it is the one at position 0. It can be false for every photo: a property whose cover was deleted, or whose cover our review declined, has no cover until one is chosen again — a reorder is how you name one. It can also be true on a photo that is still pending, where it names the cover the property is going to have: url is null, guests see no cover, and the listing is not publishable (cover_photo in missing) until that photo settles.

What a rejection means

One closed vocabulary covers both routes. Branch on it; do not read the prose.

The two ceilings

A property holds at most thirty photos, counting the ones still uploading. And each key may presign 200 photos per UTC day — today’s default rather than contract; quota.remaining_today comes back on every successful presign so you can pace an import against the live number. See Rate limits — the daily one is photo_quota_exceeded, which is not the hourly rate_limited and does not clear within the hour. At 200 a day, a first catalogue import of a few hundred properties is a job that runs over several days rather than one afternoon. Plan the import around that rather than discovering it on day one. Nothing is reserved on any refusal.

Photos and the validity gate

A property needs a cover photo and a minimum photo count to be publishable, so photos are usually what a missing: ["photos"] or missing: ["photos_processing"] is waiting for. No photo operation changes whether a property is on sale, in either direction. The sequence is three steps and the third one is yours:
  1. upload — presign, PUT the bytes;
  2. settle — we process and review each photo; it reaches ready on its own, minutes later, and nothing about your request is still running by then;
  3. list — PATCH /v1/listings/{listing_id} with status: "listed", which runs the validity gate and publishes if it passes.
Step 3 is separate because step 2 is asynchronous. A photo becomes usable inside our pipeline, not inside your call, so “uploading published it” could only ever be true for the uploads that happened to finish first — and false, silently, for the rest. One explicit operation that re-derives the facts at the moment you ask is the version that always tells the truth. Deleting a photo does not take a listing off sale either, even if the property drops below the photo floor: that would be a change you did not ask for. GET /v1/properties/by-id/{property_id} reports what the property is missing at any time.

Current status

Every operation on this page is live in a sandbox workspace and answers 501 against a production one, where nothing is uploaded, fetched or removed. A 501 means your integration is correct — see Sandbox.