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

# 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_id`s, 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.**




## OpenAPI

````yaml /openapi/openapi.yaml post /properties/{property_id}/photos/presign
openapi: 3.1.0
info:
  title: Maat Stays Partner API
  version: '2026-10-16'
  summary: Manage your Maat listings from your own system.
  contact:
    name: Maat Stays developer platform
    url: https://developer.maatstays.wtf
  description: >
    The Partner API lets a host workspace — a host, or a property company with
    its own

    system, website or app — manage its own Maat listings, calendars, rates,
    bookings and

    reviews from that system. Every key belongs to one workspace, is issued from
    that

    workspace's own Maat account, and reaches that workspace's inventory and
    nothing else.


    ```

    Your system  ──  properties, photos, calendar blocks, rates, replies  ──▶ 
    Maat Stays

    Your system  ◀──  booking, calendar, listing and review notifications  ── 
    Maat Stays

    ```


    Inbound, you push **what you manage**: the properties, their photos and
    rates, and the

    nights that are already taken elsewhere — keyed by your own system's
    identifiers where

    you have them. Outbound, we push **what happened here**: a guest booked one
    of your

    listings on Maat, a booking was cancelled, a night was held, a review was
    written.


    Nothing about a guest travels inbound, so a calendar block says "these
    nights are gone"

    and nothing else. Outbound carries no guest identity either: a booking event
    tells you

    what is happening on your inventory, never who it is happening to. The only
    guest

    identity any read in this document returns is the shortened public reviewer
    label on

    `GET /listings/{listing_id}/reviews` (`"Sarah M."`), the same string the
    public listing

    page shows. See the data boundary guide at

    https://developer.maatstays.wtf/guides/data-boundary.


    # Contract status — read this before you build


    **Every operation states its own availability.** Machine-readably as

    `x-maat-availability` on the operation, and as one sentence in its
    description. Against a

    **production** workspace every contract call authenticates, rate-limits and
    validates in

    full, then returns `501 Not Implemented` with a problem document whose
    `code` is

    `not_enabled`: nothing is written, nothing is read, and nothing is
    delivered. The

    credential exchange (`POST /token`) is the one operation that also answers a
    production

    workspace, because it is how you find out which kind you hold — read
    `sandbox` in its

    response.


    `x-maat-availability` names the workspace kind an operation does real work
    on today. Its

    values are `sandbox`, `production` and `none`; every contract operation
    carries

    `sandbox`, and nothing carries `production`. The outbound half — the
    `webhooks` section —

    has no status code to annotate, so each delivery says which workspace kind
    it is

    delivered on in its own description instead.


    The split is decided by the workspace your key was issued for, not by a flag
    you can send.


    A fabricated `200` would tell you a calendar block was applied — so you
    would stop holding

    those nights on your side, and the next guest would book a room that is
    already occupied.

    That is not a mistake you could detect from our response.


    What is live everywhere: authentication, authorization, the workspace
    boundary, rate

    limiting and **every schema in this document**. Send us a malformed payload
    today and you

    get the same `validation_failed` you will get the day the contracts are
    enabled. Build

    against it now; nothing will change shape underneath you.


    One thing you can only exercise on a sandbox workspace is a **conflict**. If
    nights you

    push are already booked or held on Maat, we refuse the whole range with
    `409` and name the

    colliding dates — we never apply part of a block, because a partial apply
    you were told

    succeeded is the one failure you could not detect.


    # Authentication


    You hold a long-lived API key (`maat_partner_…`) and exchange it for a
    short-lived bearer

    token. The key never reaches a contract endpoint; the token never lives
    longer than an

    hour.


    ```bash

    # 1. Exchange the key for a token. The body is empty on purpose:

    #    the workspace, the environment and your identity all come from

    #    the key itself, never from the request.

    curl --fail-with-body -X POST https://maatstays.wtf/v1/token \
      -H "Authorization: Bearer $MAAT_PARTNER_KEY"

    # → 200

    # {

    #   "access_token": "eyJhbGciOiJIUzI1NiIs…",

    #   "token_type": "Bearer",

    #   "expires_in": 900,

    #   "workspace_id": "3f6b0f2e-1c3a-4d5e-9a70-8b2c4d6e0f11",

    #   "sandbox": true,

    #   "scope": ["partner:token.issue", "catalog:write", "calendar:read",
    "calendar:write", "webhooks:manage"]

    # }


    # 2. Call a contract with the token.

    curl --fail-with-body -X PUT https://maatstays.wtf/v1/blocks/my-res-55123 \
      -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs…" \
      -H "Content-Type: application/json" \
      -d '{"provider_id":"my-prop-9001","check_in":"2026-09-01","check_out":"2026-09-05"}'
    ```


    **Every response in this API is a problem document**, the token exchange
    included. It

    answers one indistinguishable `401 unauthorized` for an unknown key, a
    revoked key and a

    wrong secret, and `503 service_unavailable` when token signing is not
    configured on our

    side — in the same RFC 7807 shape as everything else.


    Revoking a key kills its outstanding tokens on their **next request**. We
    re-check the key

    on every call rather than waiting for a token to expire.


    # Sandbox


    Every key is issued for exactly one Maat workspace, and that workspace is
    either a

    **sandbox** one or a **production** one. Those two are what this document
    calls

    *tenancies*: a tenancy is not a server, a region or a hostname — there is
    one host — it is

    which kind of workspace your key was issued for. A sandbox workspace is a
    real tenancy

    with real endpoints and real create-then-read loops, and everything in it is
    kept off

    every public surface: it does not appear in search, on the map, in the guest
    feed, on a

    public listing page, or in any notification.


    The environment is not a parameter and there is no header that changes it.
    Your token

    carries the environment it was issued for, we compare it against the
    workspace on every

    request, and a disagreement in either direction is a
    `workspace_scope_mismatch`.


    A sandbox booking that ends in a real charge is not available: sandbox
    workspaces do not

    reach a payment provider at all, and a hold on one is refused rather than
    faked.


    # Idempotency


    The two inbound contracts are `PUT` to a URL that contains **your**
    identifier — your

    `provider_id` for a property, your `external_uid` for a block. Retrying is
    safe by

    construction; there is no idempotency header to remember and no request-body
    field that

    can be forgotten.


    Your identifiers are unique to your workspace. Two workspaces can use the
    same

    `provider_id` for different properties and neither will ever see the
    other's.


    **One operation is not safe to retry blindly**, and it is the only one:

    `POST /properties/{property_id}/photos/presign` carries no identifier of
    yours, so a

    repeat mints a second set of `photo_id`s and spends the daily photo
    allowance again. Its

    own description says what to do when a response is lost. Everything else
    here either

    writes to a URL you chose, or is a `GET` or a `DELETE` whose second call is
    a no-op.


    # Conventions


    **Paths in this reference are relative to the server URL**, which already
    carries `/v1`.

    `PUT /blocks/{external_uid}` on an operation page is `PUT
    https://maatstays.wtf/v1/blocks/{external_uid}`

    on the wire. The guides write the full path.


    **Every field and every parameter is snake_case**, every `operationId` is
    lowerCamel, and

    every object refuses a field it does not declare (`additionalProperties:
    false`), in both

    directions. A typo'd field is a `400`, never a silently ignored one.


    **`operationId` says what the call does to the resource**, and the verb
    comes from a

    closed list:


    | Verb | When |

    | :--- | :--- |

    | `get` | one resource |

    | `list` | a collection |

    | `upsert` | a `PUT` to a URL that carries **your own** identifier — it
    creates or replaces |

    | `replace` | a `PUT` that replaces a Maat-keyed resource, or one facet of
    it, whole |

    | `patch` | a `PATCH` that merges; an absent member is untouched |

    | `delete` | the thing addressed ceases to exist |

    | `release` | you give up a claim you took, and the thing itself remains |


    Eleven operations do something that is none of those, and each keeps its own
    verb:

    `registerWebhookEndpoint` (it runs a signed challenge and activates only on
    the echo),

    `rotateWebhookSecret`, `redriveWebhookDelivery`, `issuePartnerToken`,
    `presignPropertyPhotos`, `replyToReview`, `confirmBooking`,

    `rejectBooking`, `blockListingNights`, `applyQuickAction` and
    `takeListingPricingMode`.

    Nothing else invents a verb.


    **A request schema with no properties is not a mistake.**
    `BookingActionRequest`,

    `RotateWebhookSecretRequest` and `RedriveWebhookDeliveryRequest` exist so
    that the

    operation's shape is complete and its refusal of unknown fields is
    published. Their

    bodies are optional: send `{}` or send nothing.


    # Errors


    Every response in this API uses `application/problem+json`

    ([RFC 7807](https://www.rfc-editor.org/rfc/rfc7807)):


    ```json

    {
      "type": "https://developer.maatstays.wtf/errors/not-enabled",
      "title": "Not implemented",
      "status": 501,
      "detail": "The calendar-blocks contract is published but not yet enabled…",
      "instance": "urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33",
      "code": "not_enabled"
    }

    ```


    **Branch on `code`, never on `type` or on the wording of `detail`.** `code`
    is the

    contract; the rest is documentation. `code` is snake_case; `type` is derived
    from it by

    replacing underscores with hyphens (`not_enabled` → `/errors/not-enabled`),
    so the two

    never carry different vocabularies — but only one of them is the thing to
    switch on.


    | `code`                     | Status | What it
    means                                                        |

    | -------------------------- | ------ |
    -------------------------------------------------------------------- |

    | `validation_failed`        | 400    | The payload does not match this
    document. `errors[]` names the fields. |

    | `unauthorized`             | 401    | No usable token: missing, malformed,
    expired, or its key was revoked.  |

    | `workspace_scope_mismatch` | 403    | The token's environment does not
    match the workspace it addresses.     |

    | `insufficient_scope`       | 403    | The key behind the token lacks this
    operation's scope. `detail` names it. |

    | `property_not_found`       | 404    | No such property in the workspace
    your key addresses.                  |

    | `unit_not_found`           | 404    | No such unit in the workspace your
    key addresses.                      |

    | `listing_not_found`        | 404    | No such listing in the workspace
    your key addresses.                   |

    | `booking_not_found`        | 404    | No such booking in the workspace
    your key addresses. Never says which. |

    | `review_not_found`         | 404    | No such review in the workspace your
    key addresses. Never says which.  |

    | `delivery_not_found`       | 404    | No such delivery — for you. Never
    says which.                          |

    | `photo_not_found`          | 404    | No such photo on that property. One
    answer covers a photo that does not exist and one that belongs to a
    different property. |

    | `conflict`                 | 409    | The nights collide with a Maat
    booking or hold — or, on a different operation, the thing you addressed is
    not in a state that allows it. `detail` says which. |

    | `property_not_simple`      | 409    | The property is not one unit with
    one listing, so a whole-property edit has no single target. |

    | `listing_pooled_unsupported` | 409  | The listing sells a room type over
    several rooms, so a calendar block has no single unit to close. |

    | `inventory_not_supported`  | 409    | The listing is an experience or a
    service. This contract manages stays.  |

    | `pricing_mode_conflict`    | 409    | The listing's price is set by
    another source, so the write would never apply. `detail` names it. |

    | `rate_limited`             | 429    | Too many requests for this key. See
    `Retry-After`.                     |

    | `photo_quota_exceeded`     | 429    | This key has presigned its whole
    daily photo allowance. No `Retry-After` — resume after midnight UTC. |

    | `test_event_budget_exhausted` | 429 | This credential's test-event
    allowance for the trailing 24 hours is spent. See `Retry-After`. Only `POST
    /webhooks/endpoint/test` produces it. |

    | `internal_error`           | 500    | Our fault. Retry an idempotent
    request; quote `instance` if it persists.|

    | `not_enabled`              | 501    | The contract is published and not
    yet enabled. Nothing was written.    |

    | `service_unavailable`      | 503    | A capability we need is not
    configured. Nothing was written; retry.     |


    `instance` is an occurrence identifier, not a URL. Quote it when you raise
    the problem —

    https://developer.maatstays.wtf/guides/developer-access says where that
    goes.


    We do not tell you which of several refusals you hit — an unknown key, a
    revoked key and a

    wrong secret all answer identically, and so do the three ways a workspace
    scope can fail.

    Distinguishing them would let anyone enumerate our customers.


    # Rate limits


    600 requests an hour per API key across all contract endpoints, by default.
    The ceiling

    is a property of your key: your Maat contact can raise it for yours alone,
    and it

    applies on your next request. Read `RateLimit-Limit` rather than assuming
    600 — it

    always reports the figure that applies to the key you are calling with.


    Every response carries:


    | Header                |
    Meaning                                              |

    | --------------------- |
    ---------------------------------------------------- |

    | `RateLimit-Policy`    | The quota and its window, e.g.
    `600;w=3600`          |

    | `RateLimit-Limit`     | The quota for your key — 600 unless yours was
    raised |

    | `RateLimit-Remaining` | What is left in the current
    window                   |

    | `RateLimit-Reset`     | Seconds until the window
    resets                      |


    These are declared on every response in this document, so a generated client
    can read

    them without parsing headers by hand. A `429` adds `Retry-After`, in
    seconds.


    One honest exception: a `401` or `403` raised by **authentication itself**
    carries no

    `RateLimit-*` headers. That refusal happens before your key is known, so
    there is no

    bucket to report — and reporting one would be inventing a number.
    `X-Correlation-Id` is

    on those responses like every other.


    `POST /token` does not draw on the 600. It has two ceilings of its own, both
    defaults

    rather than contract: **60 exchanges an hour from one IP address**, and
    **1,000 an hour

    per API key**. Both are generous for correct use — a token is good for
    fifteen minutes,

    so one exchange covers a whole sync — and tight enough that guessing a key
    is pointless.


    The MCP documentation server (`POST /mcp`, authorized by the key itself
    rather than by a

    token) draws on the same per-key hourly ceiling and on nothing else.


    # Webhooks


    Booking, calendar, listing and review notifications are delivered to one
    endpoint you

    register with `PUT /webhooks/endpoint`. Deliveries are signed:


    ```

    X-Signature:           hex(HMAC-SHA256(secret, "v1:" + timestamp + ":" +
    raw_body))

    X-Signature-Timestamp: 1788000000

    ```


    Verify against the **raw request body, before parsing it**. Re-serialised
    JSON does not

    round-trip byte-identically and your signature will not match. Reject a
    timestamp more

    than five minutes old.


    Every delivery carries an `event_id` that is stable across our retries, so
    you can be

    idempotent. We never follow redirects, and we only ever deliver over HTTPS
    to a public

    address whose certificate chains to a publicly trusted root.


    # Example values


    Hostnames in the examples that end `.invalid` —
    `hooks.example-partner.invalid`,

    `images.example-partner.invalid` — are deliberately unroutable. `.invalid`
    is reserved by

    [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) precisely so a
    documentation example

    can never resolve to somebody's real server. They are placeholders for your
    own host, not

    typos and not endpoints to call.


    Amounts are `EGP` throughout except where an example is specifically about a
    listing

    priced in another currency; there is nothing special about either. A listing
    is priced

    in one currency, chosen by the workspace, and this API never converts
    between them — a

    rate you send must match the listing's own currency or it is refused.
servers:
  - url: https://maatstays.wtf/v1
    description: >-
      The one host this contract is published against. Whether a call lands on a
      sandbox or a production workspace is decided by the key you exchanged,
      never by the hostname.
security:
  - partnerAuth: []
tags:
  - name: authentication
    description: >
      Exchanging your API key for the short-lived token every other call
      carries.
  - name: properties
    description: >
      Push your properties into Maat from your own system, keyed by that
      system's own

      `provider_id`, and read back everything your workspace has on Maat.
  - name: units
    description: >
      The physical inventory under a property: how many it sleeps, its door
      times, its

      house-rule answers.
  - name: listings
    description: >
      The sellable view of a unit — title, rate, badges, and whether it is on
      sale.
  - name: catalog
    description: >
      The reference lists a property push has to speak — property types,
      amenities and

      house rules. The same in every workspace.
  - name: photos
    description: >
      A property's photographs: what state each one is in, how to upload more,
      how to

      arrange them, and how to remove one.
  - name: calendar
    description: >
      What each night is doing, and who is holding it — plus the **host blocks**
      your

      workspace closes nights with, which are keyed by date rather than by an
      identifier of

      yours. The blocks your system pushes under its own identifiers are under
      `blocks`.
  - name: blocks
    description: >
      **Partner blocks.** Push the nights your own system already has taken — a
      stay sold

      through your own website, an owner's week — keyed by your system's own
      `external_uid`,

      and release them again. Only the credential's workspace can release one.
      Carries no

      guest data of any kind — no name, no booking reference, no amount, no free
      text.


      Not to be confused with the **host blocks** under `calendar`, which are a
      different

      resource: keyed by date rather than by an identifier of yours.
  - name: pricing
    description: >
      The quick actions that price and gate a listing, and the rules attached to
      it.
  - name: bookings
    description: >
      What is happening on your inventory — never who it is happening to. There
      is no guest

      identity anywhere in this tag; a person is always one click away, in the
      Maat host

      console.
  - name: sandbox
    description: >
      Tools that exist only in a sandbox workspace. Today that is one: a booking
      fixture,

      which mints a synthetic booking on your own sandbox listing so you can
      watch the real

      `booking.*` webhooks arrive at your endpoint before you go live.
  - name: reviews
    description: >
      A listing's reviews in their public form, and the one reply a workspace
      can leave on

      each.
  - name: finance
    description: >
      What your inventory earned, what has been paid out, and what you owe —
      read-only, per

      settlement currency, and never converted or added across currencies.


      There is no write here and no finance notification: asking to be paid
      commits banking

      details, and a balance is a position you poll rather than an event.
  - name: webhooks
    description: >
      Operating the endpoint you registered with `PUT /webhooks/endpoint` from
      the outside:

      reading it back, rotating its secret, deleting it, and reconciling what we
      tried to

      deliver.
  - name: booking-notifications
    description: >
      Where Maat delivers a booking on one of your properties, and the payloads
      it sends.
paths:
  /properties/{property_id}/photos/presign:
    post:
      tags:
        - photos
      summary: Get upload forms for new photos
      description: >
        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_id`s, 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.**
      operationId: presignPropertyPhotos
      parameters:
        - $ref: '#/components/parameters/PropertyId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PhotoPresignRequest'
            example:
              files:
                - content_type: image/jpeg
                  size: 482113
                - content_type: image/webp
                  size: 210044
      responses:
        '200':
          headers:
            X-Correlation-Id:
              $ref: '#/components/headers/CorrelationId'
            RateLimit-Policy:
              $ref: '#/components/headers/RateLimitPolicy'
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          description: >-
            One signed upload form per declared file, and today's remaining
            allowance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PhotoPresignResponse'
              example:
                uploads:
                  - photo_id: 0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94
                    url: https://uploads.maatstays.wtf
                    fields:
                      key: incoming/0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94
                      policy: eyJleHBpcmF0aW9uIjoi…
                      x-amz-signature: 4f1c2e…
                    expires_at: '2026-09-07T10:15:00Z'
                quota:
                  remaining_today: 198
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/PropertyNotFound'
        '409':
          $ref: '#/components/responses/PhotoWriteRefused'
        '429':
          $ref: '#/components/responses/PhotoQuotaExceeded'
        '500':
          $ref: '#/components/responses/InternalError'
        '501':
          $ref: '#/components/responses/NotEnabled'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-codeSamples:
        - lang: bash
          label: cURL
          source: >
            # 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
        - lang: javascript
          label: Node.js
          source: >
            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);
        - lang: python
          label: Python
          source: >
            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"])
        - lang: go
          label: Go
          source: |
            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)
            }
components:
  parameters:
    PropertyId:
      name: property_id
      in: path
      required: true
      description: >
        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.
      schema:
        type: string
        format: uuid
      example: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
  schemas:
    PhotoPresignRequest:
      type: object
      title: PhotoPresignRequest
      required:
        - files
      additionalProperties: false
      properties:
        files:
          type: array
          minItems: 1
          maxItems: 30
          description: >-
            One entry per file you intend to upload. The batch is validated
            whole and refused whole.
          items:
            $ref: '#/components/schemas/PhotoPresignFile'
    PhotoPresignResponse:
      type: object
      title: PhotoPresignResponse
      required:
        - uploads
        - quota
      additionalProperties: false
      properties:
        uploads:
          type: array
          description: One upload form per requested file, in the order you asked for them.
          items:
            $ref: '#/components/schemas/PhotoUpload'
        quota:
          $ref: '#/components/schemas/PhotoQuota'
    PhotoPresignFile:
      type: object
      title: PhotoPresignFile
      description: >
        One file you intend to upload. Type and size, and nothing else — the
        destination

        is ours to decide.
      required:
        - content_type
        - size
      additionalProperties: false
      properties:
        content_type:
          $ref: '#/components/schemas/PhotoContentType'
          description: |
            Signed into the upload form as an EXACT match. `image/heic` is not
            accepted: transcode on your side before uploading.

            See [`PhotoContentType`](/api-reference/enums/photo-content-type).
        size:
          type: integer
          minimum: 1024
          maximum: 52428800
          description: >
            The REAL byte length. The form's length range is pinned to it, so a
            file

            that ships more bytes than it declared is refused by the storage
            service

            itself — after you have already sent them. Measure, do not estimate.


            The ceiling is the platform's own upload limit, the same one a photo

            uploaded in the Maat apps is held to; a larger file is a `400`, and
            the

            whole batch is refused with it. Re-encode before you declare.


            It is configured per environment, and the value published here is
            the

            default. Treat it as the number to design against rather than a
            constant

            — the refusal names the limit that was actually in force.


            The floor exists because no real photograph is under a kilobyte: it
            stops

            an empty or truncated object from reaching the processing pipeline
            at

            all.
    PhotoUpload:
      type: object
      title: PhotoUpload
      description: |
        One signed upload form, and the photo it was reserved for.
      required:
        - photo_id
        - url
        - fields
        - expires_at
      additionalProperties: false
      properties:
        photo_id:
          type: string
          format: uuid
          description: |
            The reserved photo. It is already in `GET …/photos` as `pending`.
        url:
          type: string
          description: Where to POST the multipart form.
        fields:
          type: object
          additionalProperties:
            type: string
          description: >
            Every form field, verbatim, BEFORE the file part. Do not add,
            remove,

            rename or reorder them, and send `file` LAST — anything after it is

            ignored.
        expires_at:
          type: string
          format: date-time
          description: |
            After this the form is refused and the reservation is swept.
    PhotoQuota:
      type: object
      title: PhotoQuota
      required:
        - remaining_today
      additionalProperties: false
      properties:
        remaining_today:
          type: integer
          minimum: 0
          description: >
            Photos this API key may still presign before midnight UTC, counting
            down

            from a daily ceiling of **200** — today's default. Pace against this

            number rather than the default: it is read from the server, so it
            stays

            right if the ceiling moves.
    Problem:
      type: object
      title: Problem
      description: >
        An RFC 7807 problem document. Every error in this API is one of these,
        served

        as `application/problem+json` — the token exchange included.


        Branch on `code`. `type` is a stable identifier for the same thing,
        `title` and

        `detail` are for people, and `instance` identifies this one occurrence.


        **The machine-readable facts are the typed members, never the prose.**
        `conflicts`

        carries every colliding date on a `conflict` over a calendar, and
        `errors` carries

        every rejected field on a `validation_failed`. Where one of those is
        present it is

        complete, and `detail` is a shortened rendering of it for a person to
        read.


        `type`, `title`, `status` and `code` are the four this API always sends.
        `detail`

        is sent on every problem it produces today and `instance` on every
        request that

        reached it, but neither is `required` — RFC 7807 makes both optional and
        a future

        problem may carry no `detail`. `additionalProperties` is deliberately
        left unset:

        RFC 7807 lets a problem type define its own members, `conflicts` and
        `errors` are

        two of ours, and more may be added. A member you do not recognise is
        safe to

        ignore.
      required:
        - type
        - title
        - status
        - code
      properties:
        type:
          type: string
          format: uri
          description: >-
            A stable identifier, not a URL to fetch. It is `code` with
            underscores replaced by hyphens.
          example: https://developer.maatstays.wtf/errors/not-enabled
        title:
          type: string
          description: >-
            A short, human-readable summary of the status. For people; we
            reserve the right to rewrite it.
          example: Not implemented
        status:
          type: integer
          description: The HTTP status code, repeated in the body.
          example: 501
        detail:
          type: string
          description: >-
            What went wrong on this request, in a sentence for a person. Never
            parse it — it is rewritten without notice, and every
            machine-readable fact is a sibling member. Present on every problem
            this API produces today; it is not `required` because RFC 7807 does
            not require it.
        instance:
          type: string
          description: >
            An occurrence identifier, not a URL. Quote it when you raise the

            problem — https://developer.maatstays.wtf/guides/developer-access
            says

            where that goes. It is the same value as the `X-Correlation-Id`
            response

            header, which every response carries — including the ones with no
            body.
          example: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
        code:
          $ref: '#/components/schemas/ProblemCode'
          description: >-
            The contract. Branch on this, never on `type`, `title` or `detail`.
            A closed vocabulary that only ever grows, so treat an unrecognised
            value as its HTTP status class.

            See [`ProblemCode`](/api-reference/enums/problem-code).
        conflicts:
          type: array
          description: >
            Present on `conflict`. The complete list of colliding dates — never

            truncated, unlike the sentence in `detail`. Act on this, not on the
            prose.
          items:
            type: string
            format: date
          example:
            - '2026-09-02'
            - '2026-09-03'
        errors:
          type: array
          description: >
            Present on `validation_failed`. Names the rejected fields and the
            rule

            each one broke — never the value you sent.
          items:
            $ref: '#/components/schemas/ValidationError'
    PhotoContentType:
      type: string
      title: PhotoContentType
      description: >
        The image types an upload may declare. Signed into the upload form as an
        EXACT match,

        so a mismatch is refused by storage rather than by us. `image/heic` is
        not accepted —

        transcode on your side before uploading.
      enum:
        - image/jpeg
        - image/png
        - image/webp
      x-maat-internal-enum:
        name: UPLOADABLE_IMAGE_TYPES
        relation: equals
    ProblemCode:
      type: string
      title: ProblemCode
      description: >
        The contract of an error. Branch on this, never on `type`, `title` or
        `detail`.


        A closed vocabulary that only ever grows, so treat an unrecognised value
        as its HTTP

        status class. A code is permanent from the moment it is published:
        renaming one is a

        breaking change.
      enum:
        - validation_failed
        - unauthorized
        - workspace_scope_mismatch
        - insufficient_scope
        - property_not_found
        - listing_not_found
        - unit_not_found
        - conflict
        - property_not_simple
        - listing_pooled_unsupported
        - inventory_not_supported
        - photo_not_found
        - rate_limited
        - photo_quota_exceeded
        - internal_error
        - not_enabled
        - service_unavailable
        - pricing_mode_conflict
        - booking_not_found
        - review_not_found
        - delivery_not_found
        - redrive_budget_exhausted
        - test_event_budget_exhausted
        - rotation_in_progress
        - sandbox_only
      x-enum-descriptions:
        validation_failed: >-
          The body, query or path failed the published schema. `errors` names
          the fields.
        unauthorized: >-
          No usable token: absent, malformed, expired, revoked, or a human
          credential.
        workspace_scope_mismatch: >-
          The token's environment claim disagrees with the workspace it
          addresses.
        insufficient_scope: Your key was not issued with the scope this route requires.
        property_not_found: >-
          No such property in this workspace. One answer for "no such" and "not
          yours".
        listing_not_found: No such listing in this workspace.
        unit_not_found: No such unit in this workspace.
        conflict: >-
          The write collides with something already on the calendar. `conflicts`
          lists the dates.
        property_not_simple: >-
          The property has a topology this contract cannot address from a
          property id alone.
        listing_pooled_unsupported: >-
          The listing sells a room type over several rooms, so a calendar block
          has no single unit to close. Retrying cannot help.
        inventory_not_supported: The listing is not a stay. See `InventoryKind`.
        photo_not_found: No such photo on that property.
        rate_limited: >-
          Too many requests. Read the rate-limit headers and retry after the
          window.
        photo_quota_exceeded: The property already holds as many photos as it may.
        internal_error: A fault on our side. Quote `instance` when you report it.
        not_enabled: The contract is published but does no work for this workspace yet.
        service_unavailable: A dependency was unreachable. Retryable.
        pricing_mode_conflict: Another source prices this listing; you do not hold it.
        booking_not_found: No such booking in this workspace.
        review_not_found: No such review in this workspace.
        delivery_not_found: No such delivery for your endpoint.
        redrive_budget_exhausted: You have spent this window's redrives.
        test_event_budget_exhausted: You have spent this window's test events.
        rotation_in_progress: >-
          A rotation overlap is still open on your endpoint, so a second
          rotation is refused until it closes. `detail` names the instant.
        sandbox_only: >-
          The operation acts on sandbox inventory only, and you named the
          listing in your own live workspace. Retrying cannot help.
      x-maat-internal-enum:
        name: ChannelPartnerProblem
        relation: equals
    ValidationError:
      type: object
      title: ValidationError
      description: >
        One rejected field on a `validation_failed`. Never carries the value you
        sent.
      required:
        - field
        - code
      additionalProperties: false
      properties:
        field:
          type: string
          description: The rejected field, as a dotted path from the request root.
          example: body.check_out
        code:
          $ref: '#/components/schemas/ValidationErrorCode'
          description: >-
            Which rule the field broke. Show it beside the field; do not switch
            on it.

            See
            [`ValidationErrorCode`](/api-reference/enums/validation-error-code).
          example: too_big
    ValidationErrorCode:
      type: string
      title: ValidationErrorCode
      description: >
        Which rule a rejected field broke.


        Show it beside the field; do not switch on it. Most are the request
        validator's own

        rule names; a few name a check that needs something your request does
        not carry, such

        as the currency a listing is priced in. Either way the list can gain
        members, and an

        unrecognised value means exactly what a recognised one does: that field
        was not

        accepted. `field` is the part to act on.
      enum:
        - invalid_type
        - invalid_value
        - invalid_format
        - too_small
        - too_big
        - not_multiple_of
        - unrecognized_keys
        - invalid_union
        - invalid_key
        - invalid_element
        - custom
        - invalid
        - currency_mismatch
        - duplicate_value
        - unsupported_param_kind
        - unavailable
        - unknown_template
      x-enum-descriptions:
        invalid_type: >-
          A string where a number was expected, an object where an array was,
          and so on.
        invalid_value: Not one of the values the field accepts.
        invalid_format: The right type, in the wrong shape — a date, a UUID, a URL.
        too_small: 'Below a minimum: a number, a length, or a count.'
        too_big: 'Above a maximum: a number, a length, or a count.'
        not_multiple_of: A number that is not a permitted step.
        unrecognized_keys: A field this contract does not accept. `field` names it.
        invalid_union: None of the shapes the field may take matched.
        invalid_key: A key in a map-shaped field was not accepted.
        invalid_element: An entry in a collection-shaped field was not accepted.
        custom: A rule specific to this contract — a cross-field constraint, usually.
        invalid: The validator gave no rule name. Read `field` and the schema.
        currency_mismatch: >-
          An amount in a currency the listing is not priced in. Amounts are
          never converted.
        duplicate_value: The same entry appears twice where each may appear once.
        unsupported_param_kind: >-
          The value this quick action takes is of a kind this contract does not
          publish.
        unavailable: >-
          Names something real that has since been withdrawn and can no longer
          be set.
        unknown_template: >-
          Names a template that is not currently published. Stop sending that
          key until it is.
      x-maat-internal-enum:
        name: ProblemFieldErrorCode
        relation: equals
  headers:
    CorrelationId:
      description: >
        This request's own identifier, on **every** response including the ones
        with no

        body. It is the same value as `instance` in a problem document, minus
        the

        `urn:maat:request:` prefix, and it is what to quote when you raise a
        problem —

        https://developer.maatstays.wtf/guides/developer-access says where that
        goes.


        Send your own `X-Correlation-Id` and we return it unchanged — as long as
        it is

        at most 200 characters of visible ASCII with no spaces. Anything else is

        replaced by an id of ours, returned in this same header and used as
        `instance`,

        so the id you are handed is always one that resolves on both sides.
        Nothing is

        ever truncated: a clipped id is a wrong id that looks right.
      schema:
        type: string
        maxLength: 200
      example: 3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
    RateLimitPolicy:
      description: >
        The quota and its window, e.g. `600;w=3600`. Absent on a `401` or `403`
        from

        authentication itself, which happens before your key is known.
      schema:
        type: string
      example: 600;w=3600
    RateLimitLimit:
      description: >
        The quota for the current window, for THIS key. 600 by default; a key
        whose

        ceiling has been raised reports its own figure here.
      schema:
        type: integer
      example: 600
    RateLimitRemaining:
      description: Requests left in the current window. Pace against this.
      schema:
        type: integer
      example: 587
    RateLimitReset:
      description: Seconds until the window resets.
      schema:
        type: integer
      example: 2384
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
      example: 2384
  responses:
    ValidationFailed:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: The payload does not match this document. Nothing was written.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://developer.maatstays.wtf/errors/validation-failed
            title: Validation failed
            status: 400
            instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
            code: validation_failed
            errors:
              - field: body.check_out
                code: custom
    Unauthorized:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: >
        No usable partner token. One answer covers a missing token, an expired
        one, a

        revoked key and a human credential — telling them apart would report
        which

        credentials exist.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://developer.maatstays.wtf/errors/unauthorized
            title: Unauthorized
            status: 401
            detail: A valid partner token is required.
            instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
            code: unauthorized
    Forbidden:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: >
        Authenticated, and not allowed. Two codes share this status — branch on
        `code`:


        - `workspace_scope_mismatch` — the token's environment does not match
        the
          workspace it addresses (a sandbox token against a production workspace, or the
          reverse; one answer also covers a workspace that does not resolve).
        - `insufficient_scope` — the key behind the token was not issued with
        the scope
          this operation requires (`x-maat-scope` on the operation). `detail` names the
          missing scope; ask whoever issued the key for one that carries it.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            workspaceScopeMismatch:
              summary: The token's environment does not match the workspace
              value:
                type: >-
                  https://developer.maatstays.wtf/errors/workspace-scope-mismatch
                title: Workspace scope mismatch
                status: 403
                detail: This token is not valid for the requested workspace.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: workspace_scope_mismatch
            insufficientScope:
              summary: The key lacks this operation's scope
              value:
                type: https://developer.maatstays.wtf/errors/insufficient-scope
                title: Insufficient scope
                status: 403
                detail: >-
                  This key was not issued with the `catalog:write` scope. Ask
                  whoever issued it to issue a key that carries it.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: insufficient_scope
    PropertyNotFound:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: >
        No such property in the workspace this key addresses. One answer covers
        a listing

        that does not exist and one that belongs to another workspace — telling
        them apart

        would confirm the existence of another customer's inventory. Nothing was
        written.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://developer.maatstays.wtf/errors/property-not-found
            title: Property not found
            status: 404
            detail: No such property in this workspace. Nothing was blocked.
            instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
            code: property_not_found
    PhotoWriteRefused:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: >
        The request is well-formed, authenticated and in scope — and the
        property is the

        wrong kind of thing to address this way. **Nothing was changed.** Three
        codes

        share this status; branch on `code`:


        - `property_not_simple` — the property has more than one unit, or more
        than one
          listing, so "the property's photos" has no single subject.
        - `inventory_not_supported` — the listing is an experience or a service,
        which
          this contract does not manage.
        - `conflict` — the property's photos are managed by your property push
          (`PUT /properties/{provider_id}`), which replaces them wholesale on every call.
          A photo written here would be undone by your next push, so nothing was changed:
          send the photo URLs on the push instead. `detail` names the `provider_id` the
          property was pushed under.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            propertyNotSimple:
              summary: The property is not one unit with one listing
              value:
                type: https://developer.maatstays.wtf/errors/property-not-simple
                title: Property is not a single unit
                status: 409
                detail: >-
                  This property has 2 unit(s) and 2 listing(s). These routes
                  manage a property that is one unit with one listing. Nothing
                  was changed.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: property_not_simple
            inventoryNotSupported:
              summary: The listing is not a stay
              value:
                type: https://developer.maatstays.wtf/errors/inventory-not-supported
                title: Inventory kind not supported
                status: 409
                detail: >-
                  This contract manages stays. The listing is an experience,
                  which is sold by the seat and has its own vocabulary. Nothing
                  was changed.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: inventory_not_supported
            integrationOwned:
              summary: Your property push already manages this property's photos
              value:
                type: https://developer.maatstays.wtf/errors/conflict
                title: Conflict
                status: 409
                detail: >-
                  This property's photos are managed by your property push,
                  which replaces them wholesale on every call (you pushed it as
                  "my-prop-9001"). A photo uploaded here would be undone by your
                  next push, so nothing was changed.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: conflict
    PhotoQuotaExceeded:
      description: >
        **Two codes share this status here — branch on `code`.** They mean
        different

        waits, so a caller that could not tell them apart would poll a day-long
        refusal

        on an hourly timer.


        `photo_quota_exceeded` is this API key's own allowance of presigned
        photos for

        the day. It clears at **midnight UTC**, not within the hour, so the
        answer is to

        stop the import and resume tomorrow. Raising the ceiling is a
        conversation

        rather than something to engineer around — see

        https://developer.maatstays.wtf/guides/developer-access. **It carries no

        `Retry-After`**: no short wait helps, and naming an interval would
        invite one.


        `rate_limited` is the shared hourly ceiling every contract call draws
        on, and

        means the same thing here as everywhere else. **It is the one that
        carries

        `Retry-After`** — sleep for the interval it names and continue.


        Nothing was reserved and no upload was authorized, either way.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            photoQuotaExceeded:
              summary: Today's photo allowance is spent — no Retry-After
              value:
                type: https://developer.maatstays.wtf/errors/photo-quota-exceeded
                title: Daily photo quota exceeded
                status: 429
                detail: >-
                  This key may presign 200 photo(s) a day and has used its
                  allowance. Nothing was reserved. The allowance resets at
                  midnight UTC.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: photo_quota_exceeded
            rateLimited:
              summary: The shared hourly ceiling — Retry-After says how long
              value:
                type: https://developer.maatstays.wtf/errors/rate-limited
                title: Too many requests
                status: 429
                detail: >-
                  Too many requests for this API key. Retry after the interval
                  in the Retry-After header.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: rate_limited
    InternalError:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: Our fault. Retry an idempotent request.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://developer.maatstays.wtf/errors/internal-error
            title: Internal error
            status: 500
            detail: The request could not be completed.
            instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
            code: internal_error
    NotEnabled:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: >
        **What every contract endpoint returns against a production workspace.**
        The

        request was authenticated, rate-limited and validated in full; nothing
        was

        written, nothing was read and nothing will be delivered.


        Every operation in this document does real work on a **sandbox**
        workspace —

        `x-maat-availability` on the operation says so, and its description says
        what

        changes. This response is what the same call gets against a production
        one.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://developer.maatstays.wtf/errors/not-enabled
            title: Not implemented
            status: 501
            detail: >-
              The calendar-blocks contract is published but not yet enabled.
              This request was authenticated and validated; no dates were
              blocked. Keep holding these dates on your own platform.
            instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
            code: not_enabled
    ServiceUnavailable:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: >
        Something on our side could not serve this request. Not a fault and not
        a

        rejection of yours — **nothing was written and nothing was disclosed**,
        and

        retrying is the right thing to do. One code, `service_unavailable`,
        covering two

        situations that a caller acts on identically:


        - **A capability this operation needs is not configured.** Webhook
        registration
          and signing answer this until the signing material is in place, and the photo
          presign until uploads are enabled. Retrying is correct; it starts working
          without any change on your side.
        - **A part of Maat this operation depends on did not answer.** The
        booking and
          review operations read and write through Maat's booking service, so a fault
          or an outage there is reported honestly rather than as an empty page or a
          booking that appears not to exist. `detail` says what did not happen.

        It is never a degraded answer. We would rather tell you we could not do

        something than hand you a plausible result that is not true.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            capabilityUnconfigured:
              summary: A capability this operation needs is not configured
              value:
                type: https://developer.maatstays.wtf/errors/service-unavailable
                title: Service unavailable
                status: 503
                detail: >-
                  Webhook delivery is temporarily unavailable. Nothing was
                  registered; retry shortly.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: service_unavailable
            dependencyUnavailable:
              summary: A part of Maat this operation depends on did not answer
              value:
                type: https://developer.maatstays.wtf/errors/service-unavailable
                title: Service unavailable
                status: 503
                detail: >-
                  The booking service could not answer this read. Nothing was
                  disclosed; try again shortly.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: service_unavailable
  securitySchemes:
    partnerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        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.

````

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