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

# Create or replace a property

> Creates the property if we have not seen this `provider_id` from you before, and
replaces it otherwise. Your `provider_id` is yours: it is unique within your
integration and never across the platform.

**Replace, not merge.** The payload you send is the whole property. An amenity you
omit is removed; a photo you omit is dropped. This mirrors how listings are edited
everywhere else on Maat, and it is the only version of "update" in which
"remove this amenity" is expressible.

A property that cannot yet be sold — no price, no photos, no amenities — is
accepted and held in an incomplete state rather than published at a placeholder
price. `publishable` and `missing` tell you exactly what it is waiting for, and
`listing_id` stays `null` until it can actually be shown to a guest.

**Photos are fetched asynchronously**, so the FIRST push of a complete property
comes back `publishable: false` with `photos_processing`. That is not a failure
and there is nothing to do: we fetch each URL, process it and publish the
property on its own. Anything we could not turn into a photo is named in
`photos_rejected`. Sending the same URL again is free — an unchanged URL is never
re-fetched, so a nightly catalogue sync costs nothing for the photos that did not
change. Change the bytes behind a URL and publish it at a new URL.

Send only the address fields listed here. Delivery detail (building, floor,
apartment number, directions) is not part of this contract in either direction.

**Enabled in the SANDBOX.** A sandbox token creates real properties in your
sandbox workspace; they are excluded from every public surface (search, the map,
the guest feed) by design. A PRODUCTION token still receives `501` — the payload
is authenticated and validated in full first, and nothing is written.




## OpenAPI

