> ## Documentation Index
> Fetch the complete documentation index at: https://developer.maatstays.wtf/llms.txt
> Use this file to discover all available pages before exploring further.

# Photos

> The two ways a photo reaches a property, what a photo is doing while you wait, and how to order or remove one.

There are **two ways a photo reaches a property**, and which one to use depends on how the
property got here.

| The property | You send | Endpoint |
| :- | :- | :- |
| One you push whole from your own system | Photo **URLs**, on the property push. We fetch them. | `PUT /v1/properties/{provider_id}` |
| Any other — created in the Maat app, say | The **bytes**, through a signed upload form. | `POST /v1/properties/{property_id}/photos/presign` |

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:

```json theme={null}
{
    "items": [
        {
            "photo_id": "0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94",
            "position": 0,
            "is_cover": true,
            "state": "ready",
            "rejection_reason": null,
            "url": "https://cdn.maatstays.wtf/c/0f2b9c81.webp"
        },
        {
            "photo_id": "9a4e77b0-1c2d-4e5f-8a91-3b6c0d7e2f45",
            "position": 1,
            "is_cover": false,
            "state": "pending",
            "rejection_reason": null,
            "url": null
        }
    ]
}
```

`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.

| `rejection_reason` | What happened | What to do |
| :- | :- | :- |
| `url_refused` | We will not fetch that URL. URL push only. | Serve it over `https` from a public host, no redirects. |
| `fetch_failed` | Your server did not answer, timed out, redirected or returned non-2xx. | Retryable. Push again. |
| `unsupported_type` | Not `image/jpeg`, `image/png` or `image/webp`. | Transcode. `image/heic` is not accepted. |
| `too_large` | Over the size ceiling. | Re-encode smaller. |
| `processing_failed` | We had the bytes and could not publish the image — including a photo our automated review declined. | Send a different photo. |
| `upload_missing` | You presigned and the bytes never arrived. Presign only. | Presign again and complete the upload. |

### 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](/guides/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](/guides/sandbox).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.