````yaml /docs/openapi/openapi.yaml put /properties/{provider_id}
openapi: 3.1.0
info:
  title: Maat Stays Partner API
  version: '2026-08-26'
  summary: Two-way property and calendar sync for commercial channel partners.
  contact:
    name: Maat Stays partner integrations
    url: https://developer.maatstays.wtf
  description: >
    The Partner API is a two-way sync between a channel partner's platform and
    Maat Stays.


    ```

    Your system  ──  properties, calendar blocks  ──▶  Maat Stays

    Your system  ◀──  booking notifications, deletion requests  ──  Maat Stays

    ```


    Inbound, you push **what you have**: the properties you manage and the
    nights that are

    already taken on your own platform. Outbound, we push **what happened
    here**: a guest

    booked one of those properties on Maat, a booking was cancelled, or a guest
    exercised

    their right to erasure and you have records to delete.


    Nothing about a guest travels inbound. Maat has no relationship with
    somebody who booked

    on your platform, so a calendar block says "these nights are gone" and
    nothing else.


    # Contract status — read this before you build


    **The calendar-blocks contract is live in a sandbox workspace.** Push a
    block, read your

    blocks back, release one, and the nights really are held and released on a
    real calendar.

    Everything else in this document — and calendar blocks against a
    **production** workspace

    — is **published and not yet enabled**: a request that authenticates and
    validates

    receives `501 Not Implemented` with a problem document whose `code` is
    `not_enabled`.

    Nothing is written, and nothing is delivered.


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


    That is deliberate, and it is the honest version of a pre-release API. 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. It
    would tell

    you a deletion request was acknowledged, so you would record a compliance
    step that never

    happened. Neither is a mistake you could detect from our response.


    What IS live everywhere: authentication, authorization, the sandbox
    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 in the sandbox 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 -X POST https://maatstays.wtf/v1/token \
      -H "Authorization: Bearer maat_partner_a1b2c3d4e5f60718_REPLACE_WITH_YOUR_SECRET"

    # → 200

    # {

    #   "access_token": "eyJhbGciOiJIUzI1NiIs…",

    #   "token_type": "Bearer",

    #   "expires_in": 900,

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

    #   "sandbox": true

    # }


    # 2. Call a contract with the token.

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


    The token endpoint is the one place in this API that does not answer with a
    problem

    document: it returns `{ "message": … }` when a key is refused or the service
    is not

    configured, with one indistinguishable `401` for an unknown key, a revoked
    key and a wrong

    secret. An unexpected fault there is a problem document like everywhere
    else, and every

    other response in this API is one.


    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 is either a sandbox
    key or a

    production key. 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 you. Two partners can use the same
    `provider_id` for

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


    # Errors


    Every response outside the token endpoint 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`                     | 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.     |

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

    | `deletion_request_not_found` | 404  | No deletion request with that
    `event_id` — for you. Never says which. |

    | `conflict`                 | 409    | The nights collide with a Maat
    booking or hold. `conflicts[]` names them.|

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

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


    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. Every
    response carries:


    | Header                | Meaning                                     |

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

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

    | `RateLimit-Limit`     | The quota                                   |

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

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


    A `429` adds `Retry-After`, in seconds. The token endpoint has its own,
    separate ceiling.


    # Webhooks


    Booking notifications and deletion requests 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.
servers:
  - url: https://maatstays.wtf/v1
    description: Staging. The only environment this contract is published against today.
security:
  - partnerAuth: []
tags:
  - name: properties
    description: |
      Push the properties you manage into Maat, keyed by your own `provider_id`.
  - name: blocks
    description: >
      Push the nights that are already taken on your platform. Carries no guest
      data of any

      kind — no name, no booking reference, no amount, no free text.
  - name: booking-notifications
    description: >
      Where Maat delivers a booking on one of your properties, and the payloads
      it sends.
  - name: gdpr
    description: >
      Erasure requests Maat sends you when a guest exercises their right to be
      forgotten, and

      how you confirm you acted on one.
paths:
  /properties/{provider_id}:
    put:
      tags:
        - properties
      summary: Create or replace a property
      description: >
        Creates the property if we have not seen this `provider_id` from you
        before, and

        replaces it otherwise. Your `provider_id` is yours: it is unique within
        your

        integration and never across the platform.


        **Replace, not merge.** The payload you send is the whole property. An
        amenity you

        omit is removed; a photo you omit is dropped. This mirrors how listings
        are edited

        everywhere else on Maat, and it is the only version of "update" in which

        "remove this amenity" is expressible.


        A property that cannot yet be sold — no price, no photos, no amenities —
        is

        accepted and held in an incomplete state rather than published at a
        placeholder

        price. `publishable` and `missing` tell you exactly what it is waiting
        for, and

        `listing_id` stays `null` until it can actually be shown to a guest.


        **Photos are fetched asynchronously**, so the FIRST push of a complete
        property

        comes back `publishable: false` with `photos_processing`. That is not a
        failure

        and there is nothing to do: we fetch each URL, process it and publish
        the

        property on its own. Anything we could not turn into a photo is named in

        `photos_rejected`. Sending the same URL again is free — an unchanged URL
        is never

        re-fetched, so a nightly catalogue sync costs nothing for the photos
        that did not

        change. Change the bytes behind a URL and publish it at a new URL.


        Send only the address fields listed here. Delivery detail (building,
        floor,

        apartment number, directions) is not part of this contract in either
        direction.


        **Enabled in the SANDBOX.** A sandbox token creates real properties in
        your

        sandbox workspace; they are excluded from every public surface (search,
        the map,

        the guest feed) by design. A PRODUCTION token still receives `501` — the
        payload

        is authenticated and validated in full first, and nothing is written.
      operationId: upsertProperty
      parameters:
        - $ref: '#/components/parameters/ProviderId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PropertyUpsertRequest'
            example:
              name: Courtyard flat with the blue door
              description: A two-bedroom flat one street back from the corniche.
              property_type: apartment
              address:
                line1: 14 Example Street
                city: Alexandria
                region: Alexandria
                postal_code: '21500'
                country: EG
                latitude: 31.2001
                longitude: 29.9187
              occupancy:
                max_guests: 4
                bedrooms: 2
                beds: 3
                bathrooms: 1
              base_rate:
                value: '1250.00'
                currency: EGP
              amenities:
                - wifi
                - air-conditioning
                - washing-machine
              photos:
                - url: https://images.example-partner.invalid/p/9001/1.jpg
                  position: 0
                - url: https://images.example-partner.invalid/p/9001/2.jpg
                  position: 1
              check_in_time: '15:00'
              check_out_time: '11:00'
              status: listed
      responses:
        '200':
          description: >
            The property was created or replaced. Sandbox only — a production
            token

            receives `501`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyUpsertResponse'
              example:
                provider_id: ota-prop-9001
                result: updated
                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                status: listed
                publishable: true
                missing: []
                unknown_amenities: []
                photos_rejected: []
        '400':
          $ref: '#/components/responses/ValidationFailed'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/WorkspaceScopeMismatch'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '501':
          description: >
            Production only. The contract is enabled in the sandbox; a
            production

            token is authenticated and validated and then refused, and nothing
            is

            written.
          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 properties contract is enabled in the sandbox only. This
                  request was authenticated and validated; no property was
                  created or changed. Exchange a sandbox key to exercise the
                  full loop.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: not_enabled
      x-codeSamples:
        - lang: bash
          label: cURL
          source: |
            curl -X PUT https://maatstays.wtf/v1/properties/ota-prop-9001 \
              -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{
                "name": "Courtyard flat with the blue door",
                "property_type": "apartment",
                "address": {
                  "line1": "14 Example Street",
                  "city": "Alexandria",
                  "country": "EG",
                  "latitude": 31.2001,
                  "longitude": 29.9187
                },
                "occupancy": { "max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1 },
                "base_rate": { "value": "1250.00", "currency": "EGP" },
                "amenities": ["wifi", "air-conditioning"],
                "photos": [
                  { "url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0 }
                ],
                "status": "listed"
              }'
        - lang: javascript
          label: Node.js
          source: |
            const property = {
                name: 'Courtyard flat with the blue door',
                property_type: 'apartment',
                address: {
                    line1: '14 Example Street',
                    city: 'Alexandria',
                    country: 'EG',
                    latitude: 31.2001,
                    longitude: 29.9187,
                },
                occupancy: { max_guests: 4, bedrooms: 2, beds: 3, bathrooms: 1 },
                base_rate: { value: '1250.00', currency: 'EGP' },
                amenities: ['wifi', 'air-conditioning'],
                photos: [
                    { url: 'https://images.example-partner.invalid/p/9001/1.jpg', position: 0 },
                ],
                status: 'listed',
            };

            const res = await fetch(
                'https://maatstays.wtf/v1/properties/ota-prop-9001',
                {
                    method: 'PUT',
                    headers: {
                        Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(property),
                },
            );

            const body = await res.json();
            if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);

            // photos_processing clears on its own — wait, do not re-send.
            console.log(body.publishable, body.missing);
        - lang: python
          label: Python
          source: |
            import os

            import requests

            property_payload = {
                "name": "Courtyard flat with the blue door",
                "property_type": "apartment",
                "address": {
                    "line1": "14 Example Street",
                    "city": "Alexandria",
                    "country": "EG",
                    "latitude": 31.2001,
                    "longitude": 29.9187,
                },
                "occupancy": {"max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1},
                "base_rate": {"value": "1250.00", "currency": "EGP"},
                "amenities": ["wifi", "air-conditioning"],
                "photos": [
                    {"url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0}
                ],
                "status": "listed",
            }

            res = requests.put(
                "https://maatstays.wtf/v1/properties/ota-prop-9001",
                headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                json=property_payload,
                timeout=30,
            )

            body = res.json()
            if res.status_code >= 400:
                raise RuntimeError(f"{body['code']}: {body.get('detail')}")

            # photos_processing clears on its own — wait, do not re-send.
            print(body["publishable"], body["missing"])
        - lang: go
          label: Go
          source: |
            package main

            import (
                "bytes"
                "encoding/json"
                "fmt"
                "log"
                "net/http"
                "os"
            )

            func main() {
                payload, _ := json.Marshal(map[string]any{
                    "name":          "Courtyard flat with the blue door",
                    "property_type": "apartment",
                    "address": map[string]any{
                        "line1":     "14 Example Street",
                        "city":      "Alexandria",
                        "country":   "EG",
                        "latitude":  31.2001,
                        "longitude": 29.9187,
                    },
                    "occupancy": map[string]any{
                        "max_guests": 4, "bedrooms": 2, "beds": 3, "bathrooms": 1,
                    },
                    "base_rate": map[string]string{"value": "1250.00", "currency": "EGP"},
                    "amenities": []string{"wifi", "air-conditioning"},
                    "photos": []map[string]any{
                        {"url": "https://images.example-partner.invalid/p/9001/1.jpg", "position": 0},
                    },
                    "status": "listed",
                })

                req, _ := http.NewRequest(http.MethodPut,
                    "https://maatstays.wtf/v1/properties/ota-prop-9001",
                    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()

                var body map[string]any
                if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                    log.Fatal(err)
                }
                if res.StatusCode >= 400 {
                    log.Fatalf("%v: %v", body["code"], body["detail"])
                }

                // photos_processing clears on its own — wait, do not re-send.
                fmt.Println(body["publishable"], body["missing"])
            }
components:
  parameters:
    ProviderId:
      name: provider_id
      in: path
      required: true
      description: >
        Your own identifier for the property. Unique to you — it never collides
        with

        another partner's.
      schema:
        type: string
        minLength: 1
        maxLength: 128
      example: ota-prop-9001
  schemas:
    PropertyUpsertRequest:
      type: object
      title: PropertyUpsertRequest
      description: >
        The whole property. Anything you omit is removed — this replaces rather
        than

        merges.
      required:
        - name
        - property_type
        - address
        - occupancy
        - base_rate
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 120
        description:
          type: string
          maxLength: 4000
        property_type:
          type: string
          pattern: ^[a-z0-9][a-z0-9_-]*$
          maxLength: 60
          example: apartment
        address:
          $ref: '#/components/schemas/Address'
        occupancy:
          $ref: '#/components/schemas/Occupancy'
        base_rate:
          $ref: '#/components/schemas/Money'
        amenities:
          type: array
          maxItems: 60
          items:
            type: string
            pattern: ^[a-z0-9][a-z0-9_-]*$
            maxLength: 60
        photos:
          type: array
          maxItems: 30
          items:
            $ref: '#/components/schemas/Photo'
        check_in_time:
          type: string
          pattern: ^([01]\d|2[0-3]):[0-5]\d$
          description: Local time, 24-hour.
          example: '15:00'
        check_out_time:
          type: string
          pattern: ^([01]\d|2[0-3]):[0-5]\d$
          example: '11:00'
        status:
          type: string
          enum:
            - listed
            - unlisted
          description: Whether you want the property sellable on Maat right now.
    PropertyUpsertResponse:
      type: object
      title: PropertyUpsertResponse
      required:
        - provider_id
        - result
        - listing_id
        - status
        - publishable
        - missing
        - unknown_amenities
        - photos_rejected
      additionalProperties: false
      properties:
        provider_id:
          type: string
        result:
          type: string
          enum:
            - created
            - updated
        listing_id:
          type:
            - string
            - 'null'
          format: uuid
          description: >
            Maat's listing identifier, once the property is complete enough to
            have

            one. `null` while it is not — never a placeholder.
        status:
          type: string
          enum:
            - listed
            - unlisted
        publishable:
          type: boolean
          description: Whether the property can be shown to guests as it stands.
        missing:
          type: array
          description: >
            Machine-readable reasons `publishable` is false. Empty when it is
            true.


            `photos_processing` is not a deficiency — your images are being
            fetched

            and processed, and it clears on its own. It is separate from
            `photos`

            precisely so you can tell "wait" from "send us more".


            `unlisted` restates your own `status`, and it is reported alongside
            any

            real deficiency rather than instead of it, so you know whether the

            property is otherwise complete before you re-list it.
          items:
            type: string
            enum:
              - price
              - amenities
              - cover_photo
              - photos
              - photos_processing
              - unlisted
          example:
            - cover_photo
            - photos_processing
        unknown_amenities:
          type: array
          description: >
            Amenity slugs we do not recognise, echoed back rather than silently

            dropped. They are not linked to the property. One unmapped slug does
            not

            fail the push — an otherwise-good property should not be blocked
            while

            you map your vocabulary onto ours.
          items:
            type: string
          example:
            - wi-fi
        photos_rejected:
          type: array
          description: >
            Photo URLs that did not become photos. Empty when every one did.


            `missing: ["photos"]` on its own is not actionable across a

            thirty-photo push, so each failure names your own URL and a reason.

            The reason vocabulary is deliberately coarse: a refused URL never
            says

            what the name resolved to.
          items:
            $ref: '#/components/schemas/PhotoRejection'
    Problem:
      type: object
      title: Problem
      description: >
        An RFC 7807 problem document. Every error outside the token endpoint is
        one of

        these, served as `application/problem+json`.


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

        `detail` are for people, and `instance` identifies this one occurrence.
      required:
        - type
        - title
        - status
        - code
      properties:
        type:
          type: string
          format: uri
          example: https://developer.maatstays.wtf/errors/not-enabled
        title:
          type: string
          example: Not implemented
        status:
          type: integer
          example: 501
        detail:
          type: string
        instance:
          type: string
          description: |
            An occurrence identifier, not a URL. Quote it when you contact us.
          example: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
        code:
          type: string
          enum:
            - validation_failed
            - unauthorized
            - workspace_scope_mismatch
            - property_not_found
            - deletion_request_not_found
            - conflict
            - rate_limited
            - internal_error
            - not_enabled
            - service_unavailable
        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:
            type: object
            required:
              - field
              - code
            additionalProperties: false
            properties:
              field:
                type: string
                example: body.check_out
              code:
                type: string
                example: too_big
    Address:
      type: object
      title: Address
      description: >
        Where the property is. Delivery detail — building, floor, apartment
        number,

        directions — is deliberately not part of this contract, in either
        direction.
      required:
        - line1
        - city
        - country
        - latitude
        - longitude
      additionalProperties: false
      properties:
        line1:
          type: string
          minLength: 1
          maxLength: 200
        city:
          type: string
          minLength: 1
          maxLength: 120
        region:
          type: string
          maxLength: 120
          description: State, province or governorate.
        postal_code:
          type: string
          maxLength: 20
        country:
          type: string
          pattern: ^[A-Z]{2}$
          description: ISO 3166-1 alpha-2, uppercase.
        latitude:
          type: number
          minimum: -90
          maximum: 90
        longitude:
          type: number
          minimum: -180
          maximum: 180
    Occupancy:
      type: object
      title: Occupancy
      description: How many people the property sleeps, and in what.
      required:
        - max_guests
        - bedrooms
        - beds
        - bathrooms
      additionalProperties: false
      properties:
        max_guests:
          type: integer
          minimum: 1
          maximum: 50
        bedrooms:
          type: integer
          minimum: 0
          maximum: 30
        beds:
          type: integer
          minimum: 0
          maximum: 60
        bathrooms:
          type: integer
          minimum: 0
          maximum: 30
          description: >
            Whole bathrooms. Half-bathrooms are not expressible; a fractional
            value is

            refused rather than rounded into a different property than you
            described.
    Money:
      type: object
      title: Money
      description: >
        An amount and its currency, always together.


        `value` is a decimal **string**, not a number. `1250.10` has no exact
        IEEE-754

        representation, and a JSON parser that rounds it costs a cent on every
        booking.

        Parse it with a decimal type, not a float.


        There is no bare amount anywhere in this API and no currency field
        sitting beside

        one. Two fields can be read separately; one object cannot.
      required:
        - value
        - currency
      additionalProperties: false
      properties:
        value:
          type: string
          pattern: ^\d{1,10}(\.\d{1,2})?$
          description: Decimal string, at most two decimal places.
          example: '1250.00'
        currency:
          type: string
          pattern: ^[A-Z]{3}$
          description: ISO 4217, uppercase.
          example: EGP
    Photo:
      type: object
      title: Photo
      description: >
        One photo, by URL. Maat fetches and processes it through the same
        pipeline a host

        upload goes through — you never write to a Maat bucket.
      required:
        - url
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          pattern: ^https://
        position:
          type: integer
          minimum: 0
          maximum: 29
          description: 0-based display order. Position 0 is the cover photo.
    PhotoRejection:
      type: object
      title: PhotoRejection
      required:
        - url
        - reason
      additionalProperties: false
      properties:
        url:
          type: string
          description: The URL exactly as you sent it.
        reason:
          type: string
          enum:
            - url_refused
            - fetch_failed
            - unsupported_type
            - too_large
            - processing_failed
          description: >
            * `url_refused` — we will not fetch that URL. It must be `https`, on
            a
              public DNS name, on port 443 or 8443, with no redirects. Addresses
              inside private, loopback, link-local or cloud-metadata ranges are
              refused, and the check is made against the address the name resolves
              to at the moment we connect.
            * `fetch_failed` — your server did not answer, timed out,
            redirected, or
              returned a non-2xx status. Retryable: push again.
            * `unsupported_type` — not `image/jpeg`, `image/png` or
            `image/webp`.

            * `too_large` — over 15 MB.

            * `processing_failed` — we fetched it and our own pipeline could not
              publish it.
  responses:
    ValidationFailed:
      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:
      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
    WorkspaceScopeMismatch:
      description: >
        The token's environment does not match the workspace it addresses — a
        sandbox

        token against a production workspace, or the reverse. One answer covers
        both, and

        also a workspace that does not resolve.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            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
    RateLimited:
      description: >
        Too many requests for this API key. `Retry-After` gives the wait in
        seconds.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
        RateLimit-Limit:
          description: The quota for the current window.
          schema:
            type: integer
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://developer.maatstays.wtf/errors/rate-limited
            title: Too many requests
            status: 429
            detail: Too many requests for this API key.
            instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
            code: rate_limited
    InternalError:
      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
  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.


        Never send it as a cookie or in a query string, and never store it —
        store the

        API key, mint tokens from it.

````