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.

# ─── The order below IS the reference sidebar's order ────────────────────────
#
# Mintlify renders the API Reference in the order of this array, so it is the
# first thing a reader sees and the only navigation they have. It is the
# integrator's own journey: get a token, push what you manage, read it back, sell
# it, take the money that follows, then what we send you.
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.

security:
    - partnerAuth: []

paths:
    /properties/{provider_id}:
        put:
            tags: [properties]
            operationId: upsertProperty
            x-maat-scope: catalog:write
            x-maat-availability: sandbox
            summary: Create or replace a property
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT https://maatstays.wtf/v1/properties/my-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/my-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/my-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/my-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"])
                      }
            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.

                `property_id` and `unit_id` come back on **every** push, publishable or not, and
                they are the handles the targeted edits take — the address, the amenity set, the
                unit's occupancy and its house rules. Store them beside your own `provider_id`
                and you never have to look one up.

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

                **Live in a sandbox workspace. Returns `501` against a production one**, where
                the payload is authenticated and validated in full and no property is written. A
                sandbox token creates real properties, excluded from every public surface —
                search, the map, the guest feed.
            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: '12:00'
                            status: listed
            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: |
                        The property was created or replaced. Sandbox only — a production token
                        receives `501`.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PropertyUpsertResponse'
                            example:
                                provider_id: my-prop-9001
                                result: updated
                                property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                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/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/PropertyNotEnabled' }

    /blocks/{external_uid}:
        put:
            tags: [blocks]
            operationId: upsertCalendarBlock
            x-maat-scope: calendar:write
            x-maat-availability: sandbox
            summary: Hold a range of nights with a partner block
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT https://maatstays.wtf/v1/blocks/my-res-55123 \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{
                          "listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                          "check_in": "2026-09-01",
                          "check_out": "2026-09-05",
                          "reason": "reserved"
                        }'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/blocks/my-res-55123', {
                          method: 'PUT',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({
                              listing_id: '7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24',
                              check_in: '2026-09-01',
                              check_out: '2026-09-05', // exclusive — the departure day
                              reason: 'reserved',
                          }),
                      });

                      const body = await res.json();
                      if (res.status === 409) {
                          // The whole range was refused; nothing was written.
                          throw new Error(`nights already taken on Maat: ${body.conflicts}`);
                      }
                      if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);

                      console.log(body.result, body.nights);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.put(
                          "https://maatstays.wtf/v1/blocks/my-res-55123",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={
                              "listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                              "check_in": "2026-09-01",
                              "check_out": "2026-09-05",  # exclusive — the departure day
                              "reason": "reserved",
                          },
                          timeout=30,
                      )

                      body = res.json()
                      if res.status_code == 409:
                          # The whole range was refused; nothing was written.
                          raise RuntimeError(f"nights already taken on Maat: {body['conflicts']}")
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["result"], body["nights"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          payload, _ := json.Marshal(map[string]string{
                              "listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                              "check_in":   "2026-09-01",
                              "check_out":  "2026-09-05", // exclusive — the departure day
                              "reason":     "reserved",
                          })

                          req, _ := http.NewRequest(http.MethodPut,
                              "https://maatstays.wtf/v1/blocks/my-res-55123",
                              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 == http.StatusConflict {
                              // The whole range was refused; nothing was written.
                              log.Fatalf("nights already taken on Maat: %v", body["conflicts"])
                          }
                          if res.StatusCode >= 400 {
                              log.Fatalf("%v: %v", body["code"], body["detail"])
                          }

                          fmt.Println(body["result"], body["nights"])
                      }
            description: |
                Tells Maat that these nights are unavailable, because they are taken on your
                platform. Creates the block if this `external_uid` is new to us and replaces its
                dates otherwise.

                `check_out` is **exclusive** — it is the departure day, not the last night held.
                A stay of `2026-09-01` to `2026-09-05` holds four nights.

                Identify the property with either your own `provider_id` or Maat's `listing_id`,
                and send exactly one of them. Both resolve. Use `listing_id` for a property you
                did not push — we have no reference of your own for one.

                **Nothing about a guest belongs here.** There is no field for a name, a booking
                reference, an amount or a note, and sending one is a `validation_failed` rather
                than a silent drop. Maat has no relationship with somebody who booked on your
                platform and has no lawful basis to hold their details.

                Pushing a block onto nights that already carry a Maat booking or a live hold
                answers `409`, and **nothing is written**: the whole range is refused rather than
                the part of it that was free. `conflicts[]` names the colliding dates.

                A listing that sells a room type over several rooms answers
                `409 listing_pooled_unsupported`: a block closes one unit, and such a listing's
                room is chosen per booking. Nothing is written.

                Re-sending the same `external_uid` with different dates MOVES the block — the old
                nights are released and the new ones held, in one step. Re-sending it unchanged is
                a no-op that answers `updated`.

                **Live in a sandbox workspace. Returns `501` against a production one**, after
                validating the payload in full — so keep holding these nights on your side until
                the production contract is enabled.

                A `provider_id` is resolved against the mapping **your own** property push
                created, inside the workspace your key addresses. Two integrations using the same
                reference for different properties never reach each other's calendars, and a
                reference we have never seen from you is a `404`, exactly like an unknown
                listing.
            parameters:
                - $ref: '#/components/parameters/ExternalUid'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/BlockUpsertRequest'
                        example:
                            provider_id: my-prop-9001
                            check_in: '2026-09-01'
                            check_out: '2026-09-05'
                            reason: reserved
            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: The nights are held.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/BlockUpsertResponse'
                            example:
                                external_uid: my-res-55123
                                result: created
                                nights: 4
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/PropertyNotFound' }
                '409': { $ref: '#/components/responses/Conflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

        delete:
            tags: [blocks]
            operationId: releaseCalendarBlock
            x-maat-scope: calendar:write
            x-maat-availability: sandbox
            summary: Release a partner block
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X DELETE https://maatstays.wtf/v1/blocks/my-res-55123 \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -i
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/blocks/my-res-55123', {
                          method: 'DELETE',
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

                      // 204 either way — released, or it was never there. Idempotent.
                      if (res.status !== 204) {
                          const problem = await res.json();
                          throw new Error(`${problem.code}: ${problem.detail}`);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.delete(
                          "https://maatstays.wtf/v1/blocks/my-res-55123",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )

                      # 204 either way — released, or it was never there. Idempotent.
                      if res.status_code != 204:
                          problem = res.json()
                          raise RuntimeError(f"{problem['code']}: {problem.get('detail')}")
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          req, _ := http.NewRequest(http.MethodDelete,
                              "https://maatstays.wtf/v1/blocks/my-res-55123", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          // 204 either way — released, or it was never there. Idempotent.
                          if res.StatusCode != http.StatusNoContent {
                              var problem map[string]any
                              if err := json.NewDecoder(res.Body).Decode(&problem); err != nil {
                                  log.Fatal(err)
                              }
                              log.Fatalf("%v: %v", problem["code"], problem["detail"])
                          }
                      }
            description: |
                Removes the block you pushed under this `external_uid` and frees the nights it
                still holds.

                It frees **only** the nights that block is holding. A night that is unavailable
                for another reason — the property's own owner closed it, or a Maat booking has
                since taken it — stays unavailable. Releasing a block is not the same as
                declaring those dates open.

                Idempotent: releasing a block that does not exist, or releasing the same one
                twice, is `204`. There is nothing to retry and nothing has gone wrong.

                There is no partial release. Send the whole block or move it with a `PUT`.

                **Live in a sandbox workspace. Returns `501` against a production one**, where no
                block was ever applied and so none can be released.
            parameters:
                - $ref: '#/components/parameters/ExternalUid'
            responses:
                '204':
                    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 block is gone, or was never there. No body either way.
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /blocks:
        get:
            tags: [blocks]
            operationId: listCalendarBlocks
            x-maat-scope: calendar:read
            x-maat-availability: sandbox
            summary: Read your partner blocks back
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/blocks?from=2026-09-01&to=2026-09-30&page_size=200" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

                      for (let page = 1; ; page += 1) {
                          const url = new URL('https://maatstays.wtf/v1/blocks');
                          url.searchParams.set('from', '2026-09-01');
                          url.searchParams.set('to', '2026-09-30');
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '200');

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

                          for (const block of body.items) {
                              console.log(block.external_uid, block.check_in, block.check_out);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      page = 1
                      while True:
                          res = requests.get(
                              "https://maatstays.wtf/v1/blocks",
                              headers=headers,
                              params={
                                  "from": "2026-09-01",
                                  "to": "2026-09-30",
                                  "page": page,
                                  "page_size": 200,
                              },
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for block in body["items"]:
                              print(block["external_uid"], block["check_in"], block["check_out"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type block struct {
                          ExternalUID string `json:"external_uid"`
                          CheckIn     string `json:"check_in"`
                          CheckOut    string `json:"check_out"`
                      }

                      type page struct {
                          Items   []block `json:"items"`
                          HasMore bool    `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("from", "2026-09-01")
                              query.Set("to", "2026-09-30")
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "200")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/blocks?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatal(err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, b := range body.Items {
                                  fmt.Println(b.ExternalUID, b.CheckIn, b.CheckOut)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                Lists the blocks **you** pushed into the workspace your key addresses, newest
                window last. It is a reconciliation surface: after a timeout or a `500` you can
                see whether a push actually landed, rather than guessing.

                Two things bound the result and neither is a parameter you send: the blocks are
                yours, and the workspace is the one your key was issued for.

                Nothing about a guest is here, because nothing about a guest was ever accepted.

                Paged with `page` and `page_size`; `has_more` tells you whether to ask for the
                next one. There is no total — we do not count the whole set to answer a page.

                **Live in a sandbox workspace. Returns `501` against a production one**, where
                nothing has been blocked and so there is nothing to read back.
            parameters:
                - $ref: '#/components/parameters/BlockWindowFrom'
                - $ref: '#/components/parameters/BlockWindowTo'
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
            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 page of your blocks.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/BlockListResponse'
                            example:
                                items:
                                    - external_uid: my-res-55123
                                      listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                      check_in: '2026-09-01'
                                      check_out: '2026-09-05'
                                      nights: 4
                                      reason: reserved
                                      created_at: '2026-08-20T10:00:00.000Z'
                                      updated_at: '2026-08-20T10:00:00.000Z'
                                has_more: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /webhooks/endpoint:
        put:
            tags: [webhooks]
            operationId: registerWebhookEndpoint
            x-maat-scope: webhooks:manage
            x-maat-availability: sandbox
            summary: Register where deliveries go
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      # Your endpoint must answer the signed endpoint.challenge we POST
                      # to it DURING this call — see the webhooks guide.
                      curl --fail-with-body -X PUT https://maatstays.wtf/v1/webhooks/endpoint \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{
                          "url": "https://hooks.example-partner.invalid/maat",
                          "events": ["booking.confirmed", "booking.cancelled", "booking.requested"]
                        }'
                - lang: javascript
                  label: Node.js
                  source: |
                      // Your endpoint must answer the signed endpoint.challenge we POST
                      // to it DURING this call — see the webhooks guide.
                      const res = await fetch('https://maatstays.wtf/v1/webhooks/endpoint', {
                          method: 'PUT',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({
                              url: 'https://hooks.example-partner.invalid/maat',
                              events: ['booking.confirmed', 'booking.cancelled', 'booking.requested'],
                          }),
                      });

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

                      if (body.signing_secret) {
                          // Present only on the FIRST registration, and never shown again.
                          // Store it in your secret manager NOW. Never log it.
                      }
                      console.log(body.status); // 'active' once the challenge was echoed
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      # Your endpoint must answer the signed endpoint.challenge we POST
                      # to it DURING this call — see the webhooks guide.
                      res = requests.put(
                          "https://maatstays.wtf/v1/webhooks/endpoint",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={
                              "url": "https://hooks.example-partner.invalid/maat",
                              "events": ["booking.confirmed", "booking.cancelled", "booking.requested"],
                          },
                          timeout=30,
                      )

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

                      if "signing_secret" in body:
                          # Present only on the FIRST registration, and never shown again.
                          # Store it in your secret manager NOW. Never log it.
                          pass
                      print(body["status"])  # 'active' once the challenge was echoed
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          // Your endpoint must answer the signed endpoint.challenge we POST
                          // to it DURING this call — see the webhooks guide.
                          payload, _ := json.Marshal(map[string]any{
                              "url":    "https://hooks.example-partner.invalid/maat",
                              "events": []string{"booking.confirmed", "booking.cancelled", "booking.requested"},
                          })

                          req, _ := http.NewRequest(http.MethodPut,
                              "https://maatstays.wtf/v1/webhooks/endpoint",
                              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"])
                          }

                          if _, ok := body["signing_secret"]; ok {
                              // Present only on the FIRST registration, and never shown again.
                              // Store it in your secret manager NOW. Never log it.
                          }
                          fmt.Println(body["status"]) // "active" once the challenge was echoed
                      }
            description: |
                Sets the single HTTPS endpoint Maat delivers to, and the events you want. Sending
                this again replaces both — there is only ever one endpoint per workspace.

                `events` must name at least one event. It used to be documented as accepting an
                empty array to "pause" delivery, and it never did that: every registration
                re-arms the challenge, so an empty one would leave your endpoint
                `pending_verification` and subscribed to nothing. `DELETE /v1/webhooks/endpoint`
                is the operation that means stop — it is idempotent, and it keeps your signing
                secret and your delivery history.

                ### The challenge round-trip

                **Nothing real is delivered until your endpoint proves it is yours.** During this
                call we POST a signed `endpoint.challenge` delivery to the URL you gave us, and
                your handler must answer `2xx` with a JSON body echoing the same `challenge`
                string:

                ```json
                { "challenge": "<the value we sent>" }
                ```

                Echo it and the endpoint becomes `active`. Anything else — a non-2xx, an
                unreadable body, a different value — leaves it `pending_verification`, and no
                booking notification is ever sent to it. Call this endpoint again to retry.

                The challenge is signed exactly like a real delivery, so your verification code is
                exercised before your first real payload rather than after it.

                **The challenge is re-armed on EVERY call**, including one that does not change
                the URL. A URL that verified in March is not evidence about the same URL in
                September.

                ### What we check about your URL

                `https` only, on port 443 or 8443, on a publicly resolvable DNS name. We check the
                address the name actually **resolves to, at the moment we connect** — not just at
                registration — and we never follow redirects. If a URL is refused you get one
                `validation_failed`; we do not report which check failed.

                If your endpoint's address later stops resolving publicly, we suspend it and stop
                delivering rather than retrying into it.

                ### The signing secret

                Returned **once**, in the response to this call, and never again — we do not store
                it in a form anything can read back. Re-registering (a corrected path, a new event
                list) deliberately does NOT mint a new one, so your existing verification keeps
                working.

                If you lose it or it leaks, rotate it yourself with
                `POST /v1/webhooks/endpoint/rotate-secret`: we issue the next secret and sign
                every delivery with **both** for an overlap window, so you can deploy on your own
                clock. See `X-Signature-Previous` under `booking.confirmed`.

                ### Availability

                **Live in a sandbox workspace. Returns `501` against a production one**, where
                no endpoint is registered and nothing is delivered.

                A `503` means webhook signing is not configured on our side. Nothing was
                registered; retry.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/WebhookEndpointRequest'
                        example:
                            url: https://hooks.example-partner.invalid/maat
                            events: [booking.confirmed, booking.cancelled, booking.requested]
            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: |
                        The endpoint is registered. Check `status`: `active` means the challenge
                        was echoed and deliveries will flow; `pending_verification` means it was
                        not, and nothing will be sent until you call again and echo it.

                        `signing_secret` is present only the FIRST time you register. Its absence
                        on a later call is not an error — your existing secret still applies.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/WebhookEndpointResponse'
                            example:
                                url: https://hooks.example-partner.invalid/maat
                                events: [booking.confirmed, booking.cancelled, booking.requested]
                                status: active
                                signing_secret: whsec_EXAMPLE_0000000000000000000000000000
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

        get:
            tags: [webhooks]
            operationId: getWebhookEndpoint
            x-maat-scope: webhooks:manage
            x-maat-availability: sandbox
            summary: Read your endpoint back
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body https://maatstays.wtf/v1/webhooks/endpoint \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/webhooks/endpoint', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      if (body === null) {
                          console.log('no endpoint registered yet');
                      } else {
                          console.log(body.url, body.status);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/webhooks/endpoint",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      if body is None:
                          print("no endpoint registered yet")
                      else:
                          print(body["url"], body["status"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type endpointView struct {
                          URL    string `json:"url"`
                          Status string `json:"status"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/webhooks/endpoint", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body *endpointView
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          if body == nil {
                              fmt.Println("no endpoint registered yet")
                          } else {
                              fmt.Println(body.URL, body.Status)
                          }
                      }
            description: |
                Reads back the endpoint you registered with `PUT /webhooks/endpoint`, as the
                whole body — there is no wrapper object.

                The body is `null` — not a `404` — when nothing is registered. An endpoint you
                have not set up yet is a normal state for a new integration, not an error.

                **No secret is ever returned here.** The row stores a generation number, not the
                secret itself; the value is returned only by the call that mints it — the first
                registration, or a rotation — and nowhere else, ever.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            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: Your endpoint, or `null` if you have not registered one.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/DeveloperWebhookEndpointResponse'
                            example:
                                url: https://hooks.example-partner.invalid/maat
                                events: [booking.confirmed, booking.cancelled, booking.requested]
                                status: active
                                verified_at: '2026-08-30T18:41:07.000Z'
                                challenge_sent_at: '2026-08-30T18:41:02.000Z'
                                last_challenge_error: null
                                last_challenge_error_at: null
                                secret_generation: 1
                                previous_secret_expires_at: null
                                created_at: '2026-08-30T18:41:02.000Z'
                                updated_at: '2026-08-30T18:41:07.000Z'
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

        delete:
            tags: [webhooks]
            operationId: deleteWebhookEndpoint
            x-maat-scope: webhooks:manage
            x-maat-availability: sandbox
            summary: Delete your endpoint
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X DELETE https://maatstays.wtf/v1/webhooks/endpoint \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/webhooks/endpoint', {
                          method: 'DELETE',
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

                      // 204, with no body — whether or not there was one to delete.
                      if (!res.ok) {
                          const problem = await res.json();
                          throw new Error(`${problem.code}: ${problem.detail}`);
                      }

                      console.log('deliveries stopped');
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.delete(
                          "https://maatstays.wtf/v1/webhooks/endpoint",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      # 204, with no body — whether or not there was one to delete.
                      if res.status_code >= 400:
                          problem = res.json()
                          raise RuntimeError(f"{problem['code']}: {problem.get('detail')}")

                      print("deliveries stopped")
                - lang: go
                  label: Go
                  source: |
                      package main

                      import (
                          "fmt"
                          "log"
                          "net/http"
                          "os"
                      )

                      func main() {
                          req, _ := http.NewRequest(http.MethodDelete, "https://maatstays.wtf/v1/webhooks/endpoint", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          // 204, with no body — whether or not there was one to delete.
                          if res.StatusCode >= 400 {
                              log.Fatalf("delete failed: %d", res.StatusCode)
                          }

                          fmt.Println("deliveries stopped")
                      }
            description: |
                Deletes your endpoint. **Idempotent**: deleting one that is already gone is the
                same `204`, never a `404` — a second delete is not an error, and the answer does
                not tell you whether there was one, because acting on that is the `404` handling
                idempotency exists to spare you.

                This stops delivery and forgets the event subscription. It does NOT throw away
                the delivery history — your existing deliveries stay readable through
                `GET /webhooks/deliveries` — and it does not discard the signing secret either,
                so a later re-registration keeps working with what you already have stored.

                Re-registering after a delete always re-arms the challenge: a URL that verified
                before is not evidence about the same URL now.

                Deliveries still queued for the endpoint are parked `failed` with
                `endpoint_removed`. Their bodies are kept, so they can be replayed — but only
                after you register the endpoint again **and** answer the challenge; until then a
                redrive answers `409 conflict`. Deleting also starts the payload-retention clock
                those rows were exempt from while they were queued.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            responses:
                '204':
                    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: Deliveries are stopped. No body.
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /webhooks/endpoint/rotate-secret:
        post:
            tags: [webhooks]
            operationId: rotateWebhookSecret
            x-maat-scope: webhooks:manage
            x-maat-availability: sandbox
            summary: Rotate the signing secret
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST https://maatstays.wtf/v1/webhooks/endpoint/rotate-secret \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/webhooks/endpoint/rotate-secret',
                          {
                              method: 'POST',
                              headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                          },
                      );

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

                      // Store this NOW. It is never shown again.
                      console.log(body.signing_secret, body.previous_secret_expires_at);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/webhooks/endpoint/rotate-secret",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      # Store this NOW. It is never shown again.
                      print(body["signing_secret"], body["previous_secret_expires_at"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          SigningSecret           string `json:"signing_secret"`
                          PreviousSecretExpiresAt string `json:"previous_secret_expires_at"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodPost,
                              "https://maatstays.wtf/v1/webhooks/endpoint/rotate-secret", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("rotate failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          // Store this NOW. It is never shown again.
                          fmt.Println(body.SigningSecret, body.PreviousSecretExpiresAt)
                      }
            description: |
                Issues a new signing secret and starts an overlap window in which we sign every
                delivery with **both** secrets, so you can deploy your new one on your own clock —
                see `X-Signature-Previous` under `booking.confirmed`.

                **The request body is empty, on purpose.** There is no `overlap_hours`: how long
                we keep accepting the previous secret is a property of our own dispatcher, not a
                caller's knob — a caller who could set it to zero would be choosing an outage they
                cannot see coming.

                `signing_secret` is returned here, and nowhere else, ever. There is nowhere to
                read it back from afterward, so store it before you do anything else with this
                response.

                **This call is NOT idempotent, and a second one during the overlap is refused.**
                We sign with exactly two secrets — your current one and the previous one, until
                its window closes — so rotating again inside that window would evict the secret
                you are verifying with today, including for deliveries already queued. A retry
                after a lost response therefore answers `409 rotation_in_progress` with the
                instant the window closes, and your existing secret keeps working. Read that as
                proof the earlier call landed.

                `409 conflict` when there is no endpoint registered to rotate a secret for —
                register one first with `PUT /webhooks/endpoint`. `503` means webhook signing is
                not configured on our side; nothing was rotated.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            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: The new secret. Present only in this response — store it now.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RotateWebhookSecretResponse'
                            example:
                                endpoint:
                                    url: https://hooks.example-partner.invalid/maat
                                    events:
                                        [booking.confirmed, booking.cancelled, booking.requested]
                                    status: active
                                    verified_at: '2026-08-30T18:41:07.000Z'
                                    challenge_sent_at: '2026-08-30T18:41:02.000Z'
                                    last_challenge_error: null
                                    last_challenge_error_at: null
                                    secret_generation: 2
                                    previous_secret_expires_at: '2026-09-08T00:00:00.000Z'
                                    created_at: '2026-08-30T18:41:02.000Z'
                                    updated_at: '2026-09-07T09:00:00.000Z'
                                signing_secret: whsec_EXAMPLE_1111111111111111111111111111
                                previous_secret_expires_at: '2026-09-08T00:00:00.000Z'
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '409': { $ref: '#/components/responses/RotateSecretConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /webhooks/endpoint/test:
        post:
            tags: [webhooks]
            operationId: sendTestWebhookEvent
            x-maat-scope: webhooks:manage
            x-maat-availability: sandbox
            summary: Send yourself a test event
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST https://maatstays.wtf/v1/webhooks/endpoint/test \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{ "event_type": "booking.confirmed" }'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/webhooks/endpoint/test', {
                          method: 'POST',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({ event_type: 'booking.confirmed' }),
                      });

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

                      // Queued. Your endpoint receives it exactly like a real event,
                      // with "test": true in the body.
                      console.log(body.delivery.delivery_id, body.test_events_remaining_today);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/webhooks/endpoint/test",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={"event_type": "booking.confirmed"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      # Queued. Your endpoint receives it exactly like a real event,
                      # with "test": true in the body.
                      print(body["delivery"]["delivery_id"], body["test_events_remaining_today"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Delivery struct {
                              DeliveryID string `json:"delivery_id"`
                          } `json:"delivery"`
                          TestEventsRemainingToday int `json:"test_events_remaining_today"`
                      }

                      func main() {
                          reqBody, _ := json.Marshal(map[string]string{"event_type": "booking.confirmed"})
                          req, _ := http.NewRequest(http.MethodPost,
                              "https://maatstays.wtf/v1/webhooks/endpoint/test", bytes.NewReader(reqBody))
                          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("test event refused: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          // Queued. Your endpoint receives it exactly like a real event,
                          // with "test": true in the body.
                          fmt.Println(body.Delivery.DeliveryID, body.TestEventsRemainingToday)
                      }
            description: |
                Queues **one real delivery** of the named event to your registered endpoint, built
                from a fixed, obviously synthetic fixture — so you can exercise your handler end to
                end (signature, envelope, payload shape, your own 2xx) before a real event exists
                to fire.

                It is a real delivery in every way that matters: the same queue, the same
                dispatcher, the same signing with your current secret (and the previous one during
                a rotation overlap), the same retry ladder, the same `User-Agent`, and the same
                row in `GET /webhooks/deliveries` — badged `"test": true` there and in the body we
                POST you. Verify it exactly as you verify everything else.

                It is NOT a real event in two ways you can rely on. The body carries `"test": true`
                at the top level, beside `event_id`, on every test delivery and on no real one —
                branch on it if your handler must not act on the fixture. And the fixture's ids
                are visibly not yours: `00000000-0000-4000-8000-00000000000n`, a `provider_id` of
                `test-property`, a stay a week from now.

                The `data` you receive is produced by **the same projection as the real event**.
                There is no second serializer for test bodies to drift from the first.

                `event_type` must be one your credential can receive, whether or not your endpoint
                is subscribed to it: `400 validation_failed` names the ones that are not. `409
                conflict` when there is no endpoint registered, or it is not `active` — register it
                and answer the challenge first. Each accepted call spends one unit of a per-credential
                allowance over a rolling 24 hours; `429 test_event_budget_exhausted` carries
                `Retry-After`. A refused call spends nothing and queues nothing.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/SendTestEventRequest'
                        example:
                            event_type: booking.confirmed
            responses:
                '202':
                    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: |
                        Queued. `202`, not `200`: nothing has been delivered yet — the dispatcher
                        picks it up on its next pass. Watch `GET /webhooks/deliveries/{delivery_id}`
                        for the outcome, or your own endpoint's log.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/SendTestEventResponse'
                            example:
                                delivery:
                                    delivery_id: 6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f
                                    event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                                    event_type: booking.confirmed
                                    status: pending
                                    attempts: 0
                                    last_error_code: null
                                    last_response_status: null
                                    next_attempt_at: null
                                    occurred_at: '2026-09-07T09:05:00.000Z'
                                    created_at: '2026-09-07T09:05:00.100Z'
                                    payload_present: true
                                    test: true
                                test_events_remaining_today: 19
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '409': { $ref: '#/components/responses/TestEventConflict' }
                '429': { $ref: '#/components/responses/TestEventBudgetExhausted' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /webhooks/deliveries:
        get:
            tags: [webhooks]
            operationId: listWebhookDeliveries
            x-maat-scope: webhooks:manage
            x-maat-availability: sandbox
            summary: List what we have tried to deliver
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/webhooks/deliveries?status=failed&page_size=100" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

                      for (let page = 1; ; page += 1) {
                          const url = new URL('https://maatstays.wtf/v1/webhooks/deliveries');
                          url.searchParams.set('status', 'failed');
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '100');

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

                          for (const delivery of body.items) {
                              console.log(delivery.delivery_id, delivery.event_type, delivery.attempts);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      page = 1
                      while True:
                          res = requests.get(
                              "https://maatstays.wtf/v1/webhooks/deliveries",
                              headers=headers,
                              params={"status": "failed", "page": page, "page_size": 100},
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for delivery in body["items"]:
                              print(delivery["delivery_id"], delivery["event_type"], delivery["attempts"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type delivery struct {
                          DeliveryID string `json:"delivery_id"`
                          EventType  string `json:"event_type"`
                          Attempts   int    `json:"attempts"`
                      }

                      type page struct {
                          Items   []delivery `json:"items"`
                          HasMore bool       `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("status", "failed")
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "100")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/webhooks/deliveries?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatal(err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, d := range body.Items {
                                  fmt.Println(d.DeliveryID, d.EventType, d.Attempts)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                Lists what we have tried to deliver to your endpoint, newest first — a
                reconciliation surface for the question every webhook integration eventually
                asks: did you actually send the thing I think I'm missing.

                **This is envelopes, not bodies.** Each row carries `payload_present` and nothing
                that could hold the payload itself — a delivery's body is the most sensitive
                thing this platform sends anywhere, so the list stops at whether one is still
                held. `GET /webhooks/deliveries/{delivery_id}` is the one read that carries the
                body.

                Filter with `status` and `event_type`; page with `page` and `page_size`, and keep
                asking while `has_more` is `true` — there is no total. `retention_days` says how
                long we keep delivery history, so you read the window from us rather than from a
                number in your own notes.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
                - $ref: '#/components/parameters/DeliveryStatus'
                - $ref: '#/components/parameters/DeliveryEventType'
            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 page of your deliveries.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/WebhookDeliveryList'
                            example:
                                items:
                                    - delivery_id: 6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f
                                      event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                                      event_type: booking.confirmed
                                      status: failed
                                      attempts: 6
                                      last_error_code: connection_timeout
                                      last_response_status: null
                                      next_attempt_at: null
                                      occurred_at: '2026-08-30T18:42:11.000Z'
                                      created_at: '2026-08-30T18:42:11.500Z'
                                      payload_present: true
                                      test: false
                                has_more: false
                                retention_days: 30
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /webhooks/deliveries/{delivery_id}:
        get:
            tags: [webhooks]
            operationId: getWebhookDelivery
            x-maat-scope: webhooks:manage
            x-maat-availability: sandbox
            summary: Read one delivery, body included
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f',
                          { headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` } },
                      );

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

                      // `payload` is the exact bytes we sent, as a string — parse it yourself.
                      if (body.payload) {
                          console.log(JSON.parse(body.payload));
                      } else {
                          console.log(body.payload_state); // 'redacted' or 'purged'
                      }
                - lang: python
                  label: Python
                  source: |
                      import json
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      # "payload" is the exact bytes we sent, as a string — parse it yourself.
                      if body["payload"]:
                          print(json.loads(body["payload"]))
                      else:
                          print(body["payload_state"])  # "redacted" or "purged"
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type deliveryDetail struct {
                          Payload      *string `json:"payload"`
                          PayloadState string  `json:"payload_state"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet,
                              "https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body deliveryDetail
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          // Payload is the exact bytes we sent, as a string — parse it yourself.
                          if body.Payload != nil {
                              fmt.Println(*body.Payload)
                          } else {
                              fmt.Println(body.PayloadState) // "redacted" or "purged"
                          }
                      }
            description: |
                One delivery, with the body we sent you — the one read in this document where a
                body appears at all. The delivery IS the response; there is no wrapper object.

                This is not a new disclosure: it is the same bytes, to the same recipient, that
                already crossed the wire and that your own logs hold. What it adds is the ability
                to reconcile a delivery you think you missed against what we actually sent,
                without opening a support ticket to ask an operator to look at a page that
                deliberately carries no body.

                `payload` is the stored string, **verbatim** — not re-serialized from a parsed
                object. That is deliberate: it is literally what crossed the wire, so you can
                re-verify our signature against it, and re-serializing would silently change key
                order and whitespace and break exactly that. It is `null` in every
                `payload_state` but `held`.

                `404 delivery_not_found` is ONE answer for "no such delivery", "another
                workspace's delivery" and "past the retention window" — telling them apart would
                confirm the existence of another workspace's integration.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/DeliveryId'
            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: The delivery, with its body.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/WebhookDeliveryDetailResponse'
                            example:
                                delivery_id: 6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f
                                event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                                event_type: booking.confirmed
                                status: failed
                                attempts: 6
                                last_error_code: connection_timeout
                                last_response_status: null
                                next_attempt_at: null
                                occurred_at: '2026-08-30T18:42:11.000Z'
                                created_at: '2026-08-30T18:42:11.500Z'
                                payload_present: true
                                test: false
                                payload: '{"event_id":"9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d","event_type":"booking.confirmed","occurred_at":"2026-08-30T18:42:11Z","data":{"booking_reference":"5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d"}}'
                                payload_state: held
                                signature_headers:
                                    X-Signature-Timestamp: '1789200000'
                                    X-Signature: b9eeb16c320e853232bad7b7200b69a4bfa3abce6580e1de5025a6c441e4fdd6
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/DeliveryNotFound' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /webhooks/deliveries/{delivery_id}/redrive:
        post:
            tags: [webhooks]
            operationId: redriveWebhookDelivery
            x-maat-scope: webhooks:manage
            x-maat-availability: sandbox
            summary: Put a delivery back in the queue
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST "https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f/redrive" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f/redrive',
                          {
                              method: 'POST',
                              headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                          },
                      );

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

                      // Same event_id — your idempotency key survives the replay.
                      console.log(body.delivery.status, body.redrives_remaining_today);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f/redrive",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      # Same event_id — your idempotency key survives the replay.
                      print(body["delivery"]["status"], body["redrives_remaining_today"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type deliveryView struct {
                          Status string `json:"status"`
                      }

                      type payload struct {
                          Delivery               deliveryView `json:"delivery"`
                          RedrivesRemainingToday int          `json:"redrives_remaining_today"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodPost,
                              "https://maatstays.wtf/v1/webhooks/deliveries/6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f/redrive", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("redrive failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          // Same event_id — your idempotency key survives the replay.
                          fmt.Println(body.Delivery.Status, body.RedrivesRemainingToday)
                      }
            description: |
                Puts one delivery back at the front of the queue. **The body is empty, on
                purpose**: there is no reason field, no delay, and no list — a redrive addresses
                exactly one delivery, never a batch.

                Only a `delivered` or `failed` delivery can be replayed. `event_id` is
                **unchanged** by a redrive, so your idempotency key survives it — a redrive is a
                retry, not a duplicate booking notification in your own system. The attempt
                series resets and the delivery goes back to `pending`.

                **The body is empty**: a redrive addresses exactly one delivery, and there is no
                reason field, no delay and no list, because a redrive is never bulk.

                `409 conflict` when the delivery is already queued, retrying or being sent; when
                its body is gone (erased, or past the payload retention window — there is
                nothing left to redrive); or when your endpoint is not currently active. `detail`
                names which. Nothing is changed in any of those cases.

                `429 rate_limited` when this credential's rolling 24-hour redrive allowance is
                used up. The allowance is per credential and rolls forward continuously rather
                than resetting at midnight, and we do not publish the figure — it is an
                operational setting we tune, not a contract.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/DeliveryId'
            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: The delivery, put back at `pending` with `attempts` reset to zero.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RedriveWebhookDeliveryResponse'
                            example:
                                delivery:
                                    delivery_id: 6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f
                                    event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                                    event_type: booking.confirmed
                                    status: pending
                                    attempts: 0
                                    last_error_code: null
                                    last_response_status: null
                                    next_attempt_at: '2026-09-07T09:05:00.000Z'
                                    occurred_at: '2026-08-30T18:42:11.000Z'
                                    created_at: '2026-08-30T18:42:11.500Z'
                                    payload_present: true
                                    test: false
                                redrives_remaining_today: 4
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/DeliveryNotFound' }
                '409': { $ref: '#/components/responses/RedriveConflict' }
                '429': { $ref: '#/components/responses/RedriveBudgetExhausted' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /token:
        post:
            tags: [authentication]
            operationId: issuePartnerToken
            x-maat-scope: partner:token.issue
            x-maat-availability: sandbox
            summary: Exchange your API key for a token
            security:
                - partnerApiKey: []
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST https://maatstays.wtf/v1/token \
                        -H "Authorization: Bearer $MAAT_PARTNER_KEY"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/token', {
                          method: 'POST',
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_KEY}` },
                      });

                      if (!res.ok) {
                          // One indistinguishable 401 covers an unknown key, a revoked key and a
                          // wrong secret. Do not branch on it.
                          throw new Error(`token exchange failed: ${res.status}`);
                      }

                      const { access_token: token, expires_in: expiresIn } = await res.json();
                      console.log(token.slice(0, 12), expiresIn);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/token",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_KEY']}"},
                          timeout=30,
                      )
                      if res.status_code != 200:
                          raise RuntimeError(f"token exchange failed: {res.status_code}")

                      body = res.json()
                      print(body["access_token"][:12], body["expires_in"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type token struct {
                          AccessToken string `json:"access_token"`
                          ExpiresIn   int    `json:"expires_in"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodPost, "https://maatstays.wtf/v1/token", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_KEY"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode != http.StatusOK {
                              log.Fatalf("token exchange failed: %d", res.StatusCode)
                          }

                          var body token
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.AccessToken[:12], body.ExpiresIn)
                      }
            description: |
                Swap your long-lived API key (`maat_partner_…`) for a short-lived bearer token
                scoped to exactly one workspace and one environment. Send the token on every
                other call in this document.

                Tokens live for fifteen minutes by default and never for more than an hour. Mint
                one per batch of work, not one per request, and re-exchange on any `401`.

                **The request body is empty, and that is the control rather than a formality.**
                The workspace, the environment and the scopes on the token all come from the key
                you presented. The endpoint declares no fields at all, and anything you send is
                deleted before the handler runs — a request carrying a `workspace_id` or a
                `sandbox` flag mints exactly the token an empty request would. There is nothing
                you could send that would change what the token can reach.

                **Works against both kinds of workspace** — sandbox and production alike. It is
                how you authenticate, so it answers before anything else does, and it is the one
                operation in this document that never returns `501`. Read `sandbox` in the
                response to find out which kind of workspace your key was issued for; it is the
                only place that answer is published.
            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: A token, and what it can reach.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/TokenResponse'
                            example:
                                access_token: eyJhbGciOiJIUzI1NiIs…
                                token_type: Bearer
                                expires_in: 900
                                workspace_id: 3f6b0f2e-1c3a-4d5e-9a70-8b2c4d6e0f11
                                sandbox: true
                                scope:
                                    - partner:token.issue
                                    - catalog:read
                                    - calendar:read
                '401': { $ref: '#/components/responses/TokenUnauthorized' }
                '403':
                    description: |
                        The key verified, and it was not issued with `partner:token.issue`. It is
                        the only `403` this operation produces: it resolves no workspace, so
                        `workspace_scope_mismatch` cannot arise here. Ask whoever issued the key
                        for one that carries the scope — re-sending will not change the answer.
                    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' }
                    content:
                        application/problem+json:
                            schema:
                                $ref: '#/components/schemas/Problem'
                            example:
                                type: https://developer.maatstays.wtf/errors/insufficient-scope
                                title: Insufficient scope
                                status: 403
                                detail: Insufficient scope
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: insufficient_scope
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '503':
                    description: |
                        Token issuance is not configured on this platform. Not a fault and not a
                        rejection of your key — nothing was minted, and retrying is the right
                        thing to do.
                    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' }
                    content:
                        application/problem+json:
                            schema:
                                $ref: '#/components/schemas/Problem'
                            example:
                                type: https://developer.maatstays.wtf/errors/service-unavailable
                                title: Service unavailable
                                status: 503
                                detail: Token issuance is not configured on this platform yet.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: service_unavailable

    /properties:
        get:
            tags: [properties]
            operationId: listProperties
            x-maat-scope: catalog:read
            x-maat-availability: sandbox
            summary: List your properties
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/properties?page=1&page_size=100" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

                      for (let page = 1; ; page += 1) {
                          const url = new URL('https://maatstays.wtf/v1/properties');
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '100');

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

                          for (const property of body.items) {
                              console.log(property.property_id, property.publishable, property.missing);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      page = 1
                      while True:
                          res = requests.get(
                              "https://maatstays.wtf/v1/properties",
                              headers=headers,
                              params={"page": page, "page_size": 100},
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for property_ in body["items"]:
                              print(property_["property_id"], property_["publishable"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type property struct {
                          PropertyID  string   `json:"property_id"`
                          Publishable bool     `json:"publishable"`
                          Missing     []string `json:"missing"`
                      }

                      type page struct {
                          Items   []property `json:"items"`
                          HasMore bool       `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "100")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/properties?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatal(err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, p := range body.Items {
                                  fmt.Println(p.PropertyID, p.Publishable, p.Missing)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                One page of your workspace's properties, newest first.

                That is the whole workspace, including properties created in the Maat app that
                have no `provider_id` at all.

                Each entry carries its units and their listings, so a catalogue sync is one walk
                rather than one call per property. `publishable` and `missing` say whether each
                one satisfies the platform's validity gate — a price above zero, at least one
                amenity, a cover photo and a minimum photo count.

                Paged with `page` and `page_size`; keep asking while `has_more` is `true`. There
                is no total.

                **Live in a sandbox workspace. Returns `501` against a production one**, where
                nothing is read and nothing is disclosed.
            parameters:
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
            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 page of your properties.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PropertyListResponse'
                            example:
                                items:
                                    - property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                      provider_id: my-prop-9001
                                      property_type: apartment
                                      address:
                                          line1: 14 Example Street
                                          city: Alexandria
                                          region: Alexandria
                                          postal_code: '21500'
                                          country: EG
                                          latitude: 31.2001
                                          longitude: 29.9187
                                          building: '3'
                                          floor: '2'
                                          apartment: '12'
                                          directions: Blue door on the courtyard side; the bell is on the left.
                                      timezone: Africa/Cairo
                                      amenities:
                                          - wifi
                                          - air-conditioning
                                          - washing-machine
                                      units:
                                          - unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                            unit_type: apartment
                                            max_guests: 4
                                            bedrooms: 2
                                            beds: 3
                                            bathrooms: 1
                                            check_in_time: '15:00'
                                            check_out_time: '12:00'
                                            amenities:
                                                - wifi
                                                - air-conditioning
                                                - washing-machine
                                            house_rules:
                                                - slug: pets_allowed
                                                  allowed: true
                                                - slug: smoking_allowed
                                                  allowed: false
                                            listings:
                                                - listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                                  inventory_kind: unit
                                                  title: Courtyard flat with the blue door
                                                  description: A two-bedroom flat one street back from the corniche.
                                                  status: listed
                                                  base_rate:
                                                      value: '1250.00'
                                                      currency: EGP
                                                  cleaning_fee:
                                                      value: '200.00'
                                                      currency: EGP
                                                  instant_booking: true
                                                  is_exclusive: false
                                                  is_best_price: true
                                                  rating:
                                                      average: 4.8
                                                      count: 24
                                                  publishable: true
                                                  missing: []
                                                  console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                      publishable: true
                                      missing: []
                                      created_at: '2026-08-30T18:42:11Z'
                                      updated_at: '2026-09-02T09:15:40Z'
                                      console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                has_more: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /properties/by-id/{property_id}:
        get:
            tags: [properties]
            operationId: getProperty
            x-maat-scope: catalog:read
            x-maat-availability: sandbox
            summary: Read one property
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/properties/by-id/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/properties/by-id/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      console.log(body.property_type, body.units.length);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/properties/by-id/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["property_type"], len(body["units"]))
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          PropertyType string `json:"property_type"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/properties/by-id/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.PropertyType)
                      }
            description: |
                One property, with its units and their listings.

                Addressed by **Maat's** `property_id`, not by your own `provider_id`. Those are
                different questions and both are useful: `PUT /properties/{provider_id}` writes
                by your reference, and this reads by ours — which is the only handle a property
                created in the Maat app has.

                The `by-id` segment is what keeps those two apart. `/properties/{provider_id}`
                and `/properties/{property_id}` are the SAME URL template as far as OpenAPI is
                concerned, so one of them has to say which identifier it means. The write was
                published first and is not moving; the read says so in its path.

                `provider_id` in the response is **yours**. A property you did not push comes back
                with `null`.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/PropertyId'
            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: The property.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Property'
                            example:
                                property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                provider_id: my-prop-9001
                                property_type: apartment
                                address:
                                    line1: 14 Example Street
                                    city: Alexandria
                                    region: Alexandria
                                    postal_code: '21500'
                                    country: EG
                                    latitude: 31.2001
                                    longitude: 29.9187
                                    building: '3'
                                    floor: '2'
                                    apartment: '12'
                                    directions: Blue door on the courtyard side; the bell is on the left.
                                timezone: Africa/Cairo
                                amenities:
                                    - wifi
                                    - air-conditioning
                                    - washing-machine
                                units:
                                    - unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                      unit_type: apartment
                                      max_guests: 4
                                      bedrooms: 2
                                      beds: 3
                                      bathrooms: 1
                                      check_in_time: '15:00'
                                      check_out_time: '12:00'
                                      amenities:
                                          - wifi
                                          - air-conditioning
                                          - washing-machine
                                      house_rules:
                                          - slug: pets_allowed
                                            allowed: true
                                          - slug: smoking_allowed
                                            allowed: false
                                      listings:
                                          - listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                            inventory_kind: unit
                                            title: Courtyard flat with the blue door
                                            description: A two-bedroom flat one street back from the corniche.
                                            status: listed
                                            base_rate:
                                                value: '1250.00'
                                                currency: EGP
                                            cleaning_fee:
                                                value: '200.00'
                                                currency: EGP
                                            instant_booking: true
                                            is_exclusive: false
                                            is_best_price: true
                                            rating:
                                                average: 4.8
                                                count: 24
                                            publishable: true
                                            missing: []
                                            console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                publishable: true
                                missing: []
                                created_at: '2026-08-30T18:42:11Z'
                                updated_at: '2026-09-02T09:15:40Z'
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/PropertyNotFound' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /properties/{property_id}/address:
        put:
            tags: [properties]
            operationId: replacePropertyAddress
            x-maat-scope: catalog:write
            x-maat-availability: sandbox
            summary: Replace a property's address
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/address \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{
                          "line1": "14 Example Street",
                          "city": "Alexandria",
                          "region": "Alexandria",
                          "postal_code": "21500",
                          "country": "EG",
                          "latitude": 31.2001,
                          "longitude": 29.9187,
                          "building": "Nile Court",
                          "floor": "G",
                          "apartment": "3A",
                          "directions": "Blue door beside the pharmacy."
                        }'
                - lang: javascript
                  label: Node.js
                  source: |
                      // Every member is written every time — one you omit is cleared.
                      const address = {
                          line1: '14 Example Street',
                          city: 'Alexandria',
                          region: 'Alexandria',
                          postal_code: '21500',
                          country: 'EG',
                          latitude: 31.2001,
                          longitude: 29.9187,
                          building: 'Nile Court',
                          floor: 'G',
                          apartment: '3A',
                          directions: 'Blue door beside the pharmacy.',
                      };

                      const res = await fetch(
                          'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/address',
                          {
                              method: 'PUT',
                              headers: {
                                  Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                                  'Content-Type': 'application/json',
                              },
                              body: JSON.stringify(address),
                          },
                      );

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

                      // The time zone and the area come back re-derived from the coordinates.
                      console.log(body.address.city, body.timezone);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      # Every member is written every time — one you omit is cleared.
                      address = {
                          "line1": "14 Example Street",
                          "city": "Alexandria",
                          "region": "Alexandria",
                          "postal_code": "21500",
                          "country": "EG",
                          "latitude": 31.2001,
                          "longitude": 29.9187,
                          "building": "Nile Court",
                          "floor": "G",
                          "apartment": "3A",
                          "directions": "Blue door beside the pharmacy.",
                      }

                      res = requests.put(
                          "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/address",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json=address,
                          timeout=30,
                      )

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

                      print(body["address"]["city"], body["timezone"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type property struct {
                          Timezone string `json:"timezone"`
                      }

                      func main() {
                          // Every member is written every time — one you omit is cleared.
                          payload, _ := json.Marshal(map[string]any{
                              "line1":       "14 Example Street",
                              "city":        "Alexandria",
                              "region":      "Alexandria",
                              "postal_code": "21500",
                              "country":     "EG",
                              "latitude":    31.2001,
                              "longitude":   29.9187,
                              "building":    "Nile Court",
                              "floor":       "G",
                              "apartment":   "3A",
                              "directions":  "Blue door beside the pharmacy.",
                          })

                          req, _ := http.NewRequest(http.MethodPut,
                              "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/address",
                              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("address write failed: %d", res.StatusCode)
                          }

                          var body property
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.Timezone)
                      }
            description: |
                Replaces the whole address of a property.

                **Replace, not merge.** Every member is written on every call — as the value you
                sent, or as `null`. Omitting one CLEARS it, and that is deliberate: a stale floor
                number left under a new street sends a guest to the right building and the wrong
                door.

                The four private delivery fields — `building`, `floor`, `apartment`,
                `directions` — are written **here**, and are absent from the property push. They
                tell a guest which bell to ring once a stay is confirmed, and they are replaced
                on every call like everything else in the body.

                Three things are re-derived from the coordinates in the body and are not yours to
                send: the map position, the platform's area assignment and the property's time
                zone. A coordinate pair that falls outside every curated area is **not an error**
                — the area is simply left unset.

                Text is normalised before it is stored, and its length is re-checked afterwards:
                control characters, zero-width characters and bidirectional-override characters
                are removed, and runs of whitespace are collapsed. A value that normalises to
                nothing is refused rather than stored as an empty string.

                The body is strict. An undeclared field is a `400 validation_failed` naming it,
                never a silent drop, and a stray query string is refused the same way.

                A property with more than one unit, or more than one listing, is refused with
                `409 property_not_simple`; a listing that is not a stay with
                `409 inventory_not_supported`. Nothing is written either way.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/PropertyId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/AddressWriteRequest'
                        example:
                            line1: 14 Example Street
                            city: Alexandria
                            region: Alexandria
                            postal_code: '21500'
                            country: EG
                            latitude: 31.2001
                            longitude: 29.9187
                            building: Nile Court
                            floor: G
                            apartment: 3A
                            directions: Blue door beside the pharmacy.
            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: The property, read back after the change.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Property'
                            example:
                                property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                provider_id: my-prop-9001
                                property_type: apartment
                                address:
                                    line1: 14 Example Street
                                    city: Alexandria
                                    region: Alexandria
                                    postal_code: '21500'
                                    country: EG
                                    latitude: 31.2001
                                    longitude: 29.9187
                                    building: '3'
                                    floor: '2'
                                    apartment: '12'
                                    directions: Blue door on the courtyard side; the bell is on the left.
                                timezone: Africa/Cairo
                                amenities:
                                    - wifi
                                    - air-conditioning
                                    - washing-machine
                                units:
                                    - unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                      unit_type: apartment
                                      max_guests: 4
                                      bedrooms: 2
                                      beds: 3
                                      bathrooms: 1
                                      check_in_time: '15:00'
                                      check_out_time: '12:00'
                                      amenities:
                                          - wifi
                                          - air-conditioning
                                          - washing-machine
                                      house_rules:
                                          - slug: pets_allowed
                                            allowed: true
                                          - slug: smoking_allowed
                                            allowed: false
                                      listings:
                                          - listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                            inventory_kind: unit
                                            title: Courtyard flat with the blue door
                                            description: A two-bedroom flat one street back from the corniche.
                                            status: listed
                                            base_rate:
                                                value: '1250.00'
                                                currency: EGP
                                            cleaning_fee:
                                                value: '200.00'
                                                currency: EGP
                                            instant_booking: true
                                            is_exclusive: false
                                            is_best_price: true
                                            rating:
                                                average: 4.8
                                                count: 24
                                            publishable: true
                                            missing: []
                                            console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                publishable: true
                                missing: []
                                created_at: '2026-08-30T18:42:11Z'
                                updated_at: '2026-09-02T09:15:40Z'
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/PropertyNotFound' }
                '409': { $ref: '#/components/responses/WriteRefused' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /properties/{property_id}/amenities:
        put:
            tags: [properties]
            operationId: replacePropertyAmenities
            x-maat-scope: catalog:write
            x-maat-availability: sandbox
            summary: Replace a property's amenities
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"amenities": ["wifi", "air-conditioning", "washing-machine"]}'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities',
                          {
                              method: 'PUT',
                              headers: {
                                  Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                                  'Content-Type': 'application/json',
                              },
                              body: JSON.stringify({
                                  amenities: ['wifi', 'air-conditioning', 'washing-machine'],
                              }),
                          },
                      );

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

                      // `amenities` is what is now stored; anything we could not map is echoed.
                      console.log(body.amenities, body.unknown_amenities);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.put(
                          "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={"amenities": ["wifi", "air-conditioning", "washing-machine"]},
                          timeout=30,
                      )

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

                      # `amenities` is what is now stored; anything we could not map is echoed.
                      print(body["amenities"], body["unknown_amenities"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type amenitySet struct {
                          Amenities        []string `json:"amenities"`
                          UnknownAmenities []string `json:"unknown_amenities"`
                      }

                      func main() {
                          payload, _ := json.Marshal(map[string]any{
                              "amenities": []string{"wifi", "air-conditioning", "washing-machine"},
                          })

                          req, _ := http.NewRequest(http.MethodPut,
                              "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/amenities",
                              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("amenity write failed: %d", res.StatusCode)
                          }

                          var body amenitySet
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.Amenities, body.UnknownAmenities)
                      }
            description: |
                Replaces the amenity set of the property's single unit — the set Maat's own
                validity gate counts, and the same set a whole-property push replaces.

                **Replace, not merge.** A slug you omit is removed. Send at least one and at most
                sixty, with no repeats. The vocabulary is `GET /catalog/amenities`; map yours onto
                it once at the start of a sync rather than discovering it one property at a
                time.

                **An unknown slug is echoed, not refused.** `amenities` in the response is the set
                now stored — only what resolved — and `unknown_amenities` is the rest. One
                unmapped slug does not block an otherwise good property while you finish your
                mapping.

                **If none of them resolved, the whole call is a `400` and nothing is written.**
                Applying that result would clear the set, and a property with no amenity fails
                the validity gate — so an unmapped vocabulary would take a live listing off sale
                as a side effect of an edit nobody meant that way.

                The body is strict. An undeclared field is a `400 validation_failed` naming it,
                never a silent drop, and a stray query string is refused the same way.

                A property with more than one unit, or more than one listing, is refused with
                `409 property_not_simple`; a listing that is not a stay with
                `409 inventory_not_supported`. Nothing is written either way.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/PropertyId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/AmenitySetRequest'
                        example:
                            amenities: [wifi, air-conditioning, washing-machine]
            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: The amenity set now stored, and anything that did not resolve.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/AmenitySetResponse'
                            example:
                                amenities: [wifi, air-conditioning, washing-machine]
                                unknown_amenities: []
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/PropertyNotFound' }
                '409': { $ref: '#/components/responses/WriteRefused' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /properties/{property_id}/photos:
        get:
            tags: [photos]
            operationId: listPropertyPhotos
            x-maat-scope: catalog:read
            x-maat-availability: sandbox
            summary: List a property's photos
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos',
                          { headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` } },
                      );

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

                      for (const photo of body.items) {
                          console.log(photo.position, photo.state, photo.url ?? photo.rejection_reason);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )

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

                      for photo in body["items"]:
                          print(photo["position"], photo["state"], photo["url"] or photo["rejection_reason"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type photo struct {
                          PhotoID         string  `json:"photo_id"`
                          Position        int     `json:"position"`
                          IsCover         bool    `json:"is_cover"`
                          State           string  `json:"state"`
                          RejectionReason *string `json:"rejection_reason"`
                          URL             *string `json:"url"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet,
                              "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos",
                              nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("photo read failed: %d", res.StatusCode)
                          }

                          var body struct {
                              Items []photo `json:"items"`
                          }
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          for _, p := range body.Items {
                              fmt.Println(p.Position, p.State, p.URL)
                          }
                      }
            description: |
                Every photo on the property, in the order guests see them. Position `0` is the
                cover.

                **This is how you follow an upload.** A photo you presigned appears here
                immediately as `pending`; it becomes `ready` once we have processed and reviewed
                it, or `rejected` with a reason from a closed vocabulary. Poll this rather than
                assuming a successful upload means a published image — the review happens after
                you are gone.

                `url` is the published image and is `null` in every other state. A photo our
                automated review declined reads as `rejected`, not `ready`: its file exists and
                no guest will ever be shown it.

                A property with more than one unit, or more than one listing, is refused with
                `409 property_not_simple`.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/PropertyId'
            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: The property's photos, cover first.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PropertyPhotoListResponse'
                            example:
                                items:
                                    - photo_id: 0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94
                                      position: 0
                                      is_cover: true
                                      state: ready
                                      rejection_reason: null
                                      url: https://cdn.maatstays.wtf/c/0f2b9c81.webp
                                    - photo_id: 5d3a7e12-9b48-4c05-8f61-2a7d0e4b93c6
                                      position: 1
                                      is_cover: false
                                      state: pending
                                      rejection_reason: null
                                      url: null
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/PropertyNotFound' }
                '409': { $ref: '#/components/responses/ReadRefused' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /properties/{property_id}/photos/presign:
        post:
            tags: [photos]
            operationId: presignPropertyPhotos
            x-maat-scope: media:write
            x-maat-availability: sandbox
            summary: Get upload forms for new photos
            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)
                      }
            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.**
            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' }

    /properties/{property_id}/photos/order:
        put:
            tags: [photos]
            operationId: replacePropertyPhotoOrder
            x-maat-scope: media:write
            x-maat-availability: sandbox
            summary: Arrange a property's photos
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/order \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"photo_ids": ["0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94", "9a4e77b0-1c2d-4e5f-8a91-3b6c0d7e2f45"]}'
                - lang: javascript
                  label: Node.js
                  source: |
                      // Read first: the list has to be COMPLETE, so build it from what we hold.
                      const listed = await fetch(
                          'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos',
                          { headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` } },
                      ).then((r) => r.json());

                      const ready = listed.items.filter((p) => p.state === 'ready').map((p) => p.photo_id);
                      const [promote, ...rest] = ready.reverse();

                      const res = await fetch(
                          'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/order',
                          {
                              method: 'PUT',
                              headers: {
                                  Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                                  'Content-Type': 'application/json',
                              },
                              body: JSON.stringify({ photo_ids: [promote, ...rest] }),
                          },
                      );

                      const body = await res.json();
                      if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
                      console.log(body.items[0].photo_id, body.items[0].is_cover);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      base = "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos"
                      auth = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      # Read first: the list has to be COMPLETE, so build it from what we hold.
                      listed = requests.get(base, headers=auth, timeout=30).json()
                      ready = [p["photo_id"] for p in listed["items"] if p["state"] == "ready"]
                      ordered = list(reversed(ready))

                      res = requests.put(
                          f"{base}/order", headers=auth, json={"photo_ids": ordered}, timeout=30
                      )

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

                      print(body["items"][0]["photo_id"], body["items"][0]["is_cover"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      const base = "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos"

                      type photo struct {
                          PhotoID string `json:"photo_id"`
                          IsCover bool   `json:"is_cover"`
                          State   string `json:"state"`
                      }

                      type strip struct {
                          Items []photo `json:"items"`
                      }

                      func send(method, url string, payload []byte) strip {
                          req, _ := http.NewRequest(method, url, bytes.NewReader(payload))
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))
                          if payload != nil {
                              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("%s %s failed: %d", method, url, res.StatusCode)
                          }

                          var body strip
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          return body
                      }

                      func main() {
                          // Read first: the list has to be COMPLETE, so build it from what we hold.
                          listed := send(http.MethodGet, base, nil)

                          ready := []string{}
                          for _, p := range listed.Items {
                              if p.State == "ready" {
                                  ready = append(ready, p.PhotoID)
                              }
                          }
                          for i, j := 0, len(ready)-1; i < j; i, j = i+1, j-1 {
                              ready[i], ready[j] = ready[j], ready[i]
                          }

                          payload, _ := json.Marshal(map[string]any{"photo_ids": ready})
                          ordered := send(http.MethodPut, base+"/order", payload)
                          fmt.Println(ordered.Items[0].PhotoID, ordered.Items[0].IsCover)
                      }
            description: |
                Put the property's photos in the order guests should see them. **Index 0 becomes
                the cover**, everywhere it is shown.

                **Send the COMPLETE set of `ready` photos, each exactly once.** A partial list is
                refused rather than half-applied: the positions of everything you did not
                mention would otherwise have to be invented by us. Omitting one, repeating one,
                or naming one that is not `ready` are all refusals that write nothing, and each
                names what was wrong.

                Photos that are not `ready` are not orderable — they have no image yet, or they
                have one nobody may see. They keep their places after the ordered ones and join
                the sequence when they settle.

                Answers with the whole strip as `GET …/photos` publishes it.

                Reordering does not change whether the property is on sale, in either
                direction. Publication is `PATCH /v1/listings/{listing_id}` with
                `status: "listed"`.

                A property you create with `PUT /properties/{provider_id}` is refused with
                `409 conflict` — your next push would undo the change. Send its photo URLs on the
                push instead.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/PropertyId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/PhotoOrderRequest'
                        example:
                            photo_ids:
                                - 0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94
                                - 9a4e77b0-1c2d-4e5f-8a91-3b6c0d7e2f45
            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: The property's photos in their new order.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PropertyPhotoListResponse'
                            example:
                                items:
                                    - photo_id: 0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94
                                      position: 0
                                      is_cover: true
                                      state: ready
                                      rejection_reason: null
                                      url: https://cdn.maatstays.wtf/c/0f2b9c81.webp
                                    - photo_id: 5d3a7e12-9b48-4c05-8f61-2a7d0e4b93c6
                                      position: 1
                                      is_cover: false
                                      state: pending
                                      rejection_reason: null
                                      url: null
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/PhotoOrderNotFound' }
                '409': { $ref: '#/components/responses/PhotoWriteRefused' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /properties/{property_id}/photos/{photo_id}:
        delete:
            tags: [photos]
            operationId: deletePropertyPhoto
            x-maat-scope: media:write
            x-maat-availability: sandbox
            summary: Remove a photo
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X DELETE https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94 \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/photos/0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94',
                          {
                              method: 'DELETE',
                              headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                          },
                      );

                      // 204 whether or not it was still there — safe to retry.
                      if (res.status !== 204) {
                          const body = await res.json();
                          throw new Error(`${body.code}: ${body.detail}`);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.delete(
                          "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7"
                          "/photos/0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )

                      # 204 whether or not it was still there — safe to retry.
                      if res.status_code != 204:
                          body = res.json()
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")
                - lang: go
                  label: Go
                  source: |
                      package main

                      import (
                          "log"
                          "net/http"
                          "os"
                      )

                      func main() {
                          req, _ := http.NewRequest(http.MethodDelete,
                              "https://maatstays.wtf/v1/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7"+
                                  "/photos/0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94",
                              nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          // 204 whether or not it was still there — safe to retry.
                          if res.StatusCode != http.StatusNoContent {
                              log.Fatalf("delete failed: %d", res.StatusCode)
                          }
                      }
            description: |
                Removes the photo and closes the gap behind it, so positions stay dense.

                **If you delete the cover, position 0 of what remains becomes the cover.** That
                is the same rule the Maat apps follow.

                `204` whether or not the photo was still there. Deleting something already gone
                is the end state you asked for, so a retried or redelivered call is safe.

                Deleting a photo never takes a listing off sale, even if the property drops
                below the photo floor as a result — that would be a change you did not ask for.
                Nor does any photo operation put one ON sale: publication is
                `PATCH /v1/listings/{listing_id}` with `status: "listed"`, which is where the
                validity gate runs. `GET /properties/by-id/{property_id}` reports what the
                property is missing at any time.

                A property you create with `PUT /properties/{provider_id}` is refused with
                `409 conflict` — your next push would undo the change. Send its photo URLs on the
                push instead.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/PropertyId'
                - $ref: '#/components/parameters/PhotoId'
            responses:
                '204':
                    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 photo is gone.
                '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/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /units/{unit_id}:
        get:
            tags: [units]
            operationId: getUnit
            x-maat-scope: catalog:read
            x-maat-availability: sandbox
            summary: Read one unit
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      console.log(body.max_guests, body.house_rules);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["max_guests"], body["house_rules"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          MaxGuests int `json:"max_guests"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.MaxGuests)
                      }
            description: |
                One physical unit: how many it sleeps, in what, its door times, its amenities and
                the host's answer to every house rule.

                A `403` on this operation carries one of two codes — `insufficient_scope` (the
                key lacks `catalog:read`) or `workspace_scope_mismatch` (a sandbox/production
                mismatch). Branch on `code`.

                `house_rules` carries only rules somebody actually answered. A rule that is absent
                was never asked — it does not mean "no".

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/UnitId'
            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: The unit.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Unit'
                            example:
                                unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                unit_type: apartment
                                max_guests: 4
                                bedrooms: 2
                                beds: 3
                                bathrooms: 1
                                check_in_time: '15:00'
                                check_out_time: '12:00'
                                amenities:
                                    - wifi
                                    - air-conditioning
                                    - washing-machine
                                house_rules:
                                    - slug: pets_allowed
                                      allowed: true
                                    - slug: smoking_allowed
                                      allowed: false
                                listings:
                                    - listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                      inventory_kind: unit
                                      title: Courtyard flat with the blue door
                                      description: A two-bedroom flat one street back from the corniche.
                                      status: listed
                                      base_rate:
                                          value: '1250.00'
                                          currency: EGP
                                      cleaning_fee:
                                          value: '200.00'
                                          currency: EGP
                                      instant_booking: true
                                      is_exclusive: false
                                      is_best_price: true
                                      rating:
                                          average: 4.8
                                          count: 24
                                      publishable: true
                                      missing: []
                                      console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/UnitNotFound' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

        patch:
            tags: [units]
            operationId: patchUnit
            x-maat-scope: catalog:write
            x-maat-availability: sandbox
            summary: Change what a unit is
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PATCH https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804 \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"max_guests": 5, "beds": 4, "check_in_time": "15:00"}'
                - lang: javascript
                  label: Node.js
                  source: |
                      // Partial: an absent field is untouched. Send at least one.
                      const res = await fetch(
                          'https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804',
                          {
                              method: 'PATCH',
                              headers: {
                                  Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                                  'Content-Type': 'application/json',
                              },
                              body: JSON.stringify({ max_guests: 5, beds: 4, check_in_time: '15:00' }),
                          },
                      );

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

                      console.log(body.max_guests, body.beds, body.check_in_time);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      # Partial: an absent field is untouched. Send at least one.
                      res = requests.patch(
                          "https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={"max_guests": 5, "beds": 4, "check_in_time": "15:00"},
                          timeout=30,
                      )

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

                      print(body["max_guests"], body["beds"], body["check_in_time"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type unit struct {
                          MaxGuests int `json:"max_guests"`
                          Beds      int `json:"beds"`
                      }

                      func main() {
                          // Partial: an absent field is untouched. Send at least one.
                          payload, _ := json.Marshal(map[string]any{
                              "max_guests":    5,
                              "beds":          4,
                              "check_in_time": "15:00",
                          })

                          req, _ := http.NewRequest(http.MethodPatch,
                              "https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804",
                              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("unit patch failed: %d", res.StatusCode)
                          }

                          var body unit
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.MaxGuests, body.Beds)
                      }
            description: |
                Changes what a unit **is**: how many it sleeps, in what, whether it is the whole
                property or a room inside one, and its door times.

                **Partial.** An absent field is untouched — there is no merge engine and nothing
                here is nullable, because every one of these columns is required on the platform.
                Send at least one field: an empty body is a `400`, not a silent no-op that reads
                as success.

                The counts are **integers**. A `bathrooms` of `2.5` is refused rather than
                truncated into a different property than you described; half-bathrooms are not
                expressible here.

                A `403` on this operation carries one of two codes — `insufficient_scope` (the
                key lacks `catalog:write`) or `workspace_scope_mismatch` (a sandbox/production
                mismatch). Branch on `code`.

                Setting `unit_type` to `whole_property` on a property that has another unit is
                refused with `409 conflict`: the whole property cannot also contain a second one.

                The body is strict. An undeclared field is a `400 validation_failed` naming it,
                never a silent drop, and a stray query string is refused the same way.

                A property with more than one unit, or more than one listing, is refused with
                `409 property_not_simple`; a listing that is not a stay with
                `409 inventory_not_supported`. Nothing is written either way.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/UnitId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/UnitPatchRequest'
                        example:
                            max_guests: 5
                            beds: 4
                            check_in_time: '15:00'
            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: The unit, read back after the change.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Unit'
                            example:
                                unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                unit_type: apartment
                                max_guests: 4
                                bedrooms: 2
                                beds: 3
                                bathrooms: 1
                                check_in_time: '15:00'
                                check_out_time: '12:00'
                                amenities:
                                    - wifi
                                    - air-conditioning
                                    - washing-machine
                                house_rules:
                                    - slug: pets_allowed
                                      allowed: true
                                    - slug: smoking_allowed
                                      allowed: false
                                listings:
                                    - listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                      inventory_kind: unit
                                      title: Courtyard flat with the blue door
                                      description: A two-bedroom flat one street back from the corniche.
                                      status: listed
                                      base_rate:
                                          value: '1250.00'
                                          currency: EGP
                                      cleaning_fee:
                                          value: '200.00'
                                          currency: EGP
                                      instant_booking: true
                                      is_exclusive: false
                                      is_best_price: true
                                      rating:
                                          average: 4.8
                                          count: 24
                                      publishable: true
                                      missing: []
                                      console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/UnitNotFound' }
                '409': { $ref: '#/components/responses/WriteRefused' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /units/{unit_id}/house-rules:
        put:
            tags: [units]
            operationId: replaceUnitHouseRules
            x-maat-scope: catalog:write
            x-maat-availability: sandbox
            summary: Replace a unit's house-rule answers
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804/house-rules \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{
                          "answers": [
                            { "slug": "pets_allowed", "allowed": true },
                            { "slug": "smoking_allowed", "allowed": false }
                          ],
                          "check_in_time": "15:00",
                          "check_out_time": "12:00"
                        }'
                - lang: javascript
                  label: Node.js
                  source: |
                      // Send the rules you decided; every other active rule is written `false`.
                      const res = await fetch(
                          'https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804/house-rules',
                          {
                              method: 'PUT',
                              headers: {
                                  Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                                  'Content-Type': 'application/json',
                              },
                              body: JSON.stringify({
                                  answers: [
                                      { slug: 'pets_allowed', allowed: true },
                                      { slug: 'smoking_allowed', allowed: false },
                                  ],
                                  check_in_time: '15:00',
                                  check_out_time: '12:00',
                              }),
                          },
                      );

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

                      console.log(body.house_rules, body.check_in_time);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      # Send the rules you decided; every other active rule is written False.
                      res = requests.put(
                          "https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804/house-rules",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={
                              "answers": [
                                  {"slug": "pets_allowed", "allowed": True},
                                  {"slug": "smoking_allowed", "allowed": False},
                              ],
                              "check_in_time": "15:00",
                              "check_out_time": "12:00",
                          },
                          timeout=30,
                      )

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

                      print(body["house_rules"], body["check_in_time"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type answer struct {
                          Slug    string `json:"slug"`
                          Allowed bool   `json:"allowed"`
                      }

                      type unit struct {
                          HouseRules  []answer `json:"house_rules"`
                          CheckInTime string   `json:"check_in_time"`
                      }

                      func main() {
                          // Send the rules you decided; every other active rule is written false.
                          payload, _ := json.Marshal(map[string]any{
                              "answers": []answer{
                                  {Slug: "pets_allowed", Allowed: true},
                                  {Slug: "smoking_allowed", Allowed: false},
                              },
                              "check_in_time":  "15:00",
                              "check_out_time": "12:00",
                          })

                          req, _ := http.NewRequest(http.MethodPut,
                              "https://maatstays.wtf/v1/units/8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804/house-rules",
                              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("house-rule write failed: %d", res.StatusCode)
                          }

                          var body unit
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(len(body.HouseRules), body.CheckInTime)
                      }
            description: |
                Replaces a unit's house-rule answers, and optionally its door times.

                **You send the rules you decided; we expand the rest.** Every active rule in
                Maat's catalogue that you did not name is written `allowed: false`. That is what
                makes this a set replace rather than a merge, and it is what keeps a rule with no
                answer meaning **"never asked"** on the read side rather than quietly meaning
                "no". Absence has to keep meaning what it says.

                An **empty `answers` array is legitimate**, and it means "nothing is allowed". It
                is not the same as never having answered.

                A slug the catalogue does not have — **including a retired one** — refuses the
                whole call with `400`, naming the slugs. `GET /catalog/house-rules` publishes
                every rule and flags a retired one: keep a retired entry to explain an answer you
                read back, never to write a new one.

                **`check_in_time` and `check_out_time` are not rules.** A time is a value, not a
                permission, and both are columns on the unit. They ride this request because they
                are written in the **same transaction** as the answers, so a half-applied edit
                where the times saved and the answers rolled back cannot happen. Omit them to
                leave the stored times alone.

                At most a hundred answers, and no repeated slug.

                A `403` on this operation carries one of two codes — `insufficient_scope` or
                `workspace_scope_mismatch`. Branch on `code`.

                The body is strict. An undeclared field is a `400 validation_failed` naming it,
                never a silent drop, and a stray query string is refused the same way.

                A property with more than one unit, or more than one listing, is refused with
                `409 property_not_simple`; a listing that is not a stay with
                `409 inventory_not_supported`. Nothing is written either way.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/UnitId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/HouseRulesWriteRequest'
                        example:
                            answers:
                                - slug: pets_allowed
                                  allowed: true
                                - slug: smoking_allowed
                                  allowed: false
                            check_in_time: '15:00'
                            check_out_time: '12:00'
            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: The unit, read back after the change.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Unit'
                            example:
                                unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                unit_type: apartment
                                max_guests: 4
                                bedrooms: 2
                                beds: 3
                                bathrooms: 1
                                check_in_time: '15:00'
                                check_out_time: '12:00'
                                amenities:
                                    - wifi
                                    - air-conditioning
                                    - washing-machine
                                house_rules:
                                    - slug: pets_allowed
                                      allowed: true
                                    - slug: smoking_allowed
                                      allowed: false
                                listings:
                                    - listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                      inventory_kind: unit
                                      title: Courtyard flat with the blue door
                                      description: A two-bedroom flat one street back from the corniche.
                                      status: listed
                                      base_rate:
                                          value: '1250.00'
                                          currency: EGP
                                      cleaning_fee:
                                          value: '200.00'
                                          currency: EGP
                                      instant_booking: true
                                      is_exclusive: false
                                      is_best_price: true
                                      rating:
                                          average: 4.8
                                          count: 24
                                      publishable: true
                                      missing: []
                                      console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/UnitNotFound' }
                '409': { $ref: '#/components/responses/WriteRefused' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}:
        get:
            tags: [listings]
            operationId: getListing
            x-maat-scope: catalog:read
            x-maat-availability: sandbox
            summary: Read one listing
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      console.log(body.title, body.base_rate.value, body.base_rate.currency);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["title"], body["base_rate"]["value"], body["base_rate"]["currency"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Title string `json:"title"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.Title)
                      }
            description: |
                One listing — the sellable view of a unit: its title, its rate, its badges and
                whether it is on sale right now.

                **A listing that is not a stay is returned, with its kind.** `inventory_kind` is
                `unit` for a place somebody sleeps in, and `service` or `experience` for something
                sold by the seat. Answering `404` for those would be false — the listing exists,
                in your workspace. The write contracts are where a non-stay listing is refused.

                For such a listing `publishable` is `null`, meaning **nothing was assessed**
                rather than "assessed and failed": the validity gate asks about cover photos and
                amenities, and a seat on a walking tour has no answer to that.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            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: The listing.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Listing'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                inventory_kind: unit
                                title: Courtyard flat with the blue door
                                description: A two-bedroom flat one street back from the corniche.
                                status: listed
                                base_rate:
                                    value: '1250.00'
                                    currency: EGP
                                cleaning_fee:
                                    value: '200.00'
                                    currency: EGP
                                instant_booking: true
                                is_exclusive: false
                                is_best_price: true
                                rating:
                                    average: 4.8
                                    count: 24
                                publishable: true
                                missing: []
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

        patch:
            tags: [listings]
            operationId: patchListing
            x-maat-scope: catalog:write
            x-maat-availability: sandbox
            summary: Change a listing, and whether it is on sale
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PATCH https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24 \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"title": "Courtyard flat with the blue door", "status": "listed"}'
                - lang: javascript
                  label: Node.js
                  source: |
                      // Partial: an absent field is untouched. Send at least one.
                      const res = await fetch(
                          'https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24',
                          {
                              method: 'PATCH',
                              headers: {
                                  Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                                  'Content-Type': 'application/json',
                              },
                              body: JSON.stringify({
                                  title: 'Courtyard flat with the blue door',
                                  status: 'listed',
                              }),
                          },
                      );

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

                      // A `listed` request the gate refused is still a 200. Read the verdict.
                      if (!body.publishable) console.log('not published:', body.missing);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      # Partial: an absent field is untouched. Send at least one.
                      res = requests.patch(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={"title": "Courtyard flat with the blue door", "status": "listed"},
                          timeout=30,
                      )

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

                      # A "listed" request the gate refused is still a 200. Read the verdict.
                      if not body["publishable"]:
                          print("not published:", body["missing"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type listing struct {
                          Status      string   `json:"status"`
                          Publishable *bool    `json:"publishable"`
                          Missing     []string `json:"missing"`
                      }

                      func main() {
                          // Partial: an absent field is untouched. Send at least one.
                          payload, _ := json.Marshal(map[string]any{
                              "title":  "Courtyard flat with the blue door",
                              "status": "listed",
                          })

                          req, _ := http.NewRequest(http.MethodPatch,
                              "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                              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("listing patch failed: %d", res.StatusCode)
                          }

                          var body listing
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          // A "listed" request the gate refused is still a 200. Read the verdict.
                          if body.Publishable == nil || !*body.Publishable {
                              fmt.Println("not published:", body.Status, body.Missing)
                          }
                      }
            description: |
                Changes the sellable view of a unit: its title, its description, its badges, and
                whether it is on sale.

                **Partial.** An absent field is untouched. `description` is the one member that
                accepts `null`, because "this listing has no description" is a real state you
                must be able to reach. Send at least one field: an empty body is a `400`.

                **`status` is not a column write.**

                `listed` runs Maat's own validity gate — a price above zero, at least one
                amenity, a cover photo and a minimum photo count — and publishes the listing only
                if it passes. **If it does not pass, the answer is still `200`**: the returned
                listing's `publishable` and `missing` say why, and **nothing about whether the
                listing is on sale is changed**. Acceptance is not publication. A listing already
                on sale is not taken off it as a side effect of a request that asked for the
                opposite.

                `unlisted` holds the listing off sale.

                A listing **Maat has suspended for its own reasons** stays off sale through a
                `listed` request. So `publishable` can read `true` while `status` reads
                `unlisted` — that is not a contradiction, and it is not something this operation
                can clear. Contact the workspace.

                Text is normalised before it is stored, and its length is re-checked afterwards:
                control characters, zero-width characters and bidirectional-override characters
                are removed, and runs of whitespace are collapsed. A title that normalises to
                nothing is a `400`.

                The body is strict. An undeclared field is a `400 validation_failed` naming it,
                never a silent drop, and a stray query string is refused the same way.

                A property with more than one unit, or more than one listing, is refused with
                `409 property_not_simple`; a listing that is not a stay with
                `409 inventory_not_supported`. Nothing is written either way.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ListingPatchRequest'
                        example:
                            title: Courtyard flat with the blue door
                            status: listed
            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: |
                        The listing, read back after the change. A `listed` request the validity
                        gate refused lands here too — read `publishable` and `missing`.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Listing'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                inventory_kind: unit
                                title: Courtyard flat with the blue door
                                description: A two-bedroom flat one street back from the corniche.
                                status: listed
                                base_rate:
                                    value: '1250.00'
                                    currency: EGP
                                cleaning_fee:
                                    value: '200.00'
                                    currency: EGP
                                instant_booking: true
                                is_exclusive: false
                                is_best_price: true
                                rating:
                                    average: 4.8
                                    count: 24
                                publishable: true
                                missing: []
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/WriteRefused' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/pricing:
        put:
            tags: [listings]
            operationId: replaceListingPricing
            x-maat-scope: pricing:write
            x-maat-availability: sandbox
            summary: Replace a listing's price
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{
                          "base_rate": { "value": "1250.00", "currency": "EGP" },
                          "cleaning_fee": { "value": "150.00", "currency": "EGP" }
                        }'
                - lang: javascript
                  label: Node.js
                  source: |
                      // A replace: omitting `cleaning_fee` sets it to zero.
                      const res = await fetch(
                          'https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing',
                          {
                              method: 'PUT',
                              headers: {
                                  Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                                  'Content-Type': 'application/json',
                              },
                              body: JSON.stringify({
                                  base_rate: { value: '1250.00', currency: 'EGP' },
                                  cleaning_fee: { value: '150.00', currency: 'EGP' },
                              }),
                          },
                      );

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

                      console.log(body.base_rate.value, body.base_rate.currency);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      # A replace: omitting "cleaning_fee" sets it to zero.
                      res = requests.put(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={
                              "base_rate": {"value": "1250.00", "currency": "EGP"},
                              "cleaning_fee": {"value": "150.00", "currency": "EGP"},
                          },
                          timeout=30,
                      )

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

                      print(body["base_rate"]["value"], body["base_rate"]["currency"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type money struct {
                          Value    string `json:"value"`
                          Currency string `json:"currency"`
                      }

                      type listing struct {
                          BaseRate money `json:"base_rate"`
                      }

                      func main() {
                          // A replace: omitting "cleaning_fee" sets it to zero.
                          payload, _ := json.Marshal(map[string]any{
                              "base_rate":    money{Value: "1250.00", Currency: "EGP"},
                              "cleaning_fee": money{Value: "150.00", Currency: "EGP"},
                          })

                          req, _ := http.NewRequest(http.MethodPut,
                              "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing",
                              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("pricing write failed: %d", res.StatusCode)
                          }

                          var body listing
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.BaseRate.Value, body.BaseRate.Currency)
                      }
            description: |
                Replaces a listing's price: the nightly rate before any rule, fee or discount,
                and the cleaning fee.

                `base_rate.value` must be **greater than zero** — a rate of zero is one of the
                things the validity gate refuses to publish. `cleaning_fee.value` may be zero.

                **One listing, one currency.** `cleaning_fee.currency` must equal
                `base_rate.currency`. A listing carries a single currency, and accepting two
                would mean silently picking one of them.

                Sending a currency **different from the stored one is allowed, and it is a
                re-price**: the number is taken at face value in the new currency and nothing is
                converted. A silent conversion, at some rate on some day, would be a price nobody
                chose.

                **A `PUT` replaces.** Omitting `cleaning_fee`, or sending `null`, sets it to
                zero. A read publishes a zero fee as `null`, so a listing read straight back into
                this write round-trips.

                This is the one operation in the document that requires `pricing:write` rather
                than `catalog:write`: what a stay COSTS is a different capability from what it
                IS, and a key issued for a catalogue sync should not thereby be able to change a
                nightly rate.

                The body is strict. An undeclared field is a `400 validation_failed` naming it,
                never a silent drop, and a stray query string is refused the same way.

                A property with more than one unit, or more than one listing, is refused with
                `409 property_not_simple`; a listing that is not a stay with
                `409 inventory_not_supported`. Nothing is written either way.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ListingPricingRequest'
                        example:
                            base_rate:
                                value: '1250.00'
                                currency: EGP
                            cleaning_fee:
                                value: '150.00'
                                currency: EGP
            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: The listing, read back after the change.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/Listing'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                inventory_kind: unit
                                title: Courtyard flat with the blue door
                                description: A two-bedroom flat one street back from the corniche.
                                status: listed
                                base_rate:
                                    value: '1250.00'
                                    currency: EGP
                                cleaning_fee:
                                    value: '200.00'
                                    currency: EGP
                                instant_booking: true
                                is_exclusive: false
                                is_best_price: true
                                rating:
                                    average: 4.8
                                    count: 24
                                publishable: true
                                missing: []
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/WriteRefused' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/calendar:
        get:
            tags: [calendar]
            operationId: getListingCalendar
            x-maat-scope: calendar:read
            x-maat-availability: sandbox
            summary: Read availability, night by night
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar?from=2026-09-01&to=2026-09-30" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar?from=2026-09-01&to=2026-09-30', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      for (const night of body.items) console.log(night.night, night.status, night.blocked_by);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar?from=2026-09-01&to=2026-09-30",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      for night in body["items"]:
                          print(night["night"], night["status"], night["blocked_by"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Items []struct {
                              Night  string `json:"night"`
                              Status string `json:"status"`
                          } `json:"items"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar?from=2026-09-01&to=2026-09-30", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(len(body.Items))
                      }
            description: |
                Every night in the window, with what is holding it.

                `status` is `available`, `booked`, `held` or `blocked`. A booking and a hold are
                deliberately different: a hold is a checkout in flight and may expire, a booking
                will not. `blocked_by` says who put a block there — `host`, `ical` or `partner` —
                and is `null` on every other status, because a sale is not a block.

                `external_uid` comes back **only on blocks you pushed** with
                `PUT /blocks/{external_uid}`. A night closed any other way — a host block, or an
                imported calendar feed's event — is reported as blocked with a `null` uid.

                `from` and `to` are **inclusive** nights — unlike a block's `check_out`, which is
                the exclusive departure day. Send both or neither: sending one is refused rather
                than half-defaulted. Omitting both gives you today plus the next 89 nights; the
                window may not exceed 366.

                **Live in a sandbox workspace. Returns `501` against a production one, where no
                availability is read and none is disclosed.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
                - $ref: '#/components/parameters/CalendarFrom'
                - $ref: '#/components/parameters/CalendarTo'
            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: The window, night by night.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/ListingCalendar'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                items:
                                    - night: '2026-09-01'
                                      status: blocked
                                      blocked_by: partner
                                      external_uid: my-res-55123
                                    - night: '2026-09-02'
                                      status: booked
                                      blocked_by: null
                                      external_uid: null
                                    - night: '2026-09-03'
                                      status: available
                                      blocked_by: null
                                      external_uid: null
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /calendar/holds:
        get:
            tags: [calendar]
            operationId: listCalendarHolds
            x-maat-scope: calendar:read
            x-maat-availability: sandbox
            summary: What is held right now
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/calendar/holds?page_size=200" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

                      for (let page = 1; ; page += 1) {
                          const url = new URL('https://maatstays.wtf/v1/calendar/holds');
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '200');

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

                          for (const hold of body.items) {
                              console.log(hold.unit_id, hold.first_night, hold.last_night, hold.until);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      page = 1
                      while True:
                          res = requests.get(
                              "https://maatstays.wtf/v1/calendar/holds",
                              headers=headers,
                              params={"page": page, "page_size": 200},
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for hold in body["items"]:
                              print(hold["unit_id"], hold["first_night"], hold["last_night"], hold["until"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type hold struct {
                          HoldReference string `json:"hold_reference"`
                          UnitID        string `json:"unit_id"`
                          FirstNight    string `json:"first_night"`
                          LastNight     string `json:"last_night"`
                          Until         string `json:"until"`
                      }

                      type page struct {
                          Items   []hold `json:"items"`
                          HasMore bool   `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "200")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/calendar/holds?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatal(err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, h := range body.Items {
                                  fmt.Println(h.UnitID, h.FirstNight, h.LastNight, h.Until)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                Every hold currently holding nights across your workspace, the one that lapses
                soonest first.

                A **hold** is what a guest takes when they reach checkout: the nights come off
                sale on Maat while the payment completes, before there is a booking. It is the
                same object the
                [`calendar.held`](/api-reference/webhooks/calendar.held) delivery carries, in
                the same shape — this is the reconciliation surface for it. Ask this when your
                integration starts up, after an outage, or whenever you want the current answer
                rather than the last one you were pushed.

                **An expired hold is never in the result.** The live check is made against the
                clock at the moment you ask, so a hold whose `until` has passed is gone from
                this list whether or not its `calendar.released` has reached you yet. What you
                get back is what is held now.

                **There are no filters, and that is the point.** No `listing_id`, no date
                window, no status: the answer is the whole workspace, and any subset would be a
                way to be told less while believing you had been told everything — which is the
                double-booking window this contract exists to close. Sending an undeclared
                parameter is `400 validation_failed` naming it, not a silently unfiltered
                `200`.

                Paged with `page` and `page_size`; `has_more` tells you whether to ask for the
                next one. There is no total. Holds appear and vanish in minutes, so a walk over
                several pages is a moving set — treat it as a snapshot, not a transaction.

                Nothing about the guest is here. There is no name, no identifier and no amount:
                somebody may be about to buy these nights, or may walk away in a minute, and
                what you need is that they cannot be sold twice.

                A `403` here carries one of two codes — `insufficient_scope` (the key lacks
                `calendar:read`) or `workspace_scope_mismatch`. Branch on `code`.

                **Live in a sandbox workspace. Returns `501` against a production one**, where
                nothing is read and no availability is disclosed.
            parameters:
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
            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 page of the holds live right now.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/CalendarHoldList'
                            example:
                                items:
                                    - hold_reference: b3e1a7c4-6d29-4f08-9a51-0c7e2d4b8f36
                                      listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                      unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                      first_night: '2026-09-12'
                                      last_night: '2026-09-15'
                                      until: '2026-08-30T18:56:02Z'
                                has_more: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/reviews:
        get:
            tags: [reviews]
            operationId: listListingReviews
            x-maat-scope: reviews:read
            x-maat-availability: sandbox
            summary: Read a listing's reviews
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/reviews?page_size=50" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };
                      const listingId = '7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24';

                      for (let page = 1; ; page += 1) {
                          const url = new URL(`https://maatstays.wtf/v1/listings/${listingId}/reviews`);
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '50');

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

                          for (const review of body.items) {
                              console.log(review.reviewer_name, review.rating);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}
                      listing_id = "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24"

                      page = 1
                      while True:
                          res = requests.get(
                              f"https://maatstays.wtf/v1/listings/{listing_id}/reviews",
                              headers=headers,
                              params={"page": page, "page_size": 50},
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for review in body["items"]:
                              print(review["reviewer_name"], review["rating"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type review struct {
                          ReviewerName string  `json:"reviewer_name"`
                          Rating       float64 `json:"rating"`
                      }

                      type page struct {
                          Items   []review `json:"items"`
                          HasMore bool     `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")
                          listingID := "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24"

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "50")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/listings/"+listingID+"/reviews?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatal(err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, r := range body.Items {
                                  fmt.Println(r.ReviewerName, r.Rating)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                One page of a listing's reviews, newest first, in their **public** form — the
                same shortened reviewer label anyone reading the listing page sees ("Sarah M."),
                never a full name. The full name stays on the host console, where a person
                authenticated to see it; there is no field here that carries it.

                Paged with `page` and `page_size`; keep asking while `has_more` is `true`. There
                is no total.

                `404 listing_not_found` when the listing is not in your workspace.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
            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 page of the listing's reviews.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/DeveloperReviewList'
                            example:
                                items:
                                    - review_id: 2b1c3d4e-5f60-4718-9a2b-3c4d5e6f7081
                                      listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                      reviewer_name: Sarah M.
                                      rating: 4.8
                                      comment: Quiet street, exactly as described, host replied fast.
                                      categories:
                                          - category: cleanliness
                                            rating: 5
                                          - category: communication
                                            rating: 5
                                      created_at: '2026-08-20T09:00:00.000Z'
                                      reply: null
                                      console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                has_more: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /reviews/{review_id}/reply:
        post:
            tags: [reviews]
            operationId: replyToReview
            x-maat-scope: reviews:reply
            x-maat-availability: sandbox
            summary: Reply to a review, as the workspace
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST https://maatstays.wtf/v1/reviews/2b1c3d4e-5f60-4718-9a2b-3c4d5e6f7081/reply \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"body": "Thank you for staying with us — glad the street was as quiet as promised!"}'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/reviews/2b1c3d4e-5f60-4718-9a2b-3c4d5e6f7081/reply',
                          {
                              method: 'POST',
                              headers: {
                                  Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                                  'Content-Type': 'application/json',
                              },
                              body: JSON.stringify({
                                  body: 'Thank you for staying with us — glad the street was as quiet as promised!',
                              }),
                          },
                      );

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

                      console.log(body.reply.created_at);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/reviews/2b1c3d4e-5f60-4718-9a2b-3c4d5e6f7081/reply",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={
                              "body": "Thank you for staying with us — glad the street was as quiet as promised!",
                          },
                          timeout=30,
                      )

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

                      print(body["reply"]["created_at"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type reply struct {
                          CreatedAt string `json:"created_at"`
                      }

                      type reviewView struct {
                          Reply reply `json:"reply"`
                      }

                      func main() {
                          body, _ := json.Marshal(map[string]string{
                              "body": "Thank you for staying with us — glad the street was as quiet as promised!",
                          })

                          req, _ := http.NewRequest(http.MethodPost,
                              "https://maatstays.wtf/v1/reviews/2b1c3d4e-5f60-4718-9a2b-3c4d5e6f7081/reply",
                              bytes.NewReader(body))
                          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("reply failed: %d", res.StatusCode)
                          }

                          var out reviewView
                          if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(out.Reply.CreatedAt)
                      }
            description: |
                Posts a reply to a review, published as the **workspace** — exactly as the host
                app publishes one. There is no `author` field and there cannot be: letting a
                caller name an author would put a person's name on public content through an API
                key.

                One field: `body`, 1 to 2000 characters. The request is strict — an undeclared
                field is a `400 validation_failed` naming it, never a silent drop.

                One workspace reply per review over this API. `409 conflict` when the review
                already has one; edit it in the host app instead. `404 review_not_found` is ONE
                answer for "no such review" and "not in your workspace".

                A `403` on this operation carries one of two codes — `insufficient_scope` (the
                key lacks `reviews:reply`) or `workspace_scope_mismatch` (a sandbox/production
                mismatch). Branch on `code`.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/ReviewId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/DeveloperReviewReplyRequest'
                        example:
                            body: Thank you for staying with us — glad the street was as quiet as promised!
            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: The review, read back with its reply on it.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/DeveloperReviewReplyResponse'
                            example:
                                review_id: 2b1c3d4e-5f60-4718-9a2b-3c4d5e6f7081
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                reviewer_name: Sarah M.
                                rating: 4.8
                                comment: Quiet street, exactly as described, host replied fast.
                                categories:
                                    - category: cleanliness
                                      rating: 5
                                    - category: communication
                                      rating: 5
                                created_at: '2026-08-20T09:00:00.000Z'
                                reply:
                                    body: Thank you for staying with us — glad the street was as quiet as promised!
                                    created_at: '2026-09-07T09:10:00.000Z'
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ReviewNotFound' }
                '409': { $ref: '#/components/responses/ReviewReplyConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /bookings:
        get:
            tags: [bookings]
            operationId: listBookings
            x-maat-scope: bookings:read
            x-maat-availability: sandbox
            summary: List bookings on your inventory
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/bookings?status=confirmed&check_in_from=2026-09-01&page_size=100" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

                      for (let page = 1; ; page += 1) {
                          const url = new URL('https://maatstays.wtf/v1/bookings');
                          url.searchParams.set('status', 'confirmed');
                          url.searchParams.set('check_in_from', '2026-09-01');
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '100');

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

                          for (const booking of body.items) {
                              console.log(booking.booking_reference, booking.stay.check_in, booking.status);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      page = 1
                      while True:
                          res = requests.get(
                              "https://maatstays.wtf/v1/bookings",
                              headers=headers,
                              params={
                                  "status": "confirmed",
                                  "check_in_from": "2026-09-01",
                                  "page": page,
                                  "page_size": 100,
                              },
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for booking in body["items"]:
                              print(booking["booking_reference"], booking["stay"]["check_in"], booking["status"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type stay struct {
                          CheckIn string `json:"check_in"`
                      }

                      type booking struct {
                          BookingReference string `json:"booking_reference"`
                          Status           string `json:"status"`
                          Stay             stay   `json:"stay"`
                      }

                      type page struct {
                          Items   []booking `json:"items"`
                          HasMore bool      `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("status", "confirmed")
                              query.Set("check_in_from", "2026-09-01")
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "100")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/bookings?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatal(err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, b := range body.Items {
                                  fmt.Println(b.BookingReference, b.Stay.CheckIn, b.Status)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                One page of what is happening on your inventory, newest first.

                There is NO guest identity anywhere in this response — not a name, not an
                initial, not an email, not a reference you could join against another read. Every
                field is a fact about a STAY. `console_url` on each booking is the honest pointer
                to where a person IS seen: the host console, where an authenticated human sees who
                is arriving. That is deliberately slower than a field on this wire, and it is the
                point — an integration that wants an arrivals board builds one out of the
                platform's own access control, not out of a JSON key.

                Filter with `status`, `listing_id`, `check_in_from` and `check_in_to`; page with
                `page` and `page_size`, and keep asking while `has_more` is `true`. There is no
                total. The two date bounds are inclusive, and an inverted range
                (`check_in_from` after `check_in_to`) is refused rather than answered with an
                empty page.

                There is no cancellation over this API, on this operation or any other. Cancelling
                a confirmed stay creates refunds and penalties and belongs to a person in the app.

                A `403` on this operation carries one of two codes — `insufficient_scope` (the
                key lacks `bookings:read`) or `workspace_scope_mismatch` (a sandbox/production
                mismatch). Branch on `code`.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
                - $ref: '#/components/parameters/BookingStatus'
                - $ref: '#/components/parameters/BookingListingId'
                - $ref: '#/components/parameters/BookingCheckInFrom'
                - $ref: '#/components/parameters/BookingCheckInTo'
            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 page of your bookings.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/DeveloperBookingList'
                            example:
                                items:
                                    - booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                      property:
                                          provider_id: my-prop-9001
                                          listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                          unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                          property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                      inventory_kind: unit
                                      status: confirmed
                                      stay:
                                          check_in: '2026-09-12'
                                          check_out: '2026-09-16'
                                          check_in_time: '15:00'
                                          check_out_time: '12:00'
                                          guests: 3
                                      amount_paid:
                                          value: '5000.00'
                                          currency: EGP
                                      instant_booking: true
                                      cancellation: null
                                      created_at: '2026-08-30T18:42:11.000Z'
                                      console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                has_more: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /bookings/{booking_reference}:
        get:
            tags: [bookings]
            operationId: getBooking
            x-maat-scope: bookings:read
            x-maat-availability: sandbox
            summary: Read one booking
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      console.log(body.status, body.stay.check_in, body.console_url);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["status"], body["stay"]["check_in"], body["console_url"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Status     string `json:"status"`
                          ConsoleURL string `json:"console_url"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet,
                              "https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.Status, body.ConsoleURL)
                      }
            description: |
                One booking. Addressed by Maat's own `booking_reference` — the id every later
                event about it carries.

                `404 booking_not_found` is ONE answer for "no such booking" and "not in your
                workspace" — telling them apart would confirm the existence of another
                workspace's reservation.

                A `403` on this operation carries one of two codes — `insufficient_scope` (the
                key lacks `bookings:read`) or `workspace_scope_mismatch` (a sandbox/production
                mismatch). Branch on `code`.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/BookingReference'
            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: The booking.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/DeveloperBooking'
                            example:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                    unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                    property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                inventory_kind: unit
                                status: confirmed
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: 3
                                amount_paid:
                                    value: '5000.00'
                                    currency: EGP
                                instant_booking: true
                                cancellation: null
                                created_at: '2026-08-30T18:42:11.000Z'
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/BookingNotFound' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /bookings/{booking_reference}/confirm:
        post:
            tags: [bookings]
            operationId: confirmBooking
            x-maat-scope: bookings:manage
            x-maat-availability: sandbox
            summary: Confirm a pending booking
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d/confirm \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d/confirm',
                          { method: 'POST', headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` } },
                      );

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

                      console.log(body.status); // 'confirmed'
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d/confirm",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["status"])  # "confirmed"
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Status string `json:"status"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodPost,
                              "https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d/confirm", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("confirm failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.Status) // "confirmed"
                      }
            description: |
                Confirms a `pending` booking — an instant-booking listing never produces one, so
                this is for a listing that requires host approval. **The body is empty**: there
                is nothing to send beyond the booking you are addressing.

                `409 conflict` when the booking is not `pending` — you cannot confirm a booking
                that is already confirmed, cancelled, or anything else. `404 booking_not_found` is
                ONE answer for "no such booking" and "not in your workspace".

                There is no cancellation over this API. Cancelling a confirmed stay creates
                refunds and penalties and belongs to a person in the app.

                A `403` on this operation carries one of two codes — `insufficient_scope` (the
                key lacks `bookings:manage`) or `workspace_scope_mismatch` (a sandbox/production
                mismatch). Branch on `code`.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/BookingReference'
            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: The booking, read back as `confirmed`.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/DeveloperBooking'
                            example:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                    unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                    property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                inventory_kind: unit
                                status: confirmed
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: 3
                                amount_paid:
                                    value: '5000.00'
                                    currency: EGP
                                instant_booking: false
                                cancellation: null
                                created_at: '2026-08-30T18:42:11.000Z'
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/BookingNotFound' }
                '409': { $ref: '#/components/responses/BookingStatusConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /bookings/{booking_reference}/reject:
        post:
            tags: [bookings]
            operationId: rejectBooking
            x-maat-scope: bookings:manage
            x-maat-availability: sandbox
            summary: Reject a pending booking
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d/reject \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch(
                          'https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d/reject',
                          { method: 'POST', headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` } },
                      );

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

                      console.log(body.status); // 'rejected'
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d/reject",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["status"])  # "rejected"
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Status string `json:"status"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodPost,
                              "https://maatstays.wtf/v1/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d/reject", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("reject failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.Status) // "rejected"
                      }
            description: |
                Rejects a `pending` booking. **The body is empty**: there is nothing to send
                beyond the booking you are addressing.

                `409 conflict` when the booking is not `pending`. `404 booking_not_found` is ONE
                answer for "no such booking" and "not in your workspace".

                There is no cancellation over this API. Rejecting a still-pending booking is not
                the same operation as cancelling a confirmed stay, which creates refunds and
                penalties and belongs to a person in the app.

                A `403` on this operation carries one of two codes — `insufficient_scope` (the
                key lacks `bookings:manage`) or `workspace_scope_mismatch` (a sandbox/production
                mismatch). Branch on `code`.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            parameters:
                - $ref: '#/components/parameters/BookingReference'
            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: The booking, read back as `rejected`.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/DeveloperBooking'
                            example:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                    unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                    property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                inventory_kind: unit
                                status: rejected
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: 3
                                amount_paid:
                                    value: '5000.00'
                                    currency: EGP
                                instant_booking: false
                                cancellation: null
                                created_at: '2026-08-30T18:42:11.000Z'
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/BookingNotFound' }
                '409': { $ref: '#/components/responses/BookingStatusConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /sandbox/bookings:
        post:
            tags: [sandbox]
            operationId: createSandboxBooking
            x-maat-scope: bookings:manage
            x-maat-availability: sandbox
            summary: Create a booking in your sandbox
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST https://maatstays.wtf/v1/sandbox/bookings \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{
                              "listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                              "check_in": "2026-09-12",
                              "check_out": "2026-09-16",
                              "guests": 3,
                              "status": "confirmed"
                            }'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/sandbox/bookings', {
                          method: 'POST',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({
                              listing_id: '7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24',
                              check_in: '2026-09-12',
                              check_out: '2026-09-16',
                              guests: 3,
                              status: 'confirmed',
                          }),
                      });

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

                      console.log(body.booking_reference); // watch for booking.confirmed
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/sandbox/bookings",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={
                              "listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                              "check_in": "2026-09-12",
                              "check_out": "2026-09-16",
                              "guests": 3,
                              "status": "confirmed",
                          },
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["booking_reference"])  # watch for booking.confirmed
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          BookingReference string `json:"booking_reference"`
                      }

                      func main() {
                          body, _ := json.Marshal(map[string]any{
                              "listing_id": "7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24",
                              "check_in":   "2026-09-12",
                              "check_out":  "2026-09-16",
                              "guests":     3,
                              "status":     "confirmed",
                          })

                          req, _ := http.NewRequest(http.MethodPost,
                              "https://maatstays.wtf/v1/sandbox/bookings", bytes.NewReader(body))
                          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("sandbox booking failed: %d", res.StatusCode)
                          }

                          var out payload
                          if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(out.BookingReference) // watch for booking.confirmed
                      }
            description: |
                Creates a synthetic booking on one of your **sandbox** listings, so you can watch
                the real `booking.*` webhooks arrive at your endpoint before you go live.

                The booking is made the way a real one is: the same availability check, the same
                price quote, the same booking record and therefore the same events, delivered to
                your endpoint in the same envelope with the same signature. It is not a rehearsal
                of a booking — it is a booking, on inventory that only exists in your sandbox.

                **Why you need it.** A sandbox listing is never sold: nothing in a sandbox reaches
                a payment provider, so no guest can ever check out on one. Without this operation
                `GET /v1/bookings` would answer an empty page forever, `confirm` and `reject`
                would have nothing to act on, and the booking webhooks — the larger half of what
                you build against — could not be seen at all until real money was involved.

                **Pick which half of the lifecycle you want.** `status: "pending"` mints a booking
                waiting on a host decision and sends `booking.requested`; you then drive it with
                `POST /v1/bookings/{booking_reference}/confirm` or `/reject` and watch the next
                event. `status: "confirmed"` mints one already confirmed and sends
                `booking.confirmed` straight away.

                **What it never does.** There is no guest field, and there will not be one: the
                guest is synthetic, minted by us, and the same one every time for your workspace.
                No money moves and no payment provider is contacted. Nobody is emailed or
                notified. The amount on the booking comes from your listing's own price for those
                dates, so the figures have the shape a real booking's do.

                It **holds real nights on your sandbox calendar**, exactly as a real booking
                would, so a second call over the same dates is refused with `409 conflict`,
                and `conflicts` names every night that is closed — the same member, in the
                same shape, that `PUT /v1/blocks/{external_uid}` answers a collision with.
                Move the window, or free the nights from the Maat console. A stay may cover at
                most 30 nights.

                **Two codes share `409` here. Branch on `code`, never on the status.**
                `conflict` is retryable with a different window; `sandbox_only` — the listing
                is the one in your LIVE workspace, and the fixture only ever creates bookings
                on sandbox inventory — never is. `409 inventory_not_supported` for an
                experience or a service: this creates stays only.

                `404 listing_not_found` for everything else: an id that names nothing, and an
                id that names a listing outside your own live/sandbox pair. Those are one
                answer on purpose, so the status here cannot be used to find out whether an
                id names another workspace's inventory.

                **Live in a sandbox workspace. Returns `501` against a production one**, after
                full authentication and validation, and nothing is created.

                One more thing has to be true before a delivery leaves the platform: an operator
                enables the outbound delivery job. See
                [Webhooks](/guides/webhooks) for how to tell whether it is on.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/CreateSandboxBookingRequest'
                        example:
                            listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                            check_in: '2026-09-12'
                            check_out: '2026-09-16'
                            guests: 3
                            status: confirmed
            responses:
                '201':
                    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 booking, in exactly the shape `GET /v1/bookings/{booking_reference}`
                        reads it back. Its events are already on their way.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/DeveloperBooking'
                            example:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                    unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                    property_id: 3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
                                inventory_kind: unit
                                status: confirmed
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: 3
                                amount_paid:
                                    value: '5000.00'
                                    currency: EGP
                                instant_booking: false
                                cancellation: null
                                created_at: '2026-08-30T18:42:11.000Z'
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/SandboxOnlyConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /catalog/property-types:
        get:
            tags: [catalog]
            operationId: listPropertyTypes
            x-maat-scope: catalog:read
            x-maat-availability: sandbox
            summary: Every property type
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/catalog/property-types" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/catalog/property-types', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      console.log(body.items.map((t) => t.slug));
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/catalog/property-types",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print([t["slug"] for t in body["items"]])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Items []struct {
                              Slug string `json:"slug"`
                          } `json:"items"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/catalog/property-types", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(len(body.Items))
                      }
            description: |
                Every `property_type` slug `PUT /properties/{provider_id}` accepts.

                Slugs only. Every label on this platform is resolved per reader's language, so
                publishing an English string here would hand you a translation that goes stale —
                map our slug onto your own vocabulary instead.

                The catalogue is the same in every workspace and every environment, so what you
                read in the sandbox is the production answer.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            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: The property-type catalogue.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PropertyTypeListResponse'
                            example:
                                items:
                                    - slug: apartment
                                    - slug: villa
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /catalog/amenities:
        get:
            tags: [catalog]
            operationId: listAmenities
            x-maat-scope: catalog:read
            x-maat-availability: sandbox
            summary: Every amenity
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/catalog/amenities" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/catalog/amenities', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      console.log(body.items.map((a) => `${a.category}/${a.slug}`));
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/catalog/amenities",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print([(a["category"], a["slug"]) for a in body["items"]])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Items []struct {
                              Slug     string `json:"slug"`
                              Category string `json:"category"`
                          } `json:"items"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/catalog/amenities", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(len(body.Items))
                      }
            description: |
                Every amenity slug we recognise, with the section it belongs to.

                Use it to build your mapping before a catalogue sync. A slug we do not recognise
                is echoed back in `unknown_amenities` on a property push rather than silently
                dropped, but reading this list first is cheaper than finding out one property at
                a time.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            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: The amenity catalogue.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/AmenityListResponse'
                            example:
                                items:
                                    - slug: wifi
                                      category: essentials
                                    - slug: pool
                                      category: features
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /catalog/house-rules:
        get:
            tags: [catalog]
            operationId: listHouseRules
            x-maat-scope: catalog:read
            x-maat-availability: sandbox
            summary: Every house rule
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/catalog/house-rules" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/catalog/house-rules', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      console.log(body.items.filter((r) => !r.retired).map((r) => r.slug));
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/catalog/house-rules",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print([r["slug"] for r in body["items"] if not r["retired"]])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Items []struct {
                              Slug    string `json:"slug"`
                              Kind    string `json:"kind"`
                              Retired bool   `json:"retired"`
                          } `json:"items"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/catalog/house-rules", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode >= 400 {
                              log.Fatalf("read failed: %d", res.StatusCode)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(len(body.Items))
                      }
            description: |
                Every house rule in the catalogue, with the two things you need to render one.

                `kind` decides how a rule reads. A `permission` has two polarities — "Pets
                allowed" and "No pets" are the same rule answered differently. A `requirement`
                has only one: it is shown when it is answered `true` and has no negated form at
                all, because "No lock up before leaving" is not a policy anybody has.

                `sort_order` groups rules within a `category`; the bands do not overlap, so
                ordering by it alone comes out already grouped.

                **Retired rules are included, flagged `retired: true`.** Properties answered them
                before they were withdrawn and those answers still come back on a property read —
                so you need the entry to explain the slug. Do not offer a retired rule on a new
                push.

                **Live in a sandbox workspace. Returns `501` against a production one.**
            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: The house-rule catalogue.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/HouseRuleListResponse'
                            example:
                                items:
                                    - slug: pets_allowed
                                      category: during_stay
                                      kind: permission
                                      sort_order: 210
                                      retired: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/calendar/blocks:
        post:
            tags: [calendar]
            operationId: blockListingNights
            x-maat-scope: calendar:write
            x-maat-availability: sandbox
            summary: Close nights with a host block
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"dates":["2026-09-14","2026-09-15"],"category":"maintenance"}'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks', {
                          method: 'POST',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({ dates: ['2026-09-14', '2026-09-15'], category: 'maintenance' }),
                      });

                      const body = await res.json();
                      if (res.status === 409) throw new Error(`still on sale: ${body.conflicts.join(', ')}`);
                      if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);

                      console.log(body.blocked, body.already_held);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={"dates": ["2026-09-14", "2026-09-15"], "category": "maintenance"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code == 409:
                          raise RuntimeError("still on sale: " + ", ".join(body["conflicts"]))
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["blocked"], body["already_held"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type receipt struct {
                          Blocked     []string `json:"blocked"`
                          AlreadyHeld []string `json:"already_held"`
                          Conflicts   []string `json:"conflicts"`
                      }

                      func main() {
                          payload, _ := json.Marshal(map[string]any{
                              "dates":    []string{"2026-09-14", "2026-09-15"},
                              "category": "maintenance",
                          })

                          req, _ := http.NewRequest(http.MethodPost, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks", 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 receipt
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          if res.StatusCode == http.StatusConflict {
                              log.Fatalf("still on sale: %v", body.Conflicts)
                          }
                          fmt.Println(body.Blocked, body.AlreadyHeld)
                      }
            description: |
                Close a set of nights on a listing, the way the Maat app's "Block dates" does.
                The nights need not be contiguous — this is a set of days, not a range — and up
                to 366 of them fit in one request.

                **This is not `PUT /v1/blocks/{external_uid}`.** That contract records a night as
                sold in YOUR system, keyed on your own reservation id and releasable only through
                that id. This one writes a closure of the kind the Maat app makes — maintenance,
                an owner's stay — with a `category`, keyed by date. If you are syncing
                reservations from your own system, `/v1/blocks` is your endpoint; this one is for
                closing nights by date.

                **There is no `note` and no free-text field of any kind.** An unrecognised field
                is a `validation_failed` naming it, never a silent drop — the calendar direction
                of this API carries no guest data, and the obvious thing anybody writes in a note
                is a guest's name.

                Dates must be today or later, in **UTC**. A property has its own time zone, but a
                bound derived from it would make the same request behave differently for two
                listings in one account.

                **Conflicts refuse the whole range.** If any night you name carries a Maat
                booking or a live hold, nothing at all is written and the `409` names every
                colliding night in `conflicts`. We never apply the free part of a range: a
                partial apply you were told succeeded is the one failure you cannot detect.

                A night that is already closed by something else — a calendar feed, or a block you
                pushed under your own `external_uid` — is **not** a conflict. It is already unavailable, which is
                what you asked for, so it comes back under `already_held` instead: no host block
                was written there, and that closure will lift when its own source lifts.

                Re-sending the same nights is a no-op and answers the same receipt.

                A listing that sells a room type over several rooms answers
                `409 listing_pooled_unsupported` and nothing is written: a host block here closes
                one unit, and such a listing's room is chosen per booking.

                **Live in a sandbox workspace. Returns `501` against a production one, where no
                night is blocked and everything you named stays on sale.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/HostBlockWrite'
                        example:
                            dates:
                                - '2026-09-14'
                                - '2026-09-15'
                            category: maintenance
            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: The receipt. Every night you named is closed.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/HostBlockReceipt'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                blocked:
                                    - '2026-09-14'
                                    - '2026-09-15'
                                already_held: []
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/HostBlockForbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/HostBlockConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
    /listings/{listing_id}/calendar/blocks/release:
        post:
            tags: [calendar]
            operationId: releaseListingNights
            x-maat-scope: calendar:write
            x-maat-availability: sandbox
            summary: Release host blocks by date
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X POST "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks/release" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"dates":["2026-09-14"]}'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks/release', {
                          method: 'POST',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({ dates: ['2026-09-14'] }),
                      });

                      if (res.status !== 204) {
                          const body = await res.json();
                          throw new Error(`${body.code}: ${body.detail}`);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.post(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks/release",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={"dates": ["2026-09-14"]},
                          timeout=30,
                      )
                      if res.status_code != 204:
                          body = res.json()
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          payload, _ := json.Marshal(map[string]any{"dates": []string{"2026-09-14"}})

                          req, _ := http.NewRequest(http.MethodPost, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/calendar/blocks/release", 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 != http.StatusNoContent {
                              log.Fatalf("release failed: %d", res.StatusCode)
                          }
                      }
            description: |
                Open nights that a host block is holding.

                **A `POST` on its own sub-resource, because it carries a body.** The nights you
                are freeing are a SET, not a range, so they cannot ride in the path or the query;
                and a `DELETE` with a body has no defined meaning in HTTP, so clients, proxies and
                runtimes are free to drop it — which on this operation would turn "free this one
                night" into a request addressed at the whole collection. This operation was a
                `DELETE` on `/calendar/blocks` before that was noticed; it is the same operation
                and keeps the same `operationId`.

                It frees **only** what a host block holds. A night carrying a Maat booking, a live
                hold, a calendar feed's block or a block you pushed under your own `external_uid`
                is skipped in silence: freeing one would put a sold night back on sale. Release
                your own pushed blocks with `DELETE /v1/blocks/{external_uid}`.

                `204` whether or not there was anything to release. Releasing a night that carries
                no host block is the desired end state, not an error to retry.

                Unlike the block, there is no date floor here: tidying up a closure from last
                month is a legitimate thing to do.

                **Live in a sandbox workspace. Returns `501` against a production one, where no
                block is released and those nights stay unsellable.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/HostBlockRelease'
                        example:
                            dates:
                                - '2026-09-14'
                                - '2026-09-15'
            responses:
                '204':
                    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 host blocks you named are gone.
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/HostBlockForbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/InventoryNotSupported' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/quick-actions:
        get:
            tags: [pricing]
            operationId: listQuickActions
            x-maat-scope: rules:write
            x-maat-scope-additional: [pricing:write]
            x-maat-scope-additional-when: >-
                `pricing:write` is additionally required for a `pricing.*` template.
                Without it, a `pricing.*` row is listed with its `applied` state withheld.
            x-maat-availability: sandbox
            summary: The quick actions for a listing
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

                      const body = await res.json();
                      if (!res.ok) throw new Error(`${body.code}: ${body.detail}`);
                      if (body.warnings.length) console.warn('partial:', body.warnings);

                      for (const item of body.items) {
                          console.log(item.template_key, item.available, item.applied?.value);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")
                      if body["warnings"]:
                          print("partial:", body["warnings"])

                      for item in body["items"]:
                          print(item["template_key"], item["available"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Items []struct {
                              TemplateKey string `json:"template_key"`
                              Family      string `json:"family"`
                              Available   bool   `json:"available"`
                          } `json:"items"`
                          Warnings []string `json:"warnings"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode != http.StatusOK {
                              var problem struct {
                                  Code   string `json:"code"`
                                  Detail string `json:"detail"`
                              }
                              _ = json.NewDecoder(res.Body).Decode(&problem)
                              log.Fatalf("%d %s: %s", res.StatusCode, problem.Code, problem.Detail)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(len(body.Items), body.Warnings)
                      }
            description: |
                Every quick action this listing can carry, with whatever it currently has
                switched on.

                A quick action is a one-tap pricing or booking rule: a switch plus at most one
                tunable value. `template_key` is a **closed enum** and every member of it is
                permanent — a template that is ever withdrawn keeps its identifier and comes back
                `available: false, unavailable_reason: "retired"`, because a published identifier
                never vanishes.

                There is deliberately **no label**. The names in our own apps are admin copy with
                no translation behind them; render your own from the `template_key`.

                `param` describes the value the action takes: `kind`, a `default`, and the bounds
                and `step` where the kind has them. For `kind: "currency"` the value is a Money
                object in the LISTING's currency, and it is the only currency that action can be
                expressed in.

                `available: false` means a `PUT` would be refused right now, and
                `unavailable_reason` says why:

                | Reason                | Meaning                                                                      |
                | :-------------------- | :--------------------------------------------------------------------------- |
                | `external_pricing`    | The listing's price is set by another source, so no rule on it would apply.   |
                | `retired`             | The template was withdrawn. Its identifier stays published; `PUT` refuses.    |
                | `inventory_kind`      | The action does not apply to this listing's inventory kind.                   |
                | `insufficient_scope`  | Your key lacks `pricing:write`, so this `pricing.*` row omits `applied`.      |

                `warnings` is not decoration. The two families come from two services that deploy
                independently, so one being unreachable degrades ITS family to nothing and says
                so here — a short list with no warning would be indistinguishable from a listing
                with nothing switched on.

                **Scopes.** Reading needs `rules:write`; there is no read-only scope for rules,
                because seeing which rules price your listing is part of managing them. The
                `pricing.*` half additionally needs `pricing:write` — with `rules:write` alone
                you still get all eight catalogue entries, but every `pricing.*` row comes back
                `applied: null, available: false, unavailable_reason: "insufficient_scope"`. The
                cleaning fee a host charges and the surcharges they run are commercial facts
                about their business, and a key that may not change them is not thereby entitled
                to read them.

                **Live in a sandbox workspace. Returns `501` against a production one, where
                nothing is read.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            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: The quick actions, both families in one list.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/QuickActionList'
                            example:
                                items:
                                    - template_key: booking.min_stay
                                      family: booking
                                      category: stay_length
                                      param:
                                          kind: number
                                          default: 2
                                          min: 1
                                          max: 365
                                          step: 1
                                      applied:
                                          value: 3
                                          active: true
                                      available: true
                                      unavailable_reason: null
                                    - template_key: pricing.cleaning_fee
                                      family: pricing
                                      category: fees
                                      param:
                                          kind: currency
                                          default:
                                              value: '150.00'
                                              currency: EGP
                                      applied: null
                                      available: true
                                      unavailable_reason: null
                                warnings: []
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/RulesForbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/InventoryNotSupported' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/quick-actions/{template_key}:
        put:
            tags: [pricing]
            operationId: applyQuickAction
            x-maat-scope: rules:write
            x-maat-scope-additional: [pricing:write]
            x-maat-scope-additional-when: >-
                `pricing:write` is additionally required for a `pricing.*` template.
                Without it, a `pricing.*` `template_key` is refused with `403 insufficient_scope`.
            x-maat-availability: sandbox
            summary: Switch a quick action on, or move its value
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions/pricing.cleaning_fee" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"value":{"value":"25.00","currency":"EGP"}}'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions/pricing.cleaning_fee', {
                          method: 'PUT',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({ value: { value: '25.00', currency: 'EGP' } }),
                      });

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

                      console.log(body.template_key, body.value, body.active);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.put(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions/pricing.cleaning_fee",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={"value": {"value": "25.00", "currency": "EGP"}},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["template_key"], body["value"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          payload, _ := json.Marshal(map[string]any{
                              "value": map[string]string{"value": "25.00", "currency": "EGP"},
                          })

                          req, _ := http.NewRequest(http.MethodPut, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions/pricing.cleaning_fee", 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 != http.StatusOK && res.StatusCode != http.StatusCreated {
                              var problem struct {
                                  Code   string `json:"code"`
                                  Detail string `json:"detail"`
                              }
                              _ = json.NewDecoder(res.Body).Decode(&problem)
                              log.Fatalf("%d %s: %s", res.StatusCode, problem.Code, problem.Detail)
                          }

                          var body map[string]any
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body["template_key"], body["value"])
                      }
            description: |
                Switch a quick action on, or move an already-applied one to a new value.
                Idempotent: the identifier is in the path, so a retry re-points the same rule
                rather than creating a second one. `201` the first time, `200` after that.

                Omit `value` to take the action's own default — you do not have to know the
                number to switch something on.

                `value` is typed by the action's `param.kind`, and the typing is enforced:

                | Kind                     | Value                                                                  |
                | :----------------------- | :--------------------------------------------------------------------- |
                | `number`, `percent`      | a number within `min`–`max`, on `step`                                 |
                | `days_of_week`           | day names from the closed seven, no duplicates                         |
                | `currency`               | a **Money object** whose `currency` equals the listing's               |

                A Money object in the wrong currency is a `validation_failed` naming
                `body.value.currency`. There is no conversion on this path and there will not be
                one: an amount we converted for you is a number the host never chose, on every
                guest's receipt.

                ### The scope this needs

                `rules:write` for every key, **plus `pricing:write` for a `pricing.*` key**. This
                is the only operation in this document whose requirement depends on the path: a
                cleaning fee changes what a guest pays and a minimum stay does not, so the second
                half is checked against your key's scopes when the request arrives and refused
                with the same `insufficient_scope`, naming what is missing.

                ### When it is refused

                `pricing_mode_conflict` (`409`) — the listing's price is set by another source, so
                a rule on it would never take effect. Release that source in the Maat app first.
                Nothing is written.

                `validation_failed` (`400`) — the value does not suit the action, or the action is
                retired.

                **Live in a sandbox workspace. Returns `501` against a production one, where no
                rule is applied and the listing prices exactly as it did before.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
                - $ref: '#/components/parameters/TemplateKey'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/QuickActionApply'
                        example:
                            value:
                                value: '200.00'
                                currency: EGP
            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: |
                        The action is on at the value in the body. `result` says whether this
                        call switched it on or moved one that was already on.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/QuickActionApplied'
                            example:
                                template_key: pricing.cleaning_fee
                                family: pricing
                                result: updated
                                value:
                                    value: '200.00'
                                    currency: EGP
                                active: true
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/RulesForbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/QuickActionApplyConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }
        delete:
            tags: [pricing]
            operationId: deleteQuickAction
            x-maat-scope: rules:write
            x-maat-scope-additional: [pricing:write]
            x-maat-scope-additional-when: >-
                `pricing:write` is additionally required for a `pricing.*` template.
                Without it, a `pricing.*` `template_key` is refused with `403 insufficient_scope`.
            x-maat-availability: sandbox
            summary: Switch a quick action off
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X DELETE "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions/pricing.cleaning_fee" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions/pricing.cleaning_fee', {
                          method: 'DELETE',
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

                      if (res.status !== 204) {
                          const body = await res.json();
                          throw new Error(`${body.code}: ${body.detail}`);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.delete(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions/pricing.cleaning_fee",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      if res.status_code != 204:
                          body = res.json()
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")
                - lang: go
                  label: Go
                  source: |
                      package main

                      import (
                          "log"
                          "net/http"
                          "os"
                      )

                      func main() {
                          req, _ := http.NewRequest(http.MethodDelete, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/quick-actions/pricing.cleaning_fee", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode != http.StatusNoContent {
                              log.Fatalf("remove failed: %d", res.StatusCode)
                          }
                      }
            description: |
                Switch a quick action off. The rule it created is removed along with it.

                `204` whether or not it was applied — switching off something already off is the
                desired end state, not an error to retry.

                Takes the same scopes as the `PUT`, including `pricing:write` for a `pricing.*`
                key: removing a cleaning fee changes what a guest pays just as surely as adding
                one.

                **Live in a sandbox workspace. Returns `501` against a production one, where no
                rule is removed.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
                - $ref: '#/components/parameters/TemplateKey'
            responses:
                '204':
                    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 action is off.
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/RulesForbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/InventoryNotSupported' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/rules:
        get:
            tags: [pricing]
            operationId: listListingRules
            x-maat-scope: rules:write
            x-maat-scope-additional: [pricing:write]
            x-maat-scope-additional-when: >-
                `pricing:write` is additionally required for a `pricing.*` template.
                Without it, pricing rules are omitted and `warnings` carries `pricing_withheld`.
            x-maat-availability: sandbox
            summary: Every rule attached to a listing
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rules" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rules', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      for (const rule of body.items) console.log(rule.family, rule.title, rule.source);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rules",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      for rule in body["items"]:
                          print(rule["family"], rule["title"], rule["source"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type payload struct {
                          Items []struct {
                              Family string `json:"family"`
                              Title  string `json:"title"`
                              Source string `json:"source"`
                          } `json:"items"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rules", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode != http.StatusOK {
                              var problem struct {
                                  Code   string `json:"code"`
                                  Detail string `json:"detail"`
                              }
                              _ = json.NewDecoder(res.Body).Decode(&problem)
                              log.Fatalf("%d %s: %s", res.StatusCode, problem.Code, problem.Detail)
                          }

                          var body payload
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(len(body.Items))
                      }
            description: |
                Read-only. The answer to "why is my price what it is": every pricing and booking
                rule attached to this listing, whether it came from a quick action or was drawn
                by hand in the Maat app's rule builder.

                `source` tells you which. A `quick_action` rule names the `template_key` it came
                from and can be changed through the quick-action endpoints; an `authored` one
                cannot be changed here at all.

                **Rule authoring is not on this API, and there is no write here of any kind.**
                No create, no link, no unlink, no pausing. A rule's logic is a node graph drawn on
                a canvas, and publishing that graph would freeze an internal shape as a contract
                on the day it shipped — so this list carries the rule's identity and its state,
                and nothing about what it does.

                `warnings` follows the same rule as the quick-action list: a family whose service
                is unreachable is reported, not silently omitted.

                **The pricing half needs `pricing:write`.** Without it the pricing rules are
                absent from `items` and `warnings` carries `pricing_withheld` — not
                `pricing_unavailable`, which would send you into a retry loop against a refusal
                no retry can change. A rule's `title` is written by the host, and with `active`
                it says what this workspace charges and when; a key that may not change pricing
                is not thereby entitled to read it. The booking rules are unaffected.

                **Live in a sandbox workspace. Returns `501` against a production one, where
                nothing is read.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            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: The rules attached to this listing.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/ListingRuleList'
                            example:
                                items:
                                    - id: a41c9d70-6b28-4e33-95af-1c0d8e2f4b76
                                      family: booking
                                      title: Minimum stay
                                      active: true
                                      source: quick_action
                                      template_key: booking.min_stay
                                    - id: e0782b95-4d16-4a7c-b3e8-9f5c1a604d23
                                      family: pricing
                                      title: Cleaning fee
                                      active: true
                                      source: authored
                                      template_key: null
                                warnings: []
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/RulesForbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/InventoryNotSupported' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/rates:
        put:
            tags: [pricing]
            operationId: upsertListingRates
            x-maat-scope: rates:write
            x-maat-availability: sandbox
            summary: Supply nightly rates for a date range
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rates" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{
                              "rates": [
                                { "first_night": "2026-12-20", "last_night": "2026-12-31",
                                  "nightly_rate": { "value": "3200.00", "currency": "EGP" },
                                  "min_stay": 3 },
                                { "first_night": "2027-01-01", "last_night": "2027-01-31",
                                  "nightly_rate": { "value": "1800.00", "currency": "EGP" } }
                              ]
                            }'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rates', {
                          method: 'PUT',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({
                              rates: [
                                  {
                                      first_night: '2026-12-20',
                                      last_night: '2026-12-31',
                                      nightly_rate: { value: '3200.00', currency: 'EGP' },
                                      min_stay: 3,
                                  },
                              ],
                          }),
                      });

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

                      // `binding: false` means these rates are stored but not yet in force.
                      console.log(body.nights_written, body.mode, body.binding, body.coverage_until);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.put(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rates",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={
                              "rates": [
                                  {
                                      "first_night": "2026-12-20",
                                      "last_night": "2026-12-31",
                                      "nightly_rate": {"value": "3200.00", "currency": "EGP"},
                                      "min_stay": 3,
                                  }
                              ]
                          },
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      print(body["nights_written"], body["mode"], body["binding"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          payload := map[string]any{"rates": []map[string]any{{
                              "first_night":  "2026-12-20",
                              "last_night":   "2026-12-31",
                              "nightly_rate": map[string]string{"value": "3200.00", "currency": "EGP"},
                              "min_stay":     3,
                          }}}
                          buf, _ := json.Marshal(payload)

                          req, _ := http.NewRequest(http.MethodPut, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rates", bytes.NewReader(buf))
                          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 != http.StatusOK {
                              var problem struct {
                                  Code   string `json:"code"`
                                  Detail string `json:"detail"`
                              }
                              _ = json.NewDecoder(res.Body).Decode(&problem)
                              log.Fatalf("%d %s: %s", res.StatusCode, problem.Code, problem.Detail)
                          }

                          var body struct {
                              NightsWritten int    `json:"nights_written"`
                              Mode          string `json:"mode"`
                              Binding       bool   `json:"binding"`
                          }
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.NightsWritten, body.Mode, body.Binding)
                      }
            description: |
                Store what each night of a range costs.

                `first_night` and `last_night` are **both inclusive and both nights**. They are
                not called `from`/`to` precisely so there is no exclusive checkout day to
                misread: `2026-12-20 … 2026-12-31` prices twelve nights, and the twelfth is the
                night of the 31st.

                **Push rates before you take the pricing mode.** That is the recommended order,
                so a listing never spends a window with no price on it. Until the mode is taken
                the rows are stored and inert, and `binding: false` says so — the request is not
                refused.

                `nightly_rate.currency` **must equal the listing's own currency**. Nothing is
                ever converted; a mismatch is a `400 validation_failed` naming the field. If the
                host changes the listing's currency, every future rate you sent is dropped —
                re-push in the new currency. The `currency` on this response is what the
                calendar now holds, so a push that comes back in a currency you did not send is
                how you find out.

                Ranges in one request may **not overlap**, every night must be **today or
                later** and **no more than 730 nights ahead**, and one request may cover at most
                **730 nights** in total. Two *separate* requests for the same night are fine and
                the later one wins, which is how a correction works.

                The rates you send are **yours**, and only the calendar of whoever holds the
                pricing mode sets what guests are quoted.

                `coverage_until` is the last night of the **contiguous** run of YOUR OWN rates
                that starts today — not the furthest night you have priced. A horizon with a hole
                in it is not covered past the hole, and a night with no rate cannot be quoted at
                all. It reports your calendar whether or not you hold the pricing mode, and
                `GET …/rates` answers the same number.

                A listing priced by another source refuses the whole request with
                `409 pricing_mode_conflict`. Nothing is half-applied.

                **Live in a sandbox workspace. Returns `501` against a production one, where no
                rate is stored.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/RateUpsert'
                        example:
                            rates:
                                - first_night: '2026-12-20'
                                  last_night: '2026-12-31'
                                  nightly_rate:
                                      value: '1450.00'
                                      currency: EGP
                                  min_stay: 3
            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: The rates were stored.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RateUpsertResult'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                nights_written: 12
                                currency: EGP
                                mode: partner
                                binding: true
                                coverage_until: '2026-12-31'
                                gap_nights_next_90_days: 0
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/PricingModeConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
        get:
            tags: [pricing]
            operationId: getListingRates
            x-maat-scope: rates:read
            x-maat-availability: sandbox
            summary: Read back the rates you sent
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rates?from=2026-12-20&to=2026-12-31" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const url = new URL('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rates');
                      url.searchParams.set('from', '2026-12-20');
                      url.searchParams.set('to', '2026-12-31');

                      const res = await fetch(url, {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      for (const night of body.items) {
                          console.log(night.night, night.nightly_rate.value, night.min_stay);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rates",
                          params={"from": "2026-12-20", "to": "2026-12-31"},
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      for night in body["items"]:
                          print(night["night"], night["nightly_rate"]["value"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/rates?from=2026-12-20&to=2026-12-31", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode != http.StatusOK {
                              var problem struct {
                                  Code   string `json:"code"`
                                  Detail string `json:"detail"`
                              }
                              _ = json.NewDecoder(res.Body).Decode(&problem)
                              log.Fatalf("%d %s: %s", res.StatusCode, problem.Code, problem.Detail)
                          }

                          var body struct {
                              Items []struct {
                                  Night       string `json:"night"`
                                  NightlyRate struct {
                                      Value string `json:"value"`
                                  } `json:"nightly_rate"`
                              } `json:"items"`
                          }
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(len(body.Items))
                      }
            description: |
                The nights **you** sent for this listing, over an inclusive window of at most
                366 nights.

                This is a receipt: nothing on it is something Maat holds that you did not send.
                You never read a rate this platform derived on its own. If the listing is priced by another source you
                still read back your own rows — they are simply not in force, which `binding`
                tells you.

                A night you never sent is absent, not zero.

                **Live in a sandbox workspace. Returns `501` against a production one, where
                nothing is read.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
                - name: from
                  in: query
                  required: true
                  description: First night to read, inclusive.
                  schema:
                      type: string
                      format: date
                  example: '2026-12-20'
                - name: to
                  in: query
                  required: true
                  description: Last night to read, inclusive. At most 366 nights after `from`.
                  schema:
                      type: string
                      format: date
                  example: '2026-12-31'
            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: Your own rates for the window.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/RateList'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                items:
                                    - night: '2026-12-20'
                                      nightly_rate:
                                          value: '1450.00'
                                          currency: EGP
                                      min_stay: 3
                                    - night: '2026-12-21'
                                      nightly_rate:
                                          value: '1450.00'
                                          currency: EGP
                                      min_stay: 3
                                currency: EGP
                                mode: partner
                                binding: true
                                coverage_until: '2026-12-31'
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/InventoryNotSupported' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /listings/{listing_id}/pricing-mode:
        put:
            tags: [pricing]
            operationId: takeListingPricingMode
            x-maat-scope: rates:write
            x-maat-availability: sandbox
            summary: Become the listing's pricing source
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X PUT "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" \
                        -H "Content-Type: application/json" \
                        -d '{"mode":"partner","fallback_to_base_price":true}'
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode', {
                          method: 'PUT',
                          headers: {
                              Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}`,
                              'Content-Type': 'application/json',
                          },
                          body: JSON.stringify({ mode: 'partner', fallback_to_base_price: true }),
                      });

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

                      if (body.warnings.includes('no_rates_next_30_nights')) {
                          console.warn('this listing is not priced for the coming month');
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.put(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          json={"mode": "partner", "fallback_to_base_price": True},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      if "no_rates_next_30_nights" in body["warnings"]:
                          print("this listing is not priced for the coming month")
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          req, _ := http.NewRequest(http.MethodPut, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode", strings.NewReader(`{"mode":"partner","fallback_to_base_price":true}`))
                          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 != http.StatusOK {
                              var problem struct {
                                  Code   string `json:"code"`
                                  Detail string `json:"detail"`
                              }
                              _ = json.NewDecoder(res.Body).Decode(&problem)
                              log.Fatalf("%d %s: %s", res.StatusCode, problem.Code, problem.Detail)
                          }

                          var body struct {
                              Mode     string   `json:"mode"`
                              Warnings []string `json:"warnings"`
                          }
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.Mode, body.Warnings)
                      }
            description: |
                Make your rates the listing's price.

                From this point the nightly rate a guest is quoted comes from the calendar you
                pushed, and the `min_stay` you set on a night binds any stay that includes it.

                Taking the mode **switches the listing's own pricing rules off** — Maat's rules
                and your rates would otherwise be two answers to one question. Releasing does
                not switch them back on; the host re-enables the ones they still want.

                **What happens to a night you have not priced is yours to choose**, with
                `fallback_to_base_price`.

                Left `false`, a night with no rate **cannot be quoted**. It is not sold at the
                host's base price and it is not sold cheaply — it is refused, because a gap
                means the feed is broken and guessing a price for it would under-charge a peak
                night silently. Fill the horizon before you take the mode.

                Set `true`, an unpriced night falls back to the host's own base price instead
                of refusing. Choose it when your calendar covers a rolling horizon rather than
                every night you are contracted for: the listing stays bookable past the end of
                your feed instead of falling off sale. Your rates still win on every night you
                have sent one for. A listing whose host has no usable base price is refused
                either way — a fallback that could reach zero would put a stay on sale free.

                `warnings` carries `no_rates_next_30_nights` when the coming month is not
                fully priced, under either setting, and taking the mode anyway is allowed:
                building an integration incrementally is a legitimate thing to do.

                Idempotent for you. A `409 pricing_mode_conflict` means another source prices
                this listing — a pricing tool connected in the Maat app. This API does not name
                it; release it from the Maat app first.

                Only nightly-stay listings have a rate calendar; an experience or a service is
                `409 inventory_not_supported`.

                **Live in a sandbox workspace. Returns `501` against a production one, where no
                mode is taken and no rule is switched off.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/PricingModeTake'
                        example:
                            mode: partner
                            fallback_to_base_price: true
            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: You now price this listing.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PricingModeTakeResult'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                mode: partner
                                fallback_to_base_price: true
                                rules_deactivated:
                                    pricing: 2
                                coverage_until: null
                                warnings: []
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/PricingModeConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
        get:
            tags: [pricing]
            operationId: getListingPricingMode
            x-maat-scope: rates:read
            x-maat-availability: sandbox
            summary: Who prices this listing
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      // `owned_by_you` is absent unless you hold the mode.
                      if (body.owned_by_you) console.log('priced through', body.coverage_until);
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      if body.get("owned_by_you"):
                          print("priced through", body["coverage_until"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          if res.StatusCode != http.StatusOK {
                              var problem struct {
                                  Code   string `json:"code"`
                                  Detail string `json:"detail"`
                              }
                              _ = json.NewDecoder(res.Body).Decode(&problem)
                              log.Fatalf("%d %s: %s", res.StatusCode, problem.Code, problem.Detail)
                          }

                          var body struct {
                              Mode          string `json:"mode"`
                              OwnedByYou    bool   `json:"owned_by_you"`
                              CoverageUntil string `json:"coverage_until"`
                          }
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          fmt.Println(body.Mode, body.OwnedByYou, body.CoverageUntil)
                      }
            description: |
                What prices this listing right now.

                | `mode`     | Meaning                                                          |
                | :--------- | :--------------------------------------------------------------- |
                | `rules`    | Maat's own pricing rules. Your rates, if any, are stored and inert. |
                | `partner`  | You do. `owned_by_you` is `true` and the horizon fields are filled. |
                | `external` | Something else does.                                              |

                `external` is deliberately undifferentiated: this API reports that another source
                prices the listing, never which one. You see your own horizon or you see nothing.

                `last_received_at` is when **you** last sent a rate for this listing. It is the
                field to watch: if your credentials are revoked the mode does not silently
                change hands, so a horizon can run out night by night with nobody re-pushing.

                **Live in a sandbox workspace. Returns `501` against a production one, where
                nothing is read.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            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: The listing's pricing mode, as far as you are entitled to see it.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PricingMode'
                            example:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                mode: partner
                                owned_by_you: true
                                coverage_until: '2026-12-31'
                                currency: EGP
                                last_received_at: '2026-09-01T09:14:22Z'
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/InventoryNotSupported' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
        delete:
            tags: [pricing]
            operationId: releaseListingPricingMode
            x-maat-scope: rates:write
            x-maat-availability: sandbox
            summary: Stop pricing this listing
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body -X DELETE "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN" -i
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode', {
                          method: 'DELETE',
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

                      // 204 whether or not you held the mode — releasing twice is not an error.
                      if (res.status !== 204) {
                          const body = await res.json();
                          throw new Error(`${body.code}: ${body.detail}`);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.delete(
                          "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      if res.status_code != 204:
                          body = res.json()
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")
                - lang: go
                  label: Go
                  source: |
                      package main

                      import (
                          "fmt"
                          "log"
                          "net/http"
                          "os"
                      )

                      func main() {
                          req, _ := http.NewRequest(http.MethodDelete, "https://maatstays.wtf/v1/listings/7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24/pricing-mode", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()
                          fmt.Println(res.StatusCode) // 204
                      }
            description: |
                Hand the listing back to Maat's own pricing.

                Your rate rows are **kept** — they are your submission and your record of what
                you quoted, and a re-take resumes on them. What changes is that they stop being
                read.

                The listing's own pricing rules are **not** switched back on. Taking the mode
                turned them off; the host decides which to re-enable, because auto-restoring
                would revive rules they may have been glad to see go.

                Owner only, and idempotent: releasing a listing you do not price answers `204`
                just the same. The host can also release from the Maat app at any time, and
                does not need your agreement to do it.

                **Live in a sandbox workspace. Returns `501` against a production one, where no
                mode is released.**
            parameters:
                - $ref: '#/components/parameters/ListingId'
            responses:
                '204':
                    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 listing is back on Maat's own pricing.
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '404': { $ref: '#/components/responses/ListingNotFound' }
                '409': { $ref: '#/components/responses/PricingModeConflict' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }

    /finance/balance:
        get:
            tags: [finance]
            operationId: getFinanceBalance
            x-maat-scope: finance:read
            x-maat-availability: sandbox
            summary: Read your balance, per currency
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/finance/balance" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/finance/balance', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      for (const partition of body.partitions) {
                          // One partition per currency. Never add two together.
                          console.log(partition.currency, partition.available.value);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/finance/balance",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      for partition in body["partitions"]:
                          # One partition per currency. Never add two together.
                          print(partition["currency"], partition["available"]["value"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type money struct {
                          Value    string `json:"value"`
                          Currency string `json:"currency"`
                      }

                      type partition struct {
                          Currency  string `json:"currency"`
                          Available money  `json:"available"`
                      }

                      type balance struct {
                          Partitions []partition `json:"partitions"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet,
                              "https://maatstays.wtf/v1/finance/balance", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          var body balance
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          if res.StatusCode >= 400 {
                              log.Fatalf("balance failed: %d", res.StatusCode)
                          }

                          for _, p := range body.Partitions {
                              fmt.Println(p.Currency, p.Available.Value)
                          }
                      }
            description: |
                Where your money is right now, as one partition per settlement currency.

                A workspace has as many balances as it has settled currencies, and they are
                separate positions — not one figure in a preferred currency. Nothing in this API
                adds one currency to another and nothing converts between them. If you want a
                single headline number, convert the partitions yourself, with a rate you chose and
                can explain.

                `available + reserved_for_payout + paid_out_total` is everything that has cleared.
                `pending` is on top of that and has not: it is money a guest has paid, held until
                the stay starts. `outstanding_penalties` is what you OWE, and it is deliberately
                not subtracted from any of them — it is settled from released funds, and netting
                it here would make one number mean two things.

                A currency you have never traded in is **absent**, not zero. Absence means nothing
                has ever happened there.

                **Live in a sandbox workspace. Returns `501` against a production one.** A sandbox
                never charges anybody, so it answers `{ "partitions": [] }` — the real shape, with
                nothing in it.
            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: Your position, one partition per settlement currency.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/FinanceBalance'
                            example:
                                partitions:
                                    - currency: EGP
                                      pending: { value: '4200.00', currency: EGP }
                                      available: { value: '18100.00', currency: EGP }
                                      reserved_for_payout: { value: '5000.00', currency: EGP }
                                      paid_out_total: { value: '96500.00', currency: EGP }
                                      outstanding_penalties: { value: '300.00', currency: EGP }
                                    - currency: USD
                                      pending: { value: '0.00', currency: USD }
                                      available: { value: '640.00', currency: USD }
                                      reserved_for_payout: { value: '0.00', currency: USD }
                                      paid_out_total: { value: '1280.00', currency: USD }
                                      outstanding_penalties: { value: '0.00', currency: USD }
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /finance/earnings:
        get:
            tags: [finance]
            operationId: listEarnings
            x-maat-scope: finance:read
            x-maat-availability: sandbox
            summary: List what each payment earned you
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/finance/earnings?from=2026-09-01&to=2026-09-30&page_size=100" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

                      for (let page = 1; ; page += 1) {
                          const url = new URL('https://maatstays.wtf/v1/finance/earnings');
                          url.searchParams.set('from', '2026-09-01');
                          url.searchParams.set('to', '2026-09-30');
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '100');

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

                          for (const line of body.items) {
                              console.log(line.earned_on, line.net.value, line.net.currency, line.status);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      page = 1
                      while True:
                          res = requests.get(
                              "https://maatstays.wtf/v1/finance/earnings",
                              headers=headers,
                              params={
                                  "from": "2026-09-01",
                                  "to": "2026-09-30",
                                  "page": page,
                                  "page_size": 100,
                              },
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for line in body["items"]:
                              print(line["earned_on"], line["net"]["value"], line["net"]["currency"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type money struct {
                          Value    string `json:"value"`
                          Currency string `json:"currency"`
                      }

                      type earning struct {
                          EarnedOn string `json:"earned_on"`
                          Net      money  `json:"net"`
                          Status   string `json:"status"`
                      }

                      type page struct {
                          Items   []earning `json:"items"`
                          HasMore bool      `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("from", "2026-09-01")
                              query.Set("to", "2026-09-30")
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "100")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/finance/earnings?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatal(err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, e := range body.Items {
                                  fmt.Println(e.EarnedOn, e.Net.Value, e.Net.Currency, e.Status)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                One row per PAYMENT, newest stay first — the lines behind the `available` and
                `pending` figures on your balance.

                One row is one payment, not one booking. A booking that is later modified takes
                its own additional charge and therefore has its own row, with its own stay date.

                **`earned_on` is the stay's CHECK-IN date, not the date the money moved.** June
                revenue means the stays that happened in June, which is the only bucketing that
                makes a year-over-year comparison line up seasonally. It is the same convention
                the Maat host dashboard uses, so the two agree.

                `gross` is what the platform booked as YOUR income for that payment — the payout
                plus any cancellation-insurance premium. It is already net of the platform's
                commission and service fee, so it is not the guest's total; for what a guest paid,
                read `amount_paid` on the booking. `refunded` is what was reversed, positive, and
                `net` is `gross - refunded`.

                `status` says how far that payment's money has got, from held in escrow to
                transferred out. `reserved` means it is committed to an open payout request:
                cleared, but neither withdrawable nor paid.

                Filter with `from`, `to` and `listing_id`; page with `page` and `page_size`, and
                keep asking while `has_more` is `true`. Both date bounds are inclusive, an
                inverted range is refused rather than answered with an empty page, and a window
                wider than 731 days is refused rather than truncated.

                **Live in a sandbox workspace. Returns `501` against a production one.** A sandbox
                never charges anybody, so it answers an empty page.
            parameters:
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
                - $ref: '#/components/parameters/FinanceFrom'
                - $ref: '#/components/parameters/FinanceTo'
                - $ref: '#/components/parameters/FinanceListingId'
            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 page of earning lines.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/EarningList'
                            example:
                                items:
                                    - booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                      listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                      earned_on: '2026-09-12'
                                      gross: { value: '4100.00', currency: EGP }
                                      refunded: { value: '0.00', currency: EGP }
                                      net: { value: '4100.00', currency: EGP }
                                      platform_fee: { value: '500.00', currency: EGP }
                                      refunded_platform_fee: { value: '0.00', currency: EGP }
                                      status: pending
                                    - booking_reference: 77b0d4e1-9a44-4b5e-8f1a-2c3d4e5f6081
                                      listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                      earned_on: '2026-09-02'
                                      gross: { value: '3200.00', currency: EGP }
                                      refunded: { value: '800.00', currency: EGP }
                                      net: { value: '2400.00', currency: EGP }
                                      platform_fee: { value: '400.00', currency: EGP }
                                      refunded_platform_fee: { value: '100.00', currency: EGP }
                                      status: available
                                has_more: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /finance/earnings/summary:
        get:
            tags: [finance]
            operationId: getEarningsSummary
            x-maat-scope: finance:read
            x-maat-availability: sandbox
            summary: Total your earnings by day, week or month
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/finance/earnings/summary?from=2026-09-01&to=2026-09-30&granularity=month" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const url = new URL('https://maatstays.wtf/v1/finance/earnings/summary');
                      url.searchParams.set('from', '2026-09-01');
                      url.searchParams.set('to', '2026-09-30');
                      url.searchParams.set('granularity', 'month');

                      const res = await fetch(url, {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      for (const bucket of body.buckets) {
                          // A bucket is a period AND a currency. Two currencies in one month are
                          // two buckets, never one sum.
                          console.log(bucket.period, bucket.currency, bucket.net.value);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/finance/earnings/summary",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          params={"from": "2026-09-01", "to": "2026-09-30", "granularity": "month"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      for bucket in body["buckets"]:
                          # A bucket is a period AND a currency.
                          print(bucket["period"], bucket["currency"], bucket["net"]["value"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type money struct {
                          Value string `json:"value"`
                      }

                      type bucket struct {
                          Period   string `json:"period"`
                          Currency string `json:"currency"`
                          Net      money  `json:"net"`
                      }

                      type summary struct {
                          Buckets []bucket `json:"buckets"`
                      }

                      func main() {
                          query := url.Values{}
                          query.Set("from", "2026-09-01")
                          query.Set("to", "2026-09-30")
                          query.Set("granularity", "month")

                          req, _ := http.NewRequest(http.MethodGet,
                              "https://maatstays.wtf/v1/finance/earnings/summary?"+query.Encode(), nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          var body summary
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          if res.StatusCode >= 400 {
                              log.Fatalf("summary failed: %d", res.StatusCode)
                          }

                          for _, b := range body.Buckets {
                              fmt.Println(b.Period, b.Currency, b.Net.Value)
                          }
                      }
            description: |
                The same earning lines, totalled — one bucket per period AND per currency.

                A month in which you settled in two currencies is TWO buckets, not one sum. There
                is no total across them anywhere in this API.

                `period` is the bucket's FIRST day at every granularity, so `month` gives
                `2026-09-01` rather than `2026-09` and one date parser reads all three. A week
                starts on the Monday.

                `bookings` counts the distinct bookings that contributed. A payment we have not
                yet matched to a booking counts in the amounts and not in that number — a window
                of seconds after a payment settles.

                `from`, `to` and `granularity` are all REQUIRED. A summary aggregates everything
                inside its window in one pass, so an unbounded one would sweep your whole history
                on every call; and the three granularities bucket the same rows very differently,
                so we would rather be told which you want than guess. The window is inclusive at
                both ends and may span at most 731 days.

                Use `listing_id` to narrow to one listing. It filters inside your own workspace,
                so a listing that is not yours returns an empty series rather than somebody
                else's revenue.

                **Live in a sandbox workspace. Returns `501` against a production one.** A sandbox
                never charges anybody, so it answers an empty series.
            parameters:
                - $ref: '#/components/parameters/FinanceFromRequired'
                - $ref: '#/components/parameters/FinanceToRequired'
                - $ref: '#/components/parameters/FinanceGranularity'
                - $ref: '#/components/parameters/FinanceListingId'
            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 bucket per period and currency, oldest first.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/EarningsSummary'
                            example:
                                buckets:
                                    - period: '2026-09-01'
                                      currency: EGP
                                      gross: { value: '7300.00', currency: EGP }
                                      refunded: { value: '800.00', currency: EGP }
                                      net: { value: '6500.00', currency: EGP }
                                      platform_fee: { value: '900.00', currency: EGP }
                                      refunded_platform_fee: { value: '100.00', currency: EGP }
                                      bookings: 2
                                    - period: '2026-09-01'
                                      currency: USD
                                      gross: { value: '640.00', currency: USD }
                                      refunded: { value: '0.00', currency: USD }
                                      net: { value: '640.00', currency: USD }
                                      platform_fee: { value: '80.00', currency: USD }
                                      refunded_platform_fee: { value: '0.00', currency: USD }
                                      bookings: 1
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /finance/payouts:
        get:
            tags: [finance]
            operationId: listPayouts
            x-maat-scope: finance:read
            x-maat-availability: sandbox
            summary: List your payout requests
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/finance/payouts?page_size=100" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

                      for (let page = 1; ; page += 1) {
                          const url = new URL('https://maatstays.wtf/v1/finance/payouts');
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '100');

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

                          for (const payout of body.items) {
                              console.log(payout.payout_id, payout.status, payout.amount.value);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      page = 1
                      while True:
                          res = requests.get(
                              "https://maatstays.wtf/v1/finance/payouts",
                              headers=headers,
                              params={"page": page, "page_size": 100},
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for payout in body["items"]:
                              print(payout["payout_id"], payout["status"], payout["amount"]["value"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type money struct {
                          Value    string `json:"value"`
                          Currency string `json:"currency"`
                      }

                      type payout struct {
                          PayoutID string `json:"payout_id"`
                          Status   string `json:"status"`
                          Amount   money  `json:"amount"`
                      }

                      type page struct {
                          Items   []payout `json:"items"`
                          HasMore bool     `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "100")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/finance/payouts?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatal(err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, p := range body.Items {
                                  fmt.Println(p.PayoutID, p.Status, p.Amount.Value)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                Every payout your workspace has requested, newest first, with what happened to it.

                `resolved_at` is when it stopped being open — paid, rejected or cancelled — and is
                `null` while it is still `requested`.

                **There is no destination on any of these rows.** No withdrawal method, no bank or
                wallet, no IBAN, no transfer tracking number, no requester. Those are your banking
                details, and an API key in a config file is not the credential that should be able
                to read them; they stay in the Maat app, behind a person signing in.

                **This API cannot request a payout, and will not be able to.** Asking to be paid
                commits banking details and moves real money — an act a person takes.

                A payout is made in exactly one currency; read it from `amount`.

                **Live in a sandbox workspace. Returns `501` against a production one.** A sandbox
                never charges anybody, so there is nothing to pay out and it answers an empty page.
            parameters:
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
            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 page of payout requests.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PayoutList'
                            example:
                                items:
                                    - payout_id: 8f5c2d10-3b47-4e29-9a6d-1c0e7b4f8a52
                                      status: paid
                                      amount: { value: '96500.00', currency: EGP }
                                      requested_at: '2026-09-01T10:00:00.000Z'
                                      resolved_at: '2026-09-03T09:30:00.000Z'
                                    - payout_id: 4a9e6c73-2f18-4d50-b7c1-6e3a0d5b92f4
                                      status: requested
                                      amount: { value: '5000.00', currency: EGP }
                                      requested_at: '2026-09-07T08:15:00.000Z'
                                      resolved_at: null
                                has_more: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /finance/payouts/upcoming:
        get:
            tags: [finance]
            operationId: listUpcomingPayouts
            x-maat-scope: finance:read
            x-maat-availability: sandbox
            summary: See what is due to be released
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/finance/payouts/upcoming" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const res = await fetch('https://maatstays.wtf/v1/finance/payouts/upcoming', {
                          headers: { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` },
                      });

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

                      for (const row of body.items) {
                          console.log(row.available_on, row.amount.value, row.amount.currency, row.bookings);
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      res = requests.get(
                          "https://maatstays.wtf/v1/finance/payouts/upcoming",
                          headers={"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"},
                          timeout=30,
                      )
                      body = res.json()
                      if res.status_code >= 400:
                          raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                      for row in body["items"]:
                          print(row["available_on"], row["amount"]["value"], row["bookings"])
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type money struct {
                          Value    string `json:"value"`
                          Currency string `json:"currency"`
                      }

                      type upcoming struct {
                          AvailableOn string `json:"available_on"`
                          Amount      money  `json:"amount"`
                          Bookings    int    `json:"bookings"`
                      }

                      type list struct {
                          Items []upcoming `json:"items"`
                      }

                      func main() {
                          req, _ := http.NewRequest(http.MethodGet,
                              "https://maatstays.wtf/v1/finance/payouts/upcoming", nil)
                          req.Header.Set("Authorization", "Bearer "+os.Getenv("MAAT_PARTNER_TOKEN"))

                          res, err := http.DefaultClient.Do(req)
                          if err != nil {
                              log.Fatal(err)
                          }
                          defer res.Body.Close()

                          var body list
                          if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
                              log.Fatal(err)
                          }
                          if res.StatusCode >= 400 {
                              log.Fatalf("upcoming failed: %d", res.StatusCode)
                          }

                          for _, u := range body.Items {
                              fmt.Println(u.AvailableOn, u.Amount.Value, u.Amount.Currency, u.Bookings)
                          }
                      }
            description: |
                The escrowed money on its way to you, grouped by the date it is released and the
                currency it settled in.

                Funds are released when the stay starts, so `available_on` is a check-in date and
                the whole answer is in the future. Only stays you have CONFIRMED are counted: a
                request nobody has answered is not money on its way.

                One row is one date AND one currency. Two currencies releasing on the same day are
                two rows, and the currency is on `amount`.

                This list is **not paged**. It is bounded by the platform rather than by your
                history — future release dates on confirmed stays only — so a page parameter
                would suggest an unbounded set and invite a walk of one.

                **Live in a sandbox workspace. Returns `501` against a production one.** A sandbox
                never charges anybody, so it answers an empty list.
            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: What is due to be released, by date and currency.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/UpcomingPayoutList'
                            example:
                                items:
                                    - available_on: '2026-09-12'
                                      amount: { value: '4100.00', currency: EGP }
                                      bookings: 1
                                    - available_on: '2026-09-30'
                                      amount: { value: '640.00', currency: USD }
                                      bookings: 2
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

    /finance/penalties:
        get:
            tags: [finance]
            operationId: listPenalties
            x-maat-scope: finance:read
            x-maat-availability: sandbox
            summary: List what you owe
            x-codeSamples:
                - lang: bash
                  label: cURL
                  source: |
                      curl --fail-with-body "https://maatstays.wtf/v1/finance/penalties?page_size=100" \
                        -H "Authorization: Bearer $MAAT_PARTNER_TOKEN"
                - lang: javascript
                  label: Node.js
                  source: |
                      const headers = { Authorization: `Bearer ${process.env.MAAT_PARTNER_TOKEN}` };

                      for (let page = 1; ; page += 1) {
                          const url = new URL('https://maatstays.wtf/v1/finance/penalties');
                          url.searchParams.set('page', String(page));
                          url.searchParams.set('page_size', '100');

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

                          for (const penalty of body.items) {
                              console.log(penalty.penalty_id, penalty.status, penalty.outstanding.value);
                          }
                          if (!body.has_more) break;
                      }
                - lang: python
                  label: Python
                  source: |
                      import os

                      import requests

                      headers = {"Authorization": f"Bearer {os.environ['MAAT_PARTNER_TOKEN']}"}

                      page = 1
                      while True:
                          res = requests.get(
                              "https://maatstays.wtf/v1/finance/penalties",
                              headers=headers,
                              params={"page": page, "page_size": 100},
                              timeout=30,
                          )
                          body = res.json()
                          if res.status_code >= 400:
                              raise RuntimeError(f"{body['code']}: {body.get('detail')}")

                          for penalty in body["items"]:
                              print(penalty["penalty_id"], penalty["status"], penalty["outstanding"]["value"])
                          if not body["has_more"]:
                              break
                          page += 1
                - lang: go
                  label: Go
                  source: |
                      package main

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

                      type money struct {
                          Value    string `json:"value"`
                          Currency string `json:"currency"`
                      }

                      type penalty struct {
                          PenaltyID   string `json:"penalty_id"`
                          Status      string `json:"status"`
                          Outstanding money  `json:"outstanding"`
                      }

                      type page struct {
                          Items   []penalty `json:"items"`
                          HasMore bool      `json:"has_more"`
                      }

                      func main() {
                          token := os.Getenv("MAAT_PARTNER_TOKEN")

                          for pageNum := 1; ; pageNum++ {
                              query := url.Values{}
                              query.Set("page", strconv.Itoa(pageNum))
                              query.Set("page_size", "100")

                              req, _ := http.NewRequest(http.MethodGet,
                                  "https://maatstays.wtf/v1/finance/penalties?"+query.Encode(), nil)
                              req.Header.Set("Authorization", "Bearer "+token)

                              res, err := http.DefaultClient.Do(req)
                              if err != nil {
                                  log.Fatal(err)
                              }

                              var body page
                              err = json.NewDecoder(res.Body).Decode(&body)
                              res.Body.Close()
                              if err != nil {
                                  log.Fatalf("list failed: %v", err)
                              }
                              if res.StatusCode >= 400 {
                                  log.Fatalf("list failed: %d", res.StatusCode)
                              }

                              for _, p := range body.Items {
                                  fmt.Println(p.PenaltyID, p.Status, p.Outstanding.Value)
                              }
                              if !body.HasMore {
                                  break
                              }
                          }
                      }
            description: |
                Penalties charged to your workspace, newest first — `amount` is what was charged
                and `outstanding` is what is still owed.

                A penalty is a LIABILITY, not negative revenue. It is never netted against an
                earning anywhere in this API, and the `outstanding_penalties` figure on your
                balance is the same set totalled per currency. Settlement happens from released
                funds as stays check in, from your balance, or by card in the Maat app.

                `status` is `partially_settled` while some of it has been paid, and `written_off`
                when it was forgiven rather than paid — a decision somebody made, which is why it
                is not folded into `settled`.

                **Read-only.** There is no settlement, no payment and no dispute on this API:
                each of those moves money or opens a conversation, and both belong to a person.

                **Live in a sandbox workspace. Returns `501` against a production one.** A sandbox
                never charges anybody, so it answers an empty page.
            parameters:
                - $ref: '#/components/parameters/Page'
                - $ref: '#/components/parameters/PageSize'
            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 page of penalties.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/PenaltyList'
                            example:
                                items:
                                    - penalty_id: 1d7f4b26-9c58-4a31-8e0b-5f2c6a9d3e71
                                      booking_reference: 77b0d4e1-9a44-4b5e-8f1a-2c3d4e5f6081
                                      reason: host_cancellation
                                      amount: { value: '300.00', currency: EGP }
                                      outstanding: { value: '300.00', currency: EGP }
                                      status: outstanding
                                      issued_at: '2026-09-02T09:15:00.000Z'
                                has_more: false
                '400': { $ref: '#/components/responses/ValidationFailed' }
                '401': { $ref: '#/components/responses/Unauthorized' }
                '403': { $ref: '#/components/responses/Forbidden' }
                '429': { $ref: '#/components/responses/RateLimited' }
                '500': { $ref: '#/components/responses/InternalError' }
                '501': { $ref: '#/components/responses/NotEnabled' }
                '503': { $ref: '#/components/responses/ServiceUnavailable' }

webhooks:
    endpoint.challenge:
        post:
            tags: [booking-notifications]
            operationId: onEndpointChallenge
            summary: Prove the endpoint is yours
            description: |
                Delivered **during registration**, and again on every re-registration, to the URL
                you gave `PUT /v1/webhooks/endpoint`. Nothing real is ever sent to an endpoint
                that has not answered this.

                Answer `2xx` with a JSON body echoing the same `challenge` string back:

                ```json
                { "challenge": "<the value we sent>" }
                ```

                Anything else leaves the endpoint `pending_verification` and no booking
                notification is sent. Register again to retry.

                ### It is signed exactly like a real delivery

                Same headers, same scheme. That is deliberate: your verification code is exercised
                here, before a guest's arrival depends on it.

                Verify the signature over the **raw request body**, before any JSON parsing:

                ```
                expected = HMAC_SHA256(secret, "v1:" + X-Signature-Timestamp + ":" + raw_body)
                ```

                compared in hex, in constant time, against `X-Signature`, with
                `X-Signature-Timestamp` inside five minutes of now. `JSON.parse` followed by
                `JSON.stringify` does not round-trip byte-identically — key order, whitespace and
                `1.0` → `1` all move — so re-serialising the body will make every signature look
                wrong.

                Respond quickly: we wait five seconds, inside your own registration request.

                Treat the challenge value as single-use. It is not a credential and it is not
                reused, so nothing needs to store it.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/EndpointChallengeDelivery'
                        example:
                            event_id: 1c6d7f04-8a2b-4b7c-9f1e-2d3c4b5a6978
                            event_type: endpoint.challenge
                            occurred_at: '2026-08-30T18:41:02Z'
                            data:
                                challenge: 9pQ2sXk1vJ7bN4mR0tYuZaCdEfGhIjKlMnOpQrStUvW
            responses:
                '200':
                    description: |
                        Echo `{ "challenge": "<value>" }`. Any 2xx is accepted; the BODY is what
                        we check.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/EndpointChallengePayload'
                            example:
                                challenge: 9pQ2sXk1vJ7bN4mR0tYuZaCdEfGhIjKlMnOpQrStUvW

    booking.confirmed:
        post:
            tags: [booking-notifications]
            operationId: onBookingConfirmed
            summary: A guest booked one of your properties
            description: |
                Delivered when a guest completes and pays for a booking on one of your listings.
                This is the arrival-management and reconciliation event.

                It carries no guest identity at all — no name, no email, no phone. `console_url`
                opens the booking in your own Maat host console, behind the platform's own access
                control, where the people on your team who may see the guest can.

                It carries no amount either: `GET /v1/bookings/{booking_reference}` answers what
                the guest paid, as one amount with its currency.

                ### Verifying a delivery

                Every delivery carries:

                | Header                  | What it is                                                        |
                | :---------------------- | :---------------------------------------------------------------- |
                | `X-Signature`           | `HMAC-SHA256(secret, "v1:" + timestamp + ":" + raw_body)`, hex.    |
                | `X-Signature-Timestamp` | Unix seconds. Covered BY the signature — reject anything > 5m old. |
                | `X-Signature-Previous`  | The same body under your PREVIOUS secret. Rotation windows only.   |

                Verify over the **raw request body, before any JSON parsing**. Re-serialised JSON
                does not round-trip byte-identically, and a re-serialised body will fail every
                time. Compare in constant time.

                `X-Signature-Previous` is present only while a rotation overlap is open. Accept a
                delivery if EITHER signature matches, and treat its absence as normal — it means
                no rotation is in flight, not that something is wrong.

                ### Retries and idempotency

                `event_id` is stable across our retries, so use it as your idempotency key. We
                back off on failure and give up after several attempts; a delivery we abandon is
                surfaced to our operators rather than silently dropped. We never follow a redirect
                — if your endpoint moves, register the new URL.

                `occurred_at` is when the booking was confirmed, stamped in the same
                transaction as the confirmation — not when we prepared this delivery. It is safe
                to sort on; see the webhooks guide's
                [What `occurred_at` actually is](https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is)
                for the one thing it still does not give you.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/DeveloperBookingConfirmedDelivery'
                        example:
                            event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                            event_type: booking.confirmed
                            occurred_at: '2026-08-30T18:42:11Z'
                            data:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: confirmed
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: 3
                                previous_stay: null
                                cancellation: null
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
            responses:
                '200':
                    description: |
                        Return any 2xx to acknowledge. Anything else is retried with exponential
                        backoff; `event_id` is stable across retries.

    booking.cancelled:
        post:
            tags: [booking-notifications]
            operationId: onBookingCancelled
            summary: A booking on one of your properties was cancelled
            description: |
                Delivered when a confirmed booking ends before the stay. `booking_reference`
                matches the `booking.confirmed` delivery you already have.

                `initiated_by` is the field that decides what you do next: `guest` means the
                nights are free to re-sell, `host` means the property withdrew and somebody has to
                be re-accommodated.

                The refund and penalty terms are not included. Those are between Maat, the guest
                and the property.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/DeveloperBookingCancelledDelivery'
                        example:
                            event_id: 1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081
                            event_type: booking.cancelled
                            occurred_at: '2026-09-02T07:05:00Z'
                            data:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: cancelled
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: 3
                                previous_stay: null
                                cancellation:
                                    cancelled_at: '2026-09-02T07:05:00Z'
                                    initiated_by: guest
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    booking.requested:
        post:
            tags: [booking-notifications]
            operationId: onBookingRequested
            summary: A guest paid, and the host has to answer
            description: |
                Delivered when a guest completes and pays for a booking on a listing whose host
                approves each one, so it landed `pending` rather than `confirmed`. The host now
                has a decision to make, and so do you: this is the event that opens a request
                queue.

                `POST /v1/bookings/{booking_reference}/confirm` and `…/reject` are how you answer
                it, if your credential holds `bookings:manage`.

                **You receive this, or `booking.confirmed` — not both.** A listing set to
                instant-book confirms the stay the moment the guest pays, so it sends
                `booking.confirmed` and no `booking.requested` at all. Where the host does
                decide, `booking.requested` arrives when the payment lands and
                `booking.confirmed` or `booking.rejected` follows when they answer: one
                approval, two ends. It is not sent for a change to a stay that already exists —
                that is `booking.modified` — and a host-invited stay currently produces neither
                this nor `booking.confirmed`.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE: those hang
                off no unit, so they have no door times and no owning property, and this payload
                states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/DeveloperBookingRequestedDelivery'
                        example:
                            event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                            event_type: booking.requested
                            occurred_at: '2026-09-02T07:05:00Z'
                            data:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: pending
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: 2
                                previous_stay: null
                                cancellation: null
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    booking.rejected:
        post:
            tags: [booking-notifications]
            operationId: onBookingRejected
            summary: A pending request was declined
            description: |
                Delivered when a pending request-to-book was declined — by the host in the app,
                or through `POST /v1/bookings/{booking_reference}/reject`. The nights are free.

                `stay.guests` is `null`: this event does not state a party size.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE: those hang
                off no unit, so they have no door times and no owning property, and this payload
                states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/DeveloperBookingRejectedDelivery'
                        example:
                            event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                            event_type: booking.rejected
                            occurred_at: '2026-09-02T07:05:00Z'
                            data:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: rejected
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: null
                                previous_stay: null
                                cancellation: null
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    booking.checked_in:
        post:
            tags: [booking-notifications]
            operationId: onBookingCheckedIn
            summary: The guest confirmed they have arrived
            description: |
                Delivered when the GUEST confirms their own arrival, never on a clock. A stay
                whose guest does not confirm never fires this — which is the point: a delayed
                flight is not an arrival, and an automation that assumed otherwise would greet
                somebody who is still in an airport.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE: those hang
                off no unit, so they have no door times and no owning property, and this payload
                states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/DeveloperBookingCheckedInDelivery'
                        example:
                            event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                            event_type: booking.checked_in
                            occurred_at: '2026-09-02T07:05:00Z'
                            data:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: checked_in
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: null
                                previous_stay: null
                                cancellation: null
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    booking.checked_out:
        post:
            tags: [booking-notifications]
            operationId: onBookingCheckedOut
            summary: The guest confirmed they have left
            description: |
                Delivered when the GUEST confirms their own departure. `status` reads
                `completed`, because that is the state Maat holds — there is no `checked_out`
                booking status, and publishing one would invent a state.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE: those hang
                off no unit, so they have no door times and no owning property, and this payload
                states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/DeveloperBookingCheckedOutDelivery'
                        example:
                            event_id: 9a1d2c37-4e5f-4a6b-8c9d-0e1f2a3b4c5d
                            event_type: booking.checked_out
                            occurred_at: '2026-09-02T07:05:00Z'
                            data:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: completed
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: null
                                previous_stay: null
                                cancellation: null
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    booking.modified:
        post:
            tags: [booking-notifications]
            operationId: onBookingModified
            summary: A modification was applied and the dates moved
            description: |
                Delivered when a booking modification is APPLIED — not when it is requested or
                approved, but when the stay actually changed.

                `stay` is the NEW window and `previous_stay` is the one it replaced, so you can
                free the nights you were holding without diffing against your own copy.
                `status` is where the booking is at apply time, which can be `checked_in` for a
                guest extending a stay they are already in.

                The pricing consequences are not here: what a modification cost, refunded or
                re-charged is a settlement between Maat, the guest and the property.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE: those hang
                off no unit, so they have no door times and no owning property, and this payload
                states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/DeveloperBookingModifiedDelivery'
                        example:
                            event_id: 2b3c4d5e-6f70-4821-9a3b-4c5d6e7f8192
                            event_type: booking.modified
                            occurred_at: '2026-09-05T12:00:00Z'
                            data:
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: confirmed
                                stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-19'
                                    check_in_time: '15:00'
                                    check_out_time: '12:00'
                                    guests: null
                                previous_stay:
                                    check_in: '2026-09-12'
                                    check_out: '2026-09-16'
                                cancellation: null
                                console_url: https://maatstays.wtf/bookings/5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    review.created:
        post:
            tags: [reviews]
            operationId: onReviewCreated
            summary: A guest published a review
            description: |
                Delivered when a guest publishes a review of a stay on one of your listings.

                It tells you a review exists and what it scored. **It does not name the
                reviewer** — read `GET /v1/listings/{listing_id}/reviews` for the review itself,
                which carries the same shortened label the public listing page shows.

                `POST /v1/reviews/{review_id}/reply` posts the workspace's reply, one per review.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE: those hang
                off no unit, so they have no door times and no owning property, and this payload
                states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ReviewCreatedDelivery'
                        example:
                            event_id: 7d8e9f01-2a3b-4c5d-8e9f-0a1b2c3d4e5f
                            event_type: review.created
                            occurred_at: '2026-09-20T09:00:00Z'
                            data:
                                review_id: 2b1c3d4e-5f60-4718-9a2b-3c4d5e6f7081
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                                rating: 4.5
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    listing.published:
        post:
            tags: [listings]
            operationId: onListingPublished
            summary: A listing went on sale
            description: |
                Delivered when a listing becomes bookable — usually because a property push
                finally satisfied the validity gate (a price, an amenity, a cover photo and
                enough pictures). It is the answer to "did my push go live?", instead of polling
                `GET /v1/listings/{listing_id}` until it changes.

                Delivered once per transition. An ordinary edit to a listing that is already on
                sale is not one.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE: those hang
                off no unit, so they have no door times and no owning property, and this payload
                states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ListingPublicationDelivery'
                        example:
                            event_id: 3c4d5e6f-7081-492a-8b3c-4d5e6f708192
                            event_type: listing.published
                            occurred_at: '2026-09-01T08:00:00Z'
                            data:
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: published
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/listings
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    listing.unlisted:
        post:
            tags: [listings]
            operationId: onListingUnlisted
            summary: A listing came off sale
            description: |
                Delivered when a listing stops being bookable — an explicit
                `PATCH /v1/listings/{listing_id} { "status": "unlisted" }`, a push that no longer
                satisfies the validity gate, or a decision on Maat's side.

                It says **whether**, not **why**. Some of the reasons a listing comes off sale
                are commercial matters between Maat and the property, and none of those is on
                this wire.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE: those hang
                off no unit, so they have no door times and no owning property, and this payload
                states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/ListingPublicationDelivery'
                        example:
                            event_id: 4d5e6f70-8192-4a3b-9c4d-5e6f70819203
                            event_type: listing.unlisted
                            occurred_at: '2026-09-04T16:30:00Z'
                            data:
                                property:
                                    provider_id: my-prop-9001
                                    listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                status: unlisted
                                console_url: https://maatstays.wtf/properties/3c9d1f42-7b60-4a18-9e55-0d2a6f8c31b7/listings
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    pricing.mode_changed:
        post:
            tags: [pricing]
            operationId: onPricingModeChanged
            summary: Who prices a listing changed
            description: |
                Delivered when a listing's pricing mode moves — you took it, you released it, the
                host released it in the app, or the host connected or disconnected PriceLabs.

                This is how you find out that a listing you were pricing is no longer yours to
                price, **from us rather than from a `409 pricing_mode_conflict` on your next
                push** — or from a host
                asking why their calendar stopped updating.

                Every value fires: `partner` when a credential takes the mode, `rules` when it
                goes back to Maat's engine, `external` when a host connects another pricer.
                `external` never names which source took the price.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/PricingModeDelivery'
                        example:
                            event_id: 5e6f7081-9203-4b4c-8d5e-6f7081920314
                            event_type: pricing.mode_changed
                            occurred_at: '2026-09-06T11:45:00Z'
                            data:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                mode: rules
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    pricing.currency_changed:
        post:
            tags: [pricing]
            operationId: onPricingCurrencyChanged
            summary: A listing you price changed currency
            description: |
                Delivered when a listing in `partner` pricing mode changes currency. **Its future
                rates were purged in the same breath** — a rate row carries no currency beyond
                the one stamped on it, and an old-currency number wearing a right-looking date is
                a wrong amount rather than a record.

                Nothing re-pushes your calendar for you, so the listing is unpriceable until you
                push again in the currency this names.

                The example below is the only `USD` amount in this document, and it is one on
                purpose: it shows a listing that was priced in `EGP` — the currency every other
                example uses — moving to another. `currency` is whatever the workspace chose;
                there is no default and nothing is ever converted.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/PricingCurrencyDelivery'
                        example:
                            event_id: 6f708192-0314-4c5d-9e6f-708192031425
                            event_type: pricing.currency_changed
                            occurred_at: '2026-09-06T12:00:00Z'
                            data:
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                currency: USD
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    calendar.held:
        post:
            tags: [calendar]
            operationId: onCalendarHeld
            summary: Nights went off sale while a guest pays
            description: |
                Delivered the moment a guest reaches checkout on one of your listings. The
                nights are off sale on Maat from that instant — before any money moves, and
                before there is a booking.

                **This is the window that used to be invisible.** A hold lasts minutes, and
                until this event the only way to learn about one was to watch nights disappear
                from `GET /v1/listings/{listing_id}/calendar`. An integration reconciling
                availability at minute three of a hold saw nights it could still sell, sold one,
                and at minute fourteen the Maat guest paid for the same room.

                Stop selling `first_night` through `last_night` when this arrives, and wait for
                the matching [`calendar.released`](/api-reference/webhooks/calendar.released) —
                which says whether they came back or were sold.

                `until` is when the hold lapses **if nothing else happens to it**. Treat it as a
                deadline, not as an authority: the release event is what frees the nights. If a
                checkout is given longer, a second `calendar.held` arrives with the same
                `hold_reference` and a later `until`; take the later one. There is no third
                event type for an extension.

                `unit_id` matters here more than on any other event: availability is keyed on
                the physical unit, so a hold on one listing makes every other listing of the
                same unit unsellable for those nights too.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE. Those are
                sold by the seat on a session rather than by the night on a unit, so they have
                neither a unit nor nights, and this payload states both.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/CalendarHeldDelivery'
                        example:
                            event_id: 70819203-1425-4d6e-8f70-819203142536
                            event_type: calendar.held
                            occurred_at: '2026-08-30T18:41:02Z'
                            data:
                                hold_reference: b3e1a7c4-6d29-4f08-9a51-0c7e2d4b8f36
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                first_night: '2026-09-12'
                                last_night: '2026-09-15'
                                until: '2026-08-30T18:56:02Z'
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

    calendar.released:
        post:
            tags: [calendar]
            operationId: onCalendarReleased
            summary: A hold ended — the nights came back, or were sold
            description: |
                Delivered when a hold you were told about with
                [`calendar.held`](/api-reference/webhooks/calendar.held) ends. `hold_reference`
                is the same value, so this is always the other half of a pair you already have.

                **`reason` is the whole message, and the two values are opposite
                instructions:**

                - `expired` — nobody bought them. Put `first_night` through `last_night` back
                  on sale.
                - `converted` — a booking took them. They are gone, and `booking_reference`
                  names it.

                On `converted`, the `booking.requested` or `booking.confirmed` for that booking
                is written in the **same database transaction** as the booking itself, so it
                carries the same `occurred_at` as this delivery and **no order between the two
                is promised**. Join them on `booking_reference`; do not infer anything from
                which arrived first, and do not wait for one before acting on the other.

                A hold that ends before you ever heard it start produces nothing: there is no
                release for a hold with no `calendar.held`.

                Delivered on a **sandbox** workspace. On a production workspace this contract is
                published but not enabled: nothing is delivered.

                Never delivered for a listing that sells a SERVICE or an EXPERIENCE.
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/CalendarReleasedDelivery'
                        example:
                            event_id: 81920314-2536-4e7f-9081-920314253647
                            event_type: calendar.released
                            occurred_at: '2026-08-30T18:42:11Z'
                            data:
                                hold_reference: b3e1a7c4-6d29-4f08-9a51-0c7e2d4b8f36
                                listing_id: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                                unit_id: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804
                                first_night: '2026-09-12'
                                last_night: '2026-09-15'
                                reason: converted
                                booking_reference: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
            responses:
                '200':
                    description: Return any 2xx to acknowledge.

components:
    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.

        partnerApiKey:
            type: http
            scheme: bearer
            description: |
                Your long-lived API key (`maat_partner_…`), sent as
                `Authorization: Bearer <key>`. It authorizes exactly ONE thing: exchanging
                itself for a token at `POST /token`. It reaches no workspace and no data.

                Store this; mint tokens from it. If it leaks, revoke it — revoking
                it kills every outstanding token on its next request.
    parameters:
        ProviderId:
            name: provider_id
            in: path
            required: true
            description: |
                Your own identifier for the property. Unique to your workspace — it never
                collides with another workspace's.
            schema:
                type: string
                minLength: 1
                maxLength: 128
            example: my-prop-9001

        ExternalUid:
            name: external_uid
            in: path
            required: true
            description: |
                Your own identifier for the block, typically your reservation id. Unique to you.
            schema:
                type: string
                minLength: 1
                maxLength: 255
            example: my-res-55123

        BlockWindowFrom:
            name: from
            in: query
            required: false
            description: |
                Only blocks holding a night on or after this date. A block whose `check_out` is
                this date holds no night in the window, so it is not returned.
            schema:
                type: string
                format: date
            example: '2026-09-01'

        BlockWindowTo:
            name: to
            in: query
            required: false
            description: Only blocks holding a night on or before this date.
            schema:
                type: string
                format: date
            example: '2026-09-30'

        Page:
            name: page
            in: query
            required: false
            description: 1-based page number. Defaults to the first page.
            schema:
                type: integer
                minimum: 1
                default: 1
            example: 1

        PageSize:
            name: page_size
            in: query
            required: false
            description: |
                Rows per page. The same default and the same ceiling on every list in this API.

                A `page_size` above the maximum is **refused** with `validation_failed` naming
                the parameter — never silently clamped, because a caller who believes they asked
                for 500 and received 200 will stop walking a set they have not finished.
            schema:
                type: integer
                minimum: 1
                maximum: 200
                default: 25
            example: 25

        FinanceFrom:
            name: from
            in: query
            required: false
            description: >-
                Only earnings whose `earned_on` — the stay's check-in date — is on or after this
                date. Inclusive. Omit for no lower bound.
            schema:
                type: string
                format: date
            example: '2026-09-01'

        FinanceTo:
            name: to
            in: query
            required: false
            description: >-
                Only earnings whose `earned_on` is on or before this date. Inclusive. Omit for no
                upper bound. A `from` after `to`, or a window wider than 731 days, is refused with
                `400 validation_failed` rather than truncated.
            schema:
                type: string
                format: date
            example: '2026-09-30'

        FinanceFromRequired:
            name: from
            in: query
            required: true
            description: >-
                First day of the summary window, inclusive, over `earned_on` — the stay's check-in
                date. Required: a summary totals everything inside its window in one pass, so an
                unbounded one would sweep your whole history on every call.
            schema:
                type: string
                format: date
            example: '2026-09-01'

        FinanceToRequired:
            name: to
            in: query
            required: true
            description: >-
                Last day of the summary window, inclusive. A `from` after `to`, or a window wider
                than 731 days, is refused with `400 validation_failed` rather than truncated.
            schema:
                type: string
                format: date
            example: '2026-09-30'

        FinanceGranularity:
            name: granularity
            in: query
            required: true
            description: |
                How to bucket the window.

                Required rather than defaulted: the three bucket the same rows very differently,
                and a caller who did not say which they wanted should be told to say so.

                See [`EarningsGranularity`](/api-reference/enums/earnings-granularity).
            schema:
                $ref: '#/components/schemas/EarningsGranularity'
            example: month

        FinanceListingId:
            name: listing_id
            in: query
            required: false
            description: >-
                Only earnings on this listing. Filters inside your own workspace, so a listing
                that is not yours returns an empty result rather than another workspace's revenue.
            schema:
                type: string
                format: uuid
            example: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24

        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

        PhotoId:
            name: photo_id
            in: path
            required: true
            description: |
                Maat's identifier for one photo, as returned by
                `GET /properties/{property_id}/photos`.
            schema:
                type: string
                format: uuid
            example: 0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94

        UnitId:
            name: unit_id
            in: path
            required: true
            description: Maat's identifier for the physical unit.
            schema:
                type: string
                format: uuid
            example: 8e4b2a17-5c39-4d61-b0f2-7a9c3e51d804

        ListingId:
            name: listing_id
            in: path
            required: true
            description: Maat's identifier for the listing.
            schema:
                type: string
                format: uuid
            example: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24

        TemplateKey:
            name: template_key
            in: path
            required: true
            description: |
                The quick action's identifier. A closed set. Permanent: a withdrawn template
                keeps its identifier and is refused with `validation_failed`, never removed.

                See [`QuickActionTemplateKey`](/api-reference/enums/quick-action-template-key).
            schema:
                $ref: '#/components/schemas/QuickActionTemplateKey'
            example: pricing.cleaning_fee

        CalendarFrom:
            name: from
            in: query
            required: false
            description: |
                First night of the window, **inclusive**. Send it together with `to`, or send
                neither: one alone is refused rather than half-defaulted.
            schema:
                type: string
                format: date
            example: '2026-09-01'

        CalendarTo:
            name: to
            in: query
            required: false
            description: |
                Last night of the window, **inclusive** — unlike a block's `check_out`, which is
                the exclusive departure day. At most 366 nights from `from`.
            schema:
                type: string
                format: date
            example: '2026-09-30'

        DeliveryId:
            name: delivery_id
            in: path
            required: true
            description: Our id for the delivery, as returned by `GET /webhooks/deliveries`.
            schema:
                type: string
                format: uuid
            example: 6f1a2b3c-4d5e-4f60-8718-9a0b1c2d3e4f

        DeliveryStatus:
            name: status
            in: query
            required: false
            description: |
                Only deliveries in this status.

                See [`WebhookDeliveryStatus`](/api-reference/enums/webhook-delivery-status).
            schema:
                $ref: '#/components/schemas/WebhookDeliveryStatus'
            example: failed

        DeliveryEventType:
            name: event_type
            in: query
            required: false
            description: Only deliveries of this event type, e.g. `booking.confirmed`.
            schema:
                type: string
                minLength: 1
                maxLength: 48
            example: booking.confirmed

        BookingReference:
            name: booking_reference
            in: path
            required: true
            description: Maat's own id for the booking.
            schema:
                type: string
                format: uuid
            example: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d

        BookingStatus:
            name: status
            in: query
            required: false
            description: |
                Only bookings in this status.

                See [`BookingStatus`](/api-reference/enums/booking-status).
            schema:
                $ref: '#/components/schemas/BookingStatus'
            example: confirmed

        BookingListingId:
            name: listing_id
            in: query
            required: false
            description: Only bookings on this listing.
            schema:
                type: string
                format: uuid
            example: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24

        BookingCheckInFrom:
            name: check_in_from
            in: query
            required: false
            description: >-
                Only bookings whose `check_in` is on or after this date. Inclusive. Sending a
                `check_in_from` that is after `check_in_to` is refused with
                `400 validation_failed` rather than answered with an empty page.
            schema:
                type: string
                format: date
            example: '2026-09-01'

        BookingCheckInTo:
            name: check_in_to
            in: query
            required: false
            description: >-
                Only bookings whose `check_in` is on or before this date. Inclusive.
            schema:
                type: string
                format: date
            example: '2026-09-30'

        ReviewId:
            name: review_id
            in: path
            required: true
            description: Maat's id for the review.
            schema:
                type: string
                format: uuid
            example: 2b1c3d4e-5f60-4718-9a2b-3c4d5e6f7081

    schemas:
        EarningStatus:
            type: string
            title: EarningStatus
            description: |
                How far one payment's money has got, from the guest paying to it reaching your
                bank.

                It moves forward only. A payment is `pending` until the stay starts, then
                `available`; requesting a payout moves it to `reserved`, and the transfer moves it
                to `paid_out`.

                See [EarningStatus](/api-reference/enums/earning-status).
            enum:
                - pending
                - available
                - reserved
                - paid_out
            x-enum-descriptions:
                pending: >-
                    Held in escrow. The guest has paid; the stay has not started yet.
                available: >-
                    Released and withdrawable.
                reserved: >-
                    Released, but committed to a payout request that is still open — neither
                    withdrawable nor paid.
                paid_out: >-
                    Transferred out to you.
            x-maat-internal-enum:
                name: DeveloperEarningStatus
                relation: equals

        PayoutStatus:
            type: string
            title: PayoutStatus
            description: |
                Where a payout request is.

                See [PayoutStatus](/api-reference/enums/payout-status).
            enum:
                - requested
                - paid
                - rejected
                - cancelled
            x-enum-descriptions:
                requested: >-
                    Open. The funds are reserved and nobody has actioned it yet.
                paid: >-
                    Transferred.
                rejected: >-
                    Declined; the reserved funds went back to `available`.
                cancelled: >-
                    Withdrawn by the workspace before it was actioned.
            x-maat-internal-enum:
                name: DeveloperPayoutStatus
                relation: equals

        PenaltyStatus:
            type: string
            title: PenaltyStatus
            description: |
                How much of a penalty is still owed.

                `written_off` is a separate answer from `settled` on purpose: nothing was paid,
                somebody decided it would not be.

                See [PenaltyStatus](/api-reference/enums/penalty-status).
            enum:
                - outstanding
                - partially_settled
                - settled
                - written_off
            x-enum-descriptions:
                outstanding: >-
                    Nothing has been settled against it.
                partially_settled: >-
                    Some of it has been settled; `outstanding` is what remains.
                settled: >-
                    Fully settled.
                written_off: >-
                    Forgiven. Nothing is owed and nothing was paid.
            x-maat-internal-enum:
                name: DeveloperPenaltyStatus
                relation: equals

        PenaltyReason:
            type: string
            title: PenaltyReason
            description: |
                Why a penalty exists.

                One member, because the platform charges one kind. The field is published rather
                than left off so that "why do I owe this?" has an answer you can branch on, and so
                a second kind — if there is ever one — arrives as a value rather than as a new
                field.

                See [PenaltyReason](/api-reference/enums/penalty-reason).
            enum:
                - host_cancellation
            x-enum-descriptions:
                host_cancellation: >-
                    The workspace cancelled a confirmed stay.
            x-maat-internal-enum:
                name: DeveloperPenaltyReason
                relation: equals

        EarningsGranularity:
            type: string
            title: EarningsGranularity
            description: |
                How an earnings summary is bucketed. `period` is always the bucket's FIRST day, so
                one date parser reads all three.

                See [EarningsGranularity](/api-reference/enums/earnings-granularity).
            enum:
                - day
                - week
                - month
            x-enum-descriptions:
                day: >-
                    One bucket per calendar day.
                week: >-
                    One bucket per ISO week; `period` is the Monday.
                month: >-
                    One bucket per calendar month; `period` is the first of the month.
            x-maat-internal-enum:
                name: DeveloperEarningsGranularity
                relation: equals

        FinancePartition:
            type: object
            title: FinancePartition
            description: |
                One settlement currency's whole position.

                Every `Money` here carries this partition's own `currency`. Two partitions are two
                positions and are never added together.
            required:
                - currency
                - pending
                - available
                - reserved_for_payout
                - paid_out_total
                - outstanding_penalties
            additionalProperties: false
            properties:
                currency:
                    type: string
                    pattern: '^[A-Z]{3}$'
                    description: ISO 4217, uppercase. Every amount below is in this currency.
                    example: EGP
                pending:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        Held in escrow — paid by the guest, released when the stay starts.
                available:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: Released and withdrawable.
                reserved_for_payout:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        Released, but committed to an open payout request. Published so that
                        `available + reserved_for_payout + paid_out_total` reconciles.
                paid_out_total:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: Cumulative, already transferred out.
                outstanding_penalties:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        What you still owe in this currency. A liability — never subtracted from
                        the figures above.

        FinanceBalance:
            type: object
            title: FinanceBalance
            required: [partitions]
            additionalProperties: false
            properties:
                partitions:
                    type: array
                    description: >-
                        One partition per settlement currency you have rows in, sorted by
                        currency. A currency you have never traded in is absent, not zero.
                    items:
                        $ref: '#/components/schemas/FinancePartition'

        Earning:
            type: object
            title: Earning
            description: |
                One PAYMENT's income for you — not one booking. A booking that is modified takes
                its own additional charge and has its own row.
            required:
                - booking_reference
                - listing_id
                - earned_on
                - gross
                - refunded
                - net
                - platform_fee
                - refunded_platform_fee
                - status
            additionalProperties: false
            properties:
                booking_reference:
                    type: [string, 'null']
                    format: uuid
                    description: >-
                        The stay this payment was for, or `null` in the seconds before we have
                        matched the payment to a booking. Such a row still counts in the amounts.
                    example: 5f3a91c2-e8b4-4a71-9c30-1e2f3a4b5c6d
                listing_id:
                    type: [string, 'null']
                    format: uuid
                    description: >-
                        The listing, or `null` for the same reason — it is stamped with the
                        booking.
                    example: 7c4b1e58-2a90-4f61-b3d2-5e8a1c0f7d24
                earned_on:
                    type: string
                    format: date
                    description: >-
                        The stay's CHECK-IN date, not the date the money moved. June revenue means
                        the stays that happened in June.
                    example: '2026-09-12'
                gross:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        What the platform booked as YOUR income for this payment — the payout plus
                        any cancellation-insurance premium. Already net of the platform's
                        commission and service fee, so it is not the guest's total.
                refunded:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: What was reversed against this payment, as a positive amount.
                net:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: '`gross` minus `refunded`.'
                platform_fee:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        Maat's commission on this payment — the cut taken out of your rate, which
                        is why `gross` is smaller than what the stay was priced at. Before any
                        reversal of it, so it pairs with `gross` rather than with `net`. It is the
                        commission only: the guest's service fee is charged on top of your rate
                        rather than deducted from it, and card processing is ours to carry.
                refunded_platform_fee:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        How much of `platform_fee` came back when this payment was reversed, as a
                        positive amount. What Maat kept is `platform_fee` minus this.
                status:
                    allOf:
                        - $ref: '#/components/schemas/EarningStatus'
                    description: >-
                        How far this payment's money has got. See
                        [EarningStatus](/api-reference/enums/earning-status).

        EarningList:
            type: object
            title: EarningList
            required: [items, has_more]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: One page of earning lines, newest stay first.
                    items:
                        $ref: '#/components/schemas/Earning'
                has_more:
                    type: boolean
                    description: |
                        Whether another page exists. There is no total: counting the whole set to
                        answer one page is work neither of us needs.

                        **Pages are numbered and the list is newest first, so a payment that
                        settles while you are walking shifts the rows behind it.** Key what you
                        collect by `booking_reference` and `earned_on`, or narrow with `from` and
                        `to` so the set you are paging cannot move.

        EarningsBucket:
            type: object
            title: EarningsBucket
            description: |
                One period AND one currency. A month in which you settled in two currencies is two
                buckets, never one sum.
            required:
                - period
                - currency
                - gross
                - refunded
                - net
                - platform_fee
                - refunded_platform_fee
                - bookings
            additionalProperties: false
            properties:
                period:
                    type: string
                    format: date
                    description: >-
                        The bucket's FIRST day, at every granularity — a month is `2026-09-01`
                        rather than `2026-09`, and a week is its Monday.
                    example: '2026-09-01'
                currency:
                    type: string
                    pattern: '^[A-Z]{3}$'
                    description: ISO 4217, uppercase. Every amount below is in this currency.
                    example: EGP
                gross:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: Your income in this bucket, before reversals.
                refunded:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: Reversals in this bucket, as a positive amount.
                net:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: '`gross` minus `refunded`.'
                platform_fee:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        Maat's commission across this bucket, before any reversal of it.
                refunded_platform_fee:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        Commission returned across this bucket, as a positive amount.
                bookings:
                    type: integer
                    minimum: 0
                    description: >-
                        Distinct bookings that contributed. A payment not yet matched to a booking
                        counts in the amounts and not here.
                    example: 2

        EarningsSummary:
            type: object
            title: EarningsSummary
            required: [buckets]
            additionalProperties: false
            properties:
                buckets:
                    type: array
                    description: >-
                        One bucket per period and currency, oldest first. There is no total across
                        currencies anywhere in this response.
                    items:
                        $ref: '#/components/schemas/EarningsBucket'

        Payout:
            type: object
            title: Payout
            description: |
                One payout request your workspace made.

                There is no destination on this object — no withdrawal method, no bank or wallet,
                no IBAN, no tracking number, no requester. Those stay in the Maat app, behind a
                person signing in.
            required: [payout_id, status, amount, requested_at, resolved_at]
            additionalProperties: false
            properties:
                payout_id:
                    type: string
                    format: uuid
                    description: Maat's id for the request.
                    example: 8f5c2d10-3b47-4e29-9a6d-1c0e7b4f8a52
                status:
                    allOf:
                        - $ref: '#/components/schemas/PayoutStatus'
                    description: >-
                        Where the request is. See
                        [PayoutStatus](/api-reference/enums/payout-status).
                amount:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        What was requested. A payout is made in exactly one currency.
                requested_at:
                    type: string
                    format: date-time
                    description: When the workspace asked to be paid.
                    example: '2026-09-01T10:00:00.000Z'
                resolved_at:
                    type: [string, 'null']
                    format: date-time
                    description: >-
                        When it stopped being open — paid, rejected or cancelled. `null` while the
                        request is still `requested`.
                    example: '2026-09-03T09:30:00.000Z'

        PayoutList:
            type: object
            title: PayoutList
            required: [items, has_more]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: One page of payout requests, newest first.
                    items:
                        $ref: '#/components/schemas/Payout'
                has_more:
                    type: boolean
                    description: >-
                        Whether another page exists. There is no total.

        UpcomingPayout:
            type: object
            title: UpcomingPayout
            description: |
                Escrowed money due to be released on one date, in one currency.
            required: [available_on, amount, bookings]
            additionalProperties: false
            properties:
                available_on:
                    type: string
                    format: date
                    description: >-
                        The day it is released — a check-in date, so always in the future.
                    example: '2026-09-12'
                amount:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        The total releasing that day in this currency. Two currencies releasing on
                        the same day are two rows.
                bookings:
                    type: integer
                    minimum: 1
                    description: Confirmed stays releasing on that date, in that currency.
                    example: 1

        UpcomingPayoutList:
            type: object
            title: UpcomingPayoutList
            required: [items]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: >-
                        One row per release date and currency, soonest first. Not paged: the set
                        is bounded by the platform, not by your history.
                    items:
                        $ref: '#/components/schemas/UpcomingPayout'

        Penalty:
            type: object
            title: Penalty
            description: |
                One penalty charged to your workspace. A liability, never netted against an
                earning.
            required:
                - penalty_id
                - booking_reference
                - reason
                - amount
                - outstanding
                - status
                - issued_at
            additionalProperties: false
            properties:
                penalty_id:
                    type: string
                    format: uuid
                    description: Maat's id for the penalty.
                    example: 1d7f4b26-9c58-4a31-8e0b-5f2c6a9d3e71
                booking_reference:
                    type: string
                    format: uuid
                    description: The stay it is about.
                    example: 77b0d4e1-9a44-4b5e-8f1a-2c3d4e5f6081
                reason:
                    allOf:
                        - $ref: '#/components/schemas/PenaltyReason'
                    description: >-
                        Why it exists. See
                        [PenaltyReason](/api-reference/enums/penalty-reason).
                amount:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: What was charged.
                outstanding:
                    allOf:
                        - $ref: '#/components/schemas/Money'
                    description: >-
                        What is still owed. Zero once it is settled or written off.
                status:
                    allOf:
                        - $ref: '#/components/schemas/PenaltyStatus'
                    description: >-
                        How much of it is settled. See
                        [PenaltyStatus](/api-reference/enums/penalty-status).
                issued_at:
                    type: string
                    format: date-time
                    description: When it was charged.
                    example: '2026-09-02T09:15:00.000Z'

        PenaltyList:
            type: object
            title: PenaltyList
            required: [items, has_more]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: One page of penalties, newest first.
                    items:
                        $ref: '#/components/schemas/Penalty'
                has_more:
                    type: boolean
                    description: >-
                        Whether another page exists. There is no total.

        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

        Address:
            type: object
            title: Address
            description: |
                Where the property is, as your system pushes it. Delivery detail — building,
                floor, apartment number, directions — is not part of the push: write it with
                `PUT /properties/{property_id}/address` and read it back on
                `PropertyReadAddress`.
            required: [line1, city, country, latitude, longitude]
            additionalProperties: false
            properties:
                line1:
                    type: string
                    minLength: 1
                    maxLength: 200
                    description: Street address, as you hold it. One line; there is no `line2`.
                city:
                    type: string
                    minLength: 1
                    maxLength: 120
                    description: City or town.
                region:
                    type: string
                    maxLength: 120
                    description: State, province or governorate.
                postal_code:
                    type: string
                    maxLength: 20
                    description: Postal or ZIP code, where the country has one.
                country:
                    type: string
                    pattern: '^[A-Z]{2}$'
                    description: ISO 3166-1 alpha-2, uppercase.
                latitude:
                    type: number
                    minimum: -90
                    maximum: 90
                    description: >-
                        Decimal degrees, WGS 84. Maat places the pin from this and never geocodes
                        your text.
                longitude:
                    type: number
                    minimum: -180
                    maximum: 180
                    description: Decimal degrees, WGS 84.

        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
                    description: >-
                        The most people the property sleeps. Maat refuses a booking above it.
                bedrooms:
                    type: integer
                    minimum: 0
                    maximum: 30
                    description: Rooms intended for sleeping. Zero for a studio.
                beds:
                    type: integer
                    minimum: 0
                    maximum: 60
                    description: Beds in total, across every room.
                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.

        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://'
                    description: >-
                        A publicly fetchable HTTPS URL we can read without a credential. We fetch
                        it; you never write to a Maat bucket.
                position:
                    type: integer
                    minimum: 0
                    maximum: 29
                    description: 0-based display order. Position 0 is the cover photo.

        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: The property's title, as guests will see it.
                description:
                    type: string
                    maxLength: 4000
                    description: >-
                        Free text about the property. Never about a guest — see the data boundary
                        guide.
                property_type:
                    type: string
                    pattern: '^[a-z0-9][a-z0-9_-]*$'
                    maxLength: 60
                    description: >-
                        A slug from `GET /catalog/property-types`. An unknown slug is a `400`, never
                        a silent default.
                    example: apartment
                address:
                    $ref: '#/components/schemas/Address'
                occupancy:
                    $ref: '#/components/schemas/Occupancy'
                base_rate:
                    $ref: '#/components/schemas/Money'
                amenities:
                    type: array
                    maxItems: 60
                    description: >-
                        Amenity slugs from `GET /catalog/amenities`. Replaced whole on every push: a
                        slug you omit is removed.
                    items:
                        type: string
                        pattern: '^[a-z0-9][a-z0-9_-]*$'
                        maxLength: 60
                photos:
                    type: array
                    maxItems: 30
                    description: >-
                        Photos by URL, in the order you want them shown. Replaced whole on every
                        push.
                    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$'
                    description: >-
                        Local check-out time, 24-hour `HH:MM`. Omitted means the platform default,
                        `12:00`.
                    example: '12:00'
                status:
                    $ref: '#/components/schemas/ListingStatus'
                    description: |
                        Whether you want the property sellable on Maat right now.

                        See [`ListingStatus`](/api-reference/enums/listing-status).

        PropertyUpsertResponse:
            type: object
            title: PropertyUpsertResponse
            required:
                [
                    provider_id,
                    result,
                    property_id,
                    unit_id,
                    listing_id,
                    status,
                    publishable,
                    missing,
                    unknown_amenities,
                    photos_rejected,
                ]
            additionalProperties: false
            properties:
                provider_id:
                    type: string
                    description: >-
                        The identifier you addressed, echoed back so a queued write can be matched
                        to its response.
                result:
                    $ref: '#/components/schemas/WriteResult'
                    description: >-
                        Whether this push created the property or replaced one you had already
                        pushed.

                        See [`WriteResult`](/api-reference/enums/write-result).
                property_id:
                    type: string
                    format: uuid
                    description: |
                        Maat's own identifier for the property. **Always present**, unlike
                        `listing_id`: the row exists from the moment this push commits, whether
                        or not the property is publishable.

                        It is the handle every sub-resource write takes —
                        `PUT /properties/{property_id}/address` and `…/amenities` — so a caller
                        who created a property by their own reference never has to go and look
                        ours up before editing it.
                unit_id:
                    type: string
                    format: uuid
                    description: |
                        Maat's identifier for the physical unit this push created or replaced.
                        **Always present**, for the same reason.

                        A property push writes exactly one unit today, and this is the handle
                        `PATCH /units/{unit_id}` and `PUT /units/{unit_id}/house-rules` take.
                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.

                        Deliberately NOT unconditional like the two ids above, even though the
                        row exists just as early: a listing addresses something a **guest** can
                        reach, and handing it back before the property is publishable would read
                        as "we are live". The other two address our own inventory and carry no
                        such claim.
                status:
                    $ref: '#/components/schemas/ListingStatus'
                    description: >-
                        Whether the listing is on sale right now. A push that is missing something
                        publication needs comes back `unlisted`.

                        See [`ListingStatus`](/api-reference/enums/listing-status).
                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.

                        See [`MissingReason`](/api-reference/enums/missing-reason).
                    items:
                        $ref: '#/components/schemas/MissingReason'
                    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'

        PhotoRejection:
            type: object
            title: PhotoRejection
            required: [url, reason]
            additionalProperties: false
            properties:
                url:
                    type: string
                    description: The URL exactly as you sent it.
                reason:
                    $ref: '#/components/schemas/PhotoRejectionReason'
                    description: See [`PhotoRejectionReason`](/api-reference/enums/photo-rejection-reason).

        PhotoRejectionReason:
            type: string
            title: PhotoRejectionReason
            enum:
                [
                    url_refused,
                    fetch_failed,
                    unsupported_type,
                    too_large,
                    processing_failed,
                    upload_missing,
                ]
            description: |
                Why a photo did not become a published image. ONE closed vocabulary for both
                ways a photo arrives — the URL push and the presigned upload — so you write one
                switch rather than two lists of near-synonyms.

                * `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.
                  *URL push only.*
                * `fetch_failed` — your server did not answer, timed out, redirected, or
                  returned a non-2xx status. Retryable: push again. *URL push only.*
                * `unsupported_type` — not `image/jpeg`, `image/png` or `image/webp`.
                * `too_large` — over the size ceiling.
                * `processing_failed` — we had the bytes and could not publish the image. This
                  also covers a photo our automated review declined.
                * `upload_missing` — you asked for an upload form and the bytes never arrived.
                  Nothing was wrong with the image, because there was no image: presign again
                  and complete the upload. *Presigned uploads only.*
            x-enum-descriptions:
                url_refused: >-
                    The address you gave could not be used at all.
                fetch_failed: >-
                    The bytes could not be read from the address you gave.
                unsupported_type: >-
                    The file is not one of the accepted image types.
                too_large: >-
                    The file is bigger than the published ceiling.
                processing_failed: >-
                    The bytes arrived but could not be turned into a published image.
                upload_missing: >-
                    The presigned slot expired with nothing uploaded into it.
            x-maat-internal-enum:
                name: PartnerPhotoRejectionReason
                relation: equals

        PropertyPhoto:
            type: object
            title: PropertyPhoto
            description: |
                One photo on a property, and what it is doing.

                `url` is the address of the PUBLISHED image and nothing else. It is `null` for
                every state but `ready` — including for a photo our automated review declined,
                whose derivative exists and which no guest will ever be shown.
            required: [photo_id, position, is_cover, state, rejection_reason, url]
            additionalProperties: false
            properties:
                photo_id:
                    type: string
                    format: uuid
                    description: >-
                        Maat's id for this photo. The id you order by, and the id you delete by.
                position:
                    type: integer
                    minimum: 0
                    description: |
                        0-based and dense. `0` is the cover.
                is_cover:
                    type: boolean
                    description: |
                        True for AT MOST one photo, and when it is true it is the photo at
                        position zero.

                        It can be false for every photo. A property whose cover was deleted
                        elsewhere, or whose cover our automated review declined, has no cover
                        until one is chosen again — reorder the strip to name one, and the
                        photo you put first becomes it.

                        True on a `pending` photo names the cover a property is GOING to have,
                        not one it has: `url` is null, and no guest surface shows a cover for
                        that property until the photo settles.
                state:
                    $ref: '#/components/schemas/PhotoState'
                    description: |
                        * `pending` — reserved. The bytes have not arrived, or have not been
                          processed yet. Poll until it moves.
                        * `ready` — processed, reviewed and published. The only state with a
                          `url`.
                        * `rejected` — terminal. `rejection_reason` says why.

                        See [`PhotoState`](/api-reference/enums/photo-state).
                rejection_reason:
                    oneOf:
                        - $ref: '#/components/schemas/PhotoRejectionReason'
                        - type: 'null'
                    description: |
                        Set only on `rejected`.

                        See [`PhotoRejectionReason`](/api-reference/enums/photo-rejection-reason).
                url:
                    oneOf:
                        - type: string
                        - type: 'null'
                    description: |
                        The published image. `null` unless `state` is `ready` — which is the
                        common case, so type your field as nullable.

                        This document is OpenAPI 3.1, where a nullable member is written
                        `type: [string, 'null']` — a JSON Schema type union, and the form used
                        throughout this file. A `'null'` branch of a `oneOf` says the same thing
                        and is used where the other branch is a `$ref`. Neither is the 3.0
                        `nullable: true` keyword, which 3.1 removed: it is not an error here, it
                        is silently ignored, which is worse. Its sibling `rejection_reason` uses
                        the same form.
            example:
                photo_id: 0f2b9c81-4a7e-4d33-9b10-2c7e5a6f1d94
                position: 0
                is_cover: true
                state: ready
                rejection_reason: null
                url: https://cdn.maatstays.wtf/c/0f2b9c81.webp

        PropertyPhotoListResponse:
            type: object
            title: PropertyPhotoListResponse
            required: [items]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: >-
                        Every photo on the property, in display order, whatever state it is in.
                    items:
                        $ref: '#/components/schemas/PropertyPhoto'

        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.

        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'

        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.

        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'

        PhotoOrderRequest:
            type: object
            title: PhotoOrderRequest
            description: |
                The COMPLETE set of `ready` photos, in the order they should be shown. Index 0
                becomes the cover.
            required: [photo_ids]
            additionalProperties: false
            properties:
                photo_ids:
                    type: array
                    minItems: 1
                    maxItems: 30
                    description: >-
                        Every photo on the property, in the order you want them shown. The first is
                        the cover.
                    items:
                        type: string
                        format: uuid

        BlockUpsertRequest:
            type: object
            title: BlockUpsertRequest
            description: |
                A range of nights that are unavailable. Send exactly one of `provider_id` or
                `listing_id`.

                No guest data of any kind is accepted here — an unrecognised field is an error,
                not something we quietly ignore.
            required: [check_in, check_out]
            additionalProperties: false
            properties:
                provider_id:
                    type: string
                    minLength: 1
                    maxLength: 128
                    description: Your own property identifier.
                listing_id:
                    type: string
                    format: uuid
                    description: Maat's listing identifier, for a property you did not push.
                check_in:
                    type: string
                    format: date
                    description: First night held.
                    example: '2026-09-01'
                check_out:
                    type: string
                    format: date
                    description: |
                        Departure day, **exclusive**. The night before this is the last one held.
                    example: '2026-09-05'
                reason:
                    $ref: '#/components/schemas/BlockReason'
                    description: |
                        A closed set, deliberately. There is no free-text alternative, because the
                        obvious thing to write in one is a guest's name.

                        See [`BlockReason`](/api-reference/enums/block-reason).
            oneOf:
                - required: [provider_id]
                - required: [listing_id]

        BlockUpsertResponse:
            type: object
            title: BlockUpsertResponse
            required: [external_uid, result, nights, listing_id]
            additionalProperties: false
            properties:
                external_uid:
                    type: string
                    description: Your own identifier for the block, echoed back.
                result:
                    $ref: '#/components/schemas/WriteResult'
                    description: >-
                        Whether this push created the block or replaced one you had already pushed.

                        See [`WriteResult`](/api-reference/enums/write-result).
                nights:
                    type: integer
                    minimum: 1
                    maximum: 730
                    description: Nights actually held, restated so you can check it.
                listing_id:
                    type: string
                    format: uuid
                    description: |
                        The Maat listing the block landed on. Useful when you addressed the
                        property by your own identifier and want ours in return.

        Block:
            type: object
            title: Block
            description: |
                One of your blocks, as we hold it.

                What is not here is deliberate: our internal identifier for the block, our
                internal identifier for the physical property, and your own workspace and account
                identifiers. You address a block by the id you gave it, and a property by the
                listing id — there is no second handle for either.
            required:
                [external_uid, listing_id, check_in, check_out, nights, created_at, updated_at]
            additionalProperties: false
            properties:
                external_uid:
                    type: string
                    description: Your own identifier for this block.
                listing_id:
                    type: string
                    format: uuid
                    description: The Maat listing whose nights this block holds.
                check_in:
                    type: string
                    format: date
                    description: First night held, inclusive.
                check_out:
                    type: string
                    format: date
                    description: Departure day, **exclusive**.
                nights:
                    type: integer
                    minimum: 1
                    description: Nights held, so you do not have to do date arithmetic.
                reason:
                    $ref: '#/components/schemas/BlockReason'
                    description: |
                        Absent when you did not send one. We never fill it in for you.

                        See [`BlockReason`](/api-reference/enums/block-reason).
                created_at:
                    type: string
                    format: date-time
                    description: When Maat first accepted this block.
                updated_at:
                    type: string
                    format: date-time
                    description: Equal to `created_at` if the block has never been replaced.

        BlockListResponse:
            type: object
            title: BlockListResponse
            required: [items, has_more]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: >-
                        Your blocks, newest first. Only blocks you pushed with
                        `PUT /blocks/{external_uid}`.
                    items:
                        $ref: '#/components/schemas/Block'
                has_more:
                    type: boolean
                    description: |
                        Whether another page exists. There is no total: counting the whole set to
                        answer one page is work neither of us needs.

                        **Pages are numbered, and the list is newest first, so a row that lands
                        while you are walking shifts the ones behind it.** A long walk can
                        therefore see a row twice or miss one. Key what you collect by its own
                        id and treat the walk as eventually complete rather than exact, or
                        narrow with the filters this operation offers so the set you are paging
                        cannot move.

        WebhookEventType:
            type: string
            title: WebhookEventType
            description: |
                What you can subscribe to. Every event is available to every key; you receive
                the ones your endpoint registered for, about your own workspace's inventory.
                Registering for a value not on this list answers `400 validation_failed` and
                names the event.
            enum:
                - booking.confirmed
                - booking.cancelled
                - booking.requested
                - booking.rejected
                - booking.checked_in
                - booking.checked_out
                - booking.modified
                - review.created
                - listing.published
                - listing.unlisted
                - pricing.mode_changed
                - pricing.currency_changed
                - calendar.held
                - calendar.released
            x-enum-descriptions:
                booking.confirmed: >-
                    A guest booked and paid for one of your listings.
                booking.cancelled: >-
                    A confirmed booking was cancelled; the nights are free again.
                booking.requested: >-
                    A guest paid for a stay the host still has to approve.
                booking.rejected: >-
                    The host declined a pending request.
                booking.checked_in: >-
                    The guest confirmed their own arrival.
                booking.checked_out: >-
                    The guest confirmed their own departure.
                booking.modified: >-
                    A modification was applied and the stay window moved.
                review.created: >-
                    A guest published a review of a completed stay.
                listing.published: >-
                    A listing went on sale.
                listing.unlisted: >-
                    A listing came off sale.
                pricing.mode_changed: >-
                    Who prices a listing changed — including a release of yours.
                pricing.currency_changed: >-
                    A partner-priced listing changed currency and its future rates were purged.
                calendar.held: >-
                    A guest reached checkout and the nights are off sale while they pay.
                calendar.released: >-
                    A hold ended — the nights are free again, or a booking took them.
            x-maat-internal-enum:
                name: PartnerWebhookEventType
                relation: equals

        WebhookEndpointRequest:
            type: object
            title: WebhookEndpointRequest
            required: [url, events]
            additionalProperties: false
            properties:
                url:
                    type: string
                    format: uri
                    maxLength: 2048
                    pattern: '^https://'
                    description: |
                        HTTPS only, a public DNS name only. Checked against the address it
                        resolves to at the moment we connect, on every delivery.
                events:
                    type: array
                    minItems: 1
                    maxItems: 16
                    uniqueItems: true
                    items:
                        $ref: '#/components/schemas/WebhookEventType'
                    description: |
                        At least one. An empty array is refused: every registration re-arms the
                        challenge, so an empty one would leave the endpoint
                        `pending_verification` and subscribed to nothing.
                        `DELETE /v1/webhooks/endpoint` is the operation that means stop.

                        Naming an event your credential cannot receive is
                        `400 validation_failed`, and the `detail` names them — see
                        `WebhookEventType`.

                        See [`WebhookEventType`](/api-reference/enums/webhook-event-type).

        WebhookEndpointResponse:
            type: object
            title: WebhookEndpointResponse
            required: [url, events, status]
            additionalProperties: false
            properties:
                url:
                    type: string
                    format: uri
                    description: The HTTPS URL deliveries are posted to.
                events:
                    type: array
                    description: |
                        The event types this endpoint is subscribed to.

                        See [`WebhookEventType`](/api-reference/enums/webhook-event-type).
                    items:
                        $ref: '#/components/schemas/WebhookEventType'
                status:
                    $ref: '#/components/schemas/WebhookEndpointStatus'
                    description: >-
                        Whether the endpoint is delivering. A new or re-registered endpoint is
                        `pending_verification` until it echoes the challenge.

                        See [`WebhookEndpointStatus`](/api-reference/enums/webhook-endpoint-status).
                signing_secret:
                    type: string
                    description: |
                        Returned once, on the call that issues it, and never again. Store it
                        somewhere you can read it back; we cannot.

        PartnerPropertyRef:
            type: object
            title: PartnerPropertyRef
            description: |
                Which of your inventory an event is about, as one object rather than two ids
                sitting at the root of five different payloads. Carried by every booking
                event and both publication events.
            required: [provider_id, listing_id]
            additionalProperties: false
            properties:
                provider_id:
                    type: [string, 'null']
                    description: |
                        Your identifier, when the property came from you. `null` for a property
                        the host created on Maat and you are merely synced to.
                listing_id:
                    type: string
                    format: uuid
                    description: The Maat listing this event is about.

        DeveloperBookingProperty:
            type: object
            title: DeveloperBookingProperty
            description: |
                Which of your inventory a booking is on. A superset of `PartnerPropertyRef`: a
                read has all four ids, an event carries the two a recipient can act on, and the
                two members they share mean the same thing in both.
            required: [provider_id, listing_id, unit_id, property_id]
            additionalProperties: false
            properties:
                provider_id:
                    type: [string, 'null']
                    description: Your own id for the property, when you pushed it. `null` otherwise.
                listing_id:
                    type: string
                    format: uuid
                    description: The listing that was booked.
                unit_id:
                    type: [string, 'null']
                    format: uuid
                    description: The physical unit. `null` for a service or an experience, which has none.
                property_id:
                    type: [string, 'null']
                    format: uuid
                    description: |
                        `null` for a service or an experience, which hangs off no property.

        EndpointChallengePayload:
            type: object
            title: EndpointChallengePayload
            required: [challenge]
            additionalProperties: false
            properties:
                challenge:
                    type: string
                    description: |
                        An opaque, single-use, unguessable string. Echo it back verbatim in a
                        `2xx` JSON response to activate the endpoint.

        EndpointChallengeDelivery:
            type: object
            title: EndpointChallengeDelivery
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: >-
                        This delivery's own id. Stable across our retries and across a redrive — use
                        it as your idempotency key.
                event_type:
                    type: string
                    const: endpoint.challenge
                    description: >-
                        Always `endpoint.challenge`. Sent inside your own registration request, in
                        both tenancies.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When Maat issued the challenge, which is while your registration request is
                        still open.
                data:
                    $ref: '#/components/schemas/EndpointChallengePayload'

        # ─── The event catalogue's payloads ──────────────────────────────────
        #
        # No payload below carries a guest's identity, on any event.

        DeveloperBookingEventStay:
            type: object
            title: DeveloperBookingEventStay
            description: |
                The stay. `check_out` is the departure day, not the last night, and the times are
                the property's own door times.
            required: [check_in, check_out, check_in_time, check_out_time, guests]
            additionalProperties: false
            properties:
                check_in:
                    type: string
                    format: date
                    description: Arrival date.
                check_out:
                    type: string
                    format: date
                    description: Departure date, exclusive.
                check_in_time:
                    type: string
                    pattern: '^([01]\d|2[0-3]):[0-5]\d$'
                    description: Local check-in time on the arrival date, 24-hour `HH:MM`.
                check_out_time:
                    type: string
                    pattern: '^([01]\d|2[0-3]):[0-5]\d$'
                    description: Local check-out time on the departure date, 24-hour `HH:MM`.
                guests:
                    type: [integer, 'null']
                    minimum: 1
                    description: |
                        The party size, or `null` when **this event does not state one**. Three
                        of the seven carry it — `booking.requested`, `booking.confirmed` and
                        `booking.cancelled`. It is `null` on the other four:
                        `booking.rejected`, `booking.checked_in`, `booking.checked_out` and
                        `booking.modified`.

                        `null` means we are not telling you, never "nobody is coming". Do not
                        substitute the unit's capacity: it is what the property CAN sleep, not
                        who is arriving. `GET /v1/bookings/{booking_reference}` always answers it.

        DeveloperBookingPreviousStay:
            type: object
            title: DeveloperBookingPreviousStay
            description: |
                The window a modification replaced. The door times are deliberately absent — a
                modification moves dates, never a unit's check-in time.
            required: [check_in, check_out]
            additionalProperties: false
            properties:
                check_in:
                    type: string
                    format: date
                    description: The arrival date before the modification.
                check_out:
                    type: string
                    format: date
                    description: The departure date before the modification, exclusive.

        DeveloperBookingEvent:
            type: object
            title: DeveloperBookingEvent
            description: |
                One moment in a booking's life. All seven `booking.*` events share this one shape; `event_type` on the envelope says
                which transition produced it and `status` says where the booking ended up.

                **There is no guest object and there never will be one.** No name, no initial, no
                email, no user id, no reference of any kind. `console_url` is the honest pointer:
                follow it, sign in as a human, and see what a human may see.

                There is also no `amount_paid`. The amount and its currency can only be paired
                correctly inside Maat's booking service, and none of these events carries the
                pair — `GET /v1/bookings/{booking_reference}` answers it.
            required:
                [
                    booking_reference,
                    property,
                    status,
                    stay,
                    previous_stay,
                    cancellation,
                    console_url,
                ]
            additionalProperties: false
            properties:
                booking_reference:
                    type: string
                    format: uuid
                    description: >-
                        The correlation key every event about this stay carries. A UUID, and the
                        same value `GET /bookings/{booking_reference}` takes.
                property:
                    $ref: '#/components/schemas/PartnerPropertyRef'
                    description: |
                        Which of your inventory this is about. The same object a publication event
                        carries.
                status:
                    $ref: '#/components/schemas/BookingStatus'
                    description: |
                        Where the booking is AFTER this transition. A checkout reads `completed`,
                        because that is the state Maat actually holds — there is no
                        `checked_out`.

                        See [`BookingStatus`](/api-reference/enums/booking-status).
                stay:
                    $ref: '#/components/schemas/DeveloperBookingEventStay'
                previous_stay:
                    oneOf:
                        - $ref: '#/components/schemas/DeveloperBookingPreviousStay'
                        - type: 'null'
                    description: The window this modification replaced. `null` on every other event.
                cancellation:
                    oneOf:
                        - $ref: '#/components/schemas/DeveloperBookingCancellation'
                        - type: 'null'
                    description: |
                        `null` unless this is `booking.cancelled`. `initiated_by` decides what you
                        do next: `guest` frees the nights, `host` is an operational failure.
                console_url:
                    type: string
                    format: uri
                    description: The Maat host console's page for this booking.

        # ─── The seven booking deliveries ───────────────────────────────────
        #
        # One schema per event, each with a `const` `event_type`, rather than one
        # schema with a seven-member enum, so a generated client narrows on
        # `event_type` alone and no event publishes an `event_type` that can hold
        # six values it never sends.
        #
        # `event_type` still points at `BookingEventType` through an `allOf`, so
        # the published vocabulary stays one declaration and the enum's own page
        # keeps naming every field that takes it.
        #
        # There is deliberately no union schema over the seven: nothing in the
        # document would reference it, and a component nothing references is one
        # the spec lint flags and a reader cannot reach.

        DeveloperBookingRequestedDelivery:
            type: object
            title: DeveloperBookingRequestedDelivery
            description: |
                The `booking.requested` delivery.
                A guest paid for a stay the host still has to approve, so it landed `pending`.
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: Stable across our retries. Use it to be idempotent.
                event_type:
                    allOf:
                        - $ref: '#/components/schemas/BookingEventType'
                    const: booking.requested
                    description: |
                        Always `booking.requested`. Fixed on this schema, so narrowing on it
                        alone hands you this one shape.

                        See [`BookingEventType`](/api-reference/enums/booking-event-type) for the
                        seven transitions that carry this envelope.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/DeveloperBookingEvent'

        DeveloperBookingConfirmedDelivery:
            type: object
            title: DeveloperBookingConfirmedDelivery
            description: |
                The `booking.confirmed` delivery.
                The stay is sold.
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: Stable across our retries. Use it to be idempotent.
                event_type:
                    allOf:
                        - $ref: '#/components/schemas/BookingEventType'
                    const: booking.confirmed
                    description: |
                        Always `booking.confirmed`. Fixed on this schema, so narrowing on it
                        alone hands you this one shape.

                        See [`BookingEventType`](/api-reference/enums/booking-event-type) for the
                        seven transitions that carry this envelope.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/DeveloperBookingEvent'

        DeveloperBookingRejectedDelivery:
            type: object
            title: DeveloperBookingRejectedDelivery
            description: |
                The `booking.rejected` delivery.
                The host declined a pending request. `stay.guests` reads `null`.
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: Stable across our retries. Use it to be idempotent.
                event_type:
                    allOf:
                        - $ref: '#/components/schemas/BookingEventType'
                    const: booking.rejected
                    description: |
                        Always `booking.rejected`. Fixed on this schema, so narrowing on it
                        alone hands you this one shape.

                        See [`BookingEventType`](/api-reference/enums/booking-event-type) for the
                        seven transitions that carry this envelope.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/DeveloperBookingEvent'

        DeveloperBookingCheckedInDelivery:
            type: object
            title: DeveloperBookingCheckedInDelivery
            description: |
                The `booking.checked_in` delivery.
                The guest confirmed their own arrival. `stay.guests` reads `null`.
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: Stable across our retries. Use it to be idempotent.
                event_type:
                    allOf:
                        - $ref: '#/components/schemas/BookingEventType'
                    const: booking.checked_in
                    description: |
                        Always `booking.checked_in`. Fixed on this schema, so narrowing on it
                        alone hands you this one shape.

                        See [`BookingEventType`](/api-reference/enums/booking-event-type) for the
                        seven transitions that carry this envelope.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/DeveloperBookingEvent'

        DeveloperBookingCheckedOutDelivery:
            type: object
            title: DeveloperBookingCheckedOutDelivery
            description: |
                The `booking.checked_out` delivery.
                The guest confirmed their own departure. `status` reads `completed`, because that is the state Maat holds.
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: Stable across our retries. Use it to be idempotent.
                event_type:
                    allOf:
                        - $ref: '#/components/schemas/BookingEventType'
                    const: booking.checked_out
                    description: |
                        Always `booking.checked_out`. Fixed on this schema, so narrowing on it
                        alone hands you this one shape.

                        See [`BookingEventType`](/api-reference/enums/booking-event-type) for the
                        seven transitions that carry this envelope.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/DeveloperBookingEvent'

        DeveloperBookingCancelledDelivery:
            type: object
            title: DeveloperBookingCancelledDelivery
            description: |
                The `booking.cancelled` delivery.
                A confirmed booking ended before the stay; `cancellation` is present.
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: Stable across our retries. Use it to be idempotent.
                event_type:
                    allOf:
                        - $ref: '#/components/schemas/BookingEventType'
                    const: booking.cancelled
                    description: |
                        Always `booking.cancelled`. Fixed on this schema, so narrowing on it
                        alone hands you this one shape.

                        See [`BookingEventType`](/api-reference/enums/booking-event-type) for the
                        seven transitions that carry this envelope.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/DeveloperBookingEvent'

        DeveloperBookingModifiedDelivery:
            type: object
            title: DeveloperBookingModifiedDelivery
            description: |
                The `booking.modified` delivery.
                A modification was applied and the stay window moved; `previous_stay` is present.
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: Stable across our retries. Use it to be idempotent.
                event_type:
                    allOf:
                        - $ref: '#/components/schemas/BookingEventType'
                    const: booking.modified
                    description: |
                        Always `booking.modified`. Fixed on this schema, so narrowing on it
                        alone hands you this one shape.

                        See [`BookingEventType`](/api-reference/enums/booking-event-type) for the
                        seven transitions that carry this envelope.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/DeveloperBookingEvent'

        ReviewCreatedPayload:
            type: object
            title: ReviewCreatedPayload
            description: |
                A guest published a review. **The reviewer is not named here.**

                `GET /v1/listings/{listing_id}/reviews` does carry a reviewer label — the same
                shortened form the public listing page shows — because that read reuses Maat's
                one definition of a publicly readable review. A notification cannot reuse it, so
                it says a review exists and what it scored, and the read says the rest.
            required: [review_id, listing_id, booking_reference, rating, console_url]
            additionalProperties: false
            properties:
                review_id:
                    type: string
                    format: uuid
                    description: >-
                        Maat's id for the review. The id `POST /reviews/{review_id}/reply` takes.
                listing_id:
                    type: string
                    format: uuid
                    description: The listing the review is about.
                booking_reference:
                    type: string
                    format: uuid
                    description: >-
                        The stay it reviews, so it correlates with the booking deliveries. A
                        UUID, and the same value `GET /bookings/{booking_reference}` takes.
                rating:
                    type: number
                    description: The derived overall score, in half-star steps.
                console_url:
                    type: [string, 'null']
                    format: uri
                    description: |
                        The console's page for the owning property — reputation lives there, and
                        there is no per-review page to link to. `null` for a listing that hangs
                        off no property.

        ReviewCreatedDelivery:
            type: object
            title: ReviewCreatedDelivery
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: >-
                        This delivery's own id. Stable across our retries and across a redrive — use
                        it as your idempotency key.
                event_type:
                    type: string
                    const: review.created
                    description: Always `review.created`. Narrow on this before reading `data`.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/ReviewCreatedPayload'

        ListingPublicationPayload:
            type: object
            title: ListingPublicationPayload
            description: |
                A listing went on or came off sale.

                It says **whether**, never **why**. A listing can be held back because a push is
                incomplete, because somebody unlisted it deliberately, or for commercial reasons
                between Maat and the host — and only the first two are yours to read.
            required: [property, status, console_url]
            additionalProperties: false
            properties:
                property:
                    $ref: '#/components/schemas/PartnerPropertyRef'
                    description: |
                        Which of your inventory went on or came off sale. The same object the
                        booking events carry.
                status:
                    $ref: '#/components/schemas/ListingPublicationStatus'
                    description: |
                        Which side of the transition the listing is now on.

                        See [`ListingPublicationStatus`](/api-reference/enums/listing-publication-status).
                console_url:
                    type: [string, 'null']
                    format: uri
                    description: The console's listings page for the owning property.

        ListingPublicationDelivery:
            type: object
            title: ListingPublicationDelivery
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: >-
                        This delivery's own id. Stable across our retries and across a redrive — use
                        it as your idempotency key.
                event_type:
                    $ref: '#/components/schemas/ListingPublicationEventType'
                    description: >-
                        Which side of the transition this is. Narrow on it before reading `data`.

                        See [`ListingPublicationEventType`](/api-reference/enums/listing-publication-event-type).
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/ListingPublicationPayload'

        PricingModePayload:
            type: object
            title: PricingModePayload
            description: |
                Who prices a listing changed.

                There is deliberately no `changed_by`. Maat cannot tell you whether the holder
                released the mode or the host released it in the app — the two are recorded
                identically — and a guessed attribution on this wire would be worse than none.
            required: [listing_id, mode]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: Maat's id for the listing.
                mode:
                    $ref: '#/components/schemas/PricingModeValue'
                    description: |
                        The same three values `GET /v1/listings/{listing_id}/pricing-mode`
                        answers with — this event tells you what that read would now say,
                        without your having to poll it.

                        `partner` — an API credential prices every night of it.
                        `rules` — Maat's own engine has it back; that is not "no pricing",
                        the listing keeps its base price and whichever rules are still linked.
                        `external` — another source prices it, and **which** is deliberately
                        never named, exactly as on the read.

                        See [`PricingModeValue`](/api-reference/enums/pricing-mode-value).

        PricingModeDelivery:
            type: object
            title: PricingModeDelivery
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: >-
                        This delivery's own id. Stable across our retries and across a redrive — use
                        it as your idempotency key.
                event_type:
                    type: string
                    const: pricing.mode_changed
                    description: >-
                        Always `pricing.mode_changed`. Narrow on this before reading `data`.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/PricingModePayload'

        PricingCurrencyPayload:
            type: object
            title: PricingCurrencyPayload
            description: |
                A listing you price changed currency, and its **future rates were purged**. A rate
                row carries no currency beyond the one stamped on it, so an old-currency number
                wearing a right-looking date is a wrong amount rather than a record.

                Nothing re-pushes your calendar for you. The listing is unpriceable until you
                push again in the new currency.
            required: [listing_id, currency]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: Maat's id for the listing.
                currency:
                    type: string
                    minLength: 3
                    maxLength: 3
                    description: |
                        ISO 4217, uppercase — the NEW currency, the one a re-push must be
                        denominated in. The old one is not included: it is unusable now, and
                        naming it would invite a conversion nobody should perform.

        PricingCurrencyDelivery:
            type: object
            title: PricingCurrencyDelivery
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: >-
                        This delivery's own id. Stable across our retries and across a redrive — use
                        it as your idempotency key.
                event_type:
                    type: string
                    const: pricing.currency_changed
                    description: >-
                        Always `pricing.currency_changed`. Narrow on this before reading `data`.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the thing happened, stamped in the same transaction as the fact
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it, so it is safe to sort on; two events written
                        in one transaction share an instant, which makes it a partial order
                        rather than a sequence number. See
                        https://developer.maatstays.wtf/guides/webhook-delivery#what-occurred_at-actually-is.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/PricingCurrencyPayload'

        CalendarHold:
            type: object
            title: CalendarHold
            description: |
                Nights that are off sale because a guest is part-way through paying for them.

                A hold is taken the moment somebody reaches checkout and released when they pay,
                give up, or run out of time. It is the same object in both directions: this is
                the `data` of a `calendar.held` delivery AND one row of
                `GET /v1/calendar/holds`, so a recipient that missed a delivery and one that
                reads the list are told the same thing about the same hold.

                It says nothing about the guest — no name, no identifier, nothing to join on —
                and nothing about money. Somebody may be about to buy these nights, or may
                walk away in a minute; what an integration needs is that they cannot be sold
                twice.
            required: [hold_reference, listing_id, unit_id, first_night, last_night, until]
            additionalProperties: false
            properties:
                hold_reference:
                    type: string
                    format: uuid
                    description: |
                        Maat's opaque reference for this hold, and the key that joins the pair:
                        the `calendar.released` that ends it carries the same value.

                        It addresses nothing on its own — there is no
                        `GET /v1/calendar/holds/{hold_reference}` — and it is not a credential.
                listing_id:
                    type: string
                    format: uuid
                    description: The listing the guest is buying.
                unit_id:
                    type: string
                    format: uuid
                    description: |
                        The physical unit the nights are held on. Availability is unit-keyed, so
                        two listings of one unit share a hold: this is what tells you the second
                        listing is unsellable too.
                first_night:
                    type: string
                    format: date
                    description: The first night held — the guest's arrival day.
                last_night:
                    type: string
                    format: date
                    description: |
                        The last night held, **inclusive**. Both bounds are nights, so a
                        one-night hold has `last_night` equal to `first_night`.

                        This is the `first_night` / `last_night` pair the rate calendar uses,
                        deliberately **not** the `check_in` / `check_out` pair a booking uses —
                        whose second member is the exclusive departure day.
                until:
                    type: string
                    format: date-time
                    description: |
                        When this hold lapses if nothing else happens to it.

                        A moment, not a duration: how long a hold lasts is ours to change, so do
                        not derive one from this and do not hard-code one. Treat it as a deadline
                        with a hint attached — the authority that the nights are free again is a
                        `calendar.released` delivery, never this clock passing.

                        If a checkout is given longer, a **second** `calendar.held` arrives with
                        the same `hold_reference` and a later `until`. Take the later one.

        CalendarHoldList:
            type: object
            title: CalendarHoldList
            required: [items, has_more]
            additionalProperties: false
            properties:
                items:
                    type: array
                    items:
                        $ref: '#/components/schemas/CalendarHold'
                has_more:
                    type: boolean
                    description: >-
                        Whether another page exists. There is no total — we do not count the whole
                        set to answer a page.

        HoldReleaseReason:
            type: string
            title: HoldReleaseReason
            description: |
                Why a hold ended, and therefore what to do about it. The two are opposite
                instructions, which is why `calendar.released` is one event with a reason rather
                than two events nobody would remember to handle both of.
            enum:
                - expired
                - converted
            x-enum-descriptions:
                expired: >-
                    Nobody bought the nights. They are sellable again.
                converted: >-
                    A booking took the nights. They are gone, and `booking_reference` names it.
            x-maat-internal-enum:
                name: PartnerHoldReleaseReason
                relation: equals

        CalendarReleasedPayload:
            type: object
            title: CalendarReleasedPayload
            description: |
                A hold ended. The nights it was holding are either sellable again or sold, and
                `reason` is which.
            required:
                [
                    hold_reference,
                    listing_id,
                    unit_id,
                    first_night,
                    last_night,
                    reason,
                    booking_reference,
                ]
            additionalProperties: false
            properties:
                hold_reference:
                    type: string
                    format: uuid
                    description: >-
                        The same value the `calendar.held` for these nights carried.
                listing_id:
                    type: string
                    format: uuid
                    description: The listing the hold was taken on.
                unit_id:
                    type: string
                    format: uuid
                    description: The physical unit the nights were held on.
                first_night:
                    type: string
                    format: date
                    description: The first night the hold was holding.
                last_night:
                    type: string
                    format: date
                    description: The last night the hold was holding, inclusive.
                reason:
                    $ref: '#/components/schemas/HoldReleaseReason'
                    description: |
                        Whether the nights came back or were sold.

                        See [`HoldReleaseReason`](/api-reference/enums/hold-release-reason).
                booking_reference:
                    type: [string, 'null']
                    format: uuid
                    description: |
                        The booking that took the nights. Present when `reason` is `converted`,
                        `null` when it is `expired`.

                        It is what joins this release to the `booking.requested` or
                        `booking.confirmed` that follows. Those are written in the **same
                        database transaction** as the booking, so all of them carry the same
                        `occurred_at` and no order between them is promised — join on this
                        reference, not on which arrived first.

        CalendarHeldDelivery:
            type: object
            title: CalendarHeldDelivery
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: >-
                        This delivery's own id. Stable across our retries and across a redrive — use
                        it as your idempotency key.
                event_type:
                    type: string
                    const: calendar.held
                    description: Always `calendar.held`. Narrow on this before reading `data`.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the nights went off sale, stamped in the same transaction as the hold
                        itself — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/CalendarHold'

        CalendarReleasedDelivery:
            type: object
            title: CalendarReleasedDelivery
            required: [event_id, event_type, occurred_at, data]
            additionalProperties: false
            properties:
                event_id:
                    type: string
                    format: uuid
                    description: >-
                        This delivery's own id. Stable across our retries and across a redrive — use
                        it as your idempotency key.
                event_type:
                    type: string
                    const: calendar.released
                    description: Always `calendar.released`. Narrow on this before reading `data`.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        When the hold ended, stamped in the same transaction as the fact that
                        ended it — not when we prepared this delivery. A backlog, a retry or a
                        redrive does not move it.
                test:
                    type: boolean
                    const: true
                    description: |
                        Present, and `true`, only on a delivery you asked for with
                        `POST /webhooks/endpoint/test`. Absent on every real event — never
                        `false`. Branch on its presence if your handler must not act on
                        the fixture.
                data:
                    $ref: '#/components/schemas/CalendarReleasedPayload'

        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'

        TokenResponse:
            type: object
            title: TokenResponse
            required: [access_token, token_type, expires_in, workspace_id, sandbox, scope]
            additionalProperties: false
            properties:
                access_token:
                    type: string
                    description: |
                        Send it as `Authorization: Bearer <access_token>`. Never store it — store
                        the key and mint tokens from it.
                token_type:
                    type: string
                    const: Bearer
                    description: >-
                        Always `Bearer`. Send the token as `Authorization: Bearer <access_token>`.
                expires_in:
                    type: integer
                    description: Seconds. Fifteen minutes by default, never more than an hour.
                    example: 900
                workspace_id:
                    type: string
                    format: uuid
                    description: The one workspace this token addresses. Fixed by the key.
                sandbox:
                    type: boolean
                    description: |
                        Whether the workspace is a sandbox. `false` means every contract answers
                        `501` until launch.
                scope:
                    type: array
                    description: |
                        The scopes the key held at exchange time, for your own reading. NOT what
                        authorizes a request: scopes are re-read from the key on every call, so a
                        key narrowed after this exchange is narrowed on its next request.
                    items:
                        type: string
                    example: [partner:token.issue, catalog:read, calendar:read]

        PropertyReadAddress:
            type: object
            title: PropertyReadAddress
            description: |
                Where the property is.

                The last four members are **private delivery detail** a host typed for the guest
                who has to find the door. Keep them out of anything your system publishes.
            required: [line1, city, region, postal_code, country, latitude, longitude]
            additionalProperties: false
            properties:
                line1:
                    type: [string, 'null']
                    description: Street address. `null` where the property has none recorded.
                city:
                    type: [string, 'null']
                    description: City or town. `null` where none is recorded.
                region:
                    type: [string, 'null']
                    description: State, province or governorate.
                postal_code:
                    type: [string, 'null']
                    description: Postal or ZIP code. `null` where none is recorded.
                country:
                    type: [string, 'null']
                    description: ISO 3166-1 alpha-2, uppercase.
                latitude:
                    type: number
                    description: Decimal degrees, WGS 84.
                longitude:
                    type: number
                    description: Decimal degrees, WGS 84.
                building:
                    type: [string, 'null']
                    description: Private delivery detail — which building. `null` where none is recorded.
                floor:
                    type: [string, 'null']
                    description: |
                        Private delivery detail. A string, not a number — real answers include "G"
                        and "Mezzanine".
                apartment:
                    type: [string, 'null']
                    description: Private delivery detail — which door. `null` where none is recorded.
                directions:
                    type: [string, 'null']
                    description: Private delivery detail — how to find the door, as the host wrote it.

        ListingRating:
            type: object
            title: ListingRating
            description: |
                The review score, as one object. `average` is `null` until somebody has scored
                the listing — a zero would be a terrible score, and "nobody has stayed yet" is
                not one.
            required: [average, count]
            additionalProperties: false
            properties:
                average:
                    type: [number, 'null']
                    description: >-
                        The mean guest score out of five, or `null` when nobody has reviewed the
                        listing yet.
                    example: 4.7
                count:
                    type: integer
                    description: How many reviews the average is over. Zero when there are none.

        MissingReason:
            type: string
            title: MissingReason
            description: Why a listing is not publishable. A closed vocabulary.
            enum: [price, amenities, cover_photo, photos, photos_processing, unlisted]
            x-enum-descriptions:
                price: >-
                    No nightly price has been set.
                amenities: >-
                    No amenity has been selected.
                cover_photo: >-
                    No photo is marked as the cover.
                photos: >-
                    Fewer photos than publication needs.
                photos_processing: >-
                    Photos are still being processed — this one clears itself.
                unlisted: >-
                    Nothing is missing; somebody took it off sale deliberately.
            x-maat-internal-enum:
                name: PartnerPropertyMissingReason
                relation: equals

        Listing:
            type: object
            title: Listing
            description: |
                The sellable view of one unit.

                `console_url` is a deep link into the Maat host console. It is `null` for a
                listing with no property behind it.
            required:
                [
                    listing_id,
                    inventory_kind,
                    title,
                    description,
                    status,
                    base_rate,
                    cleaning_fee,
                    instant_booking,
                    is_exclusive,
                    is_best_price,
                    rating,
                    publishable,
                    missing,
                ]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: >-
                        Maat's id for the listing. The id every listing-scoped operation takes.
                inventory_kind:
                    $ref: '#/components/schemas/InventoryKind'
                    description: |
                        What the listing sells. `unit` is a place somebody stays in; the other
                        two are sold by the seat and are read-only through this contract.

                        See [`InventoryKind`](/api-reference/enums/inventory-kind).
                title:
                    type: string
                    description: The listing's title, as guests see it.
                description:
                    type: string
                    description: >-
                        The listing's description, as guests see it. Empty string when the host has
                        written none.
                status:
                    $ref: '#/components/schemas/ListingStatus'
                    description: |
                        Whether it is on sale right now. `unlisted` covers every reason — asked
                        for, held back by the validity gate, or suspended. `missing` says which.

                        See [`ListingStatus`](/api-reference/enums/listing-status).
                base_rate:
                    $ref: '#/components/schemas/Money'
                cleaning_fee:
                    oneOf:
                        - $ref: '#/components/schemas/Money'
                        - type: 'null'
                    description: |
                        `null` when the listing charges none. Never `"0.00"`, which would read as
                        a price of zero.
                instant_booking:
                    type: boolean
                    description: Whether a guest can book without the host confirming first.
                is_exclusive:
                    type: boolean
                    description: >-
                        Whether the listing is part of Maat's curated collection. Set by Maat, not
                        by this API.
                is_best_price:
                    type: boolean
                    description: A marketing badge. Not an amount, despite the name.
                rating:
                    $ref: '#/components/schemas/ListingRating'
                publishable:
                    type: [boolean, 'null']
                    description: |
                        Whether the listing satisfies the platform's validity gate — a price
                        above zero, at least one amenity, a cover photo and a minimum photo
                        count.

                        `null` means **nothing was assessed**, and is not the same as `false`:
                        the gate describes a place somebody stays in, so a `service` or
                        `experience` listing has no answer to it.
                missing:
                    type: array
                    description: |
                        Reasons `publishable` is false. Empty otherwise.

                        See [`MissingReason`](/api-reference/enums/missing-reason).
                    items:
                        $ref: '#/components/schemas/MissingReason'
                console_url:
                    type: [string, 'null']
                    format: uri
                    description: The listing's page in the Maat host console.

        HouseRuleAnswer:
            type: object
            title: HouseRuleAnswer
            description: |
                One rule and the host's answer to it. A rule that is **absent** was never asked —
                absence does not mean `false`.
            required: [slug, allowed]
            additionalProperties: false
            properties:
                slug:
                    type: string
                    description: A house-rule slug from `GET /catalog/house-rules`.
                allowed:
                    type: boolean
                    description: >-
                        The answer to that rule. Absence of the slug means the question was never
                        asked, which is not the same as `false`.

        Unit:
            type: object
            title: Unit
            description: |
                One physical unit, and the listings sold on it.
            required:
                [
                    unit_type,
                    max_guests,
                    bedrooms,
                    beds,
                    bathrooms,
                    check_in_time,
                    check_out_time,
                    amenities,
                    house_rules,
                    listings,
                ]
            additionalProperties: false
            properties:
                unit_id:
                    type: string
                    format: uuid
                    description: The unit's id — the one `GET /units/{unit_id}` takes.
                unit_type:
                    type: string
                    description: What kind of physical inventory this is, as a slug.
                max_guests:
                    type: integer
                    description: The most people the unit sleeps.
                bedrooms:
                    type: integer
                    description: Rooms intended for sleeping.
                beds:
                    type: integer
                    description: Beds in total, across every room.
                bathrooms:
                    type: integer
                    description: Whole bathrooms. Half-bathrooms are not expressible.
                check_in_time:
                    type: string
                    pattern: '^([01]\d|2[0-3]):[0-5]\d$'
                    description: Local `HH:MM`, 24-hour.
                    example: '15:00'
                check_out_time:
                    type: string
                    pattern: '^([01]\d|2[0-3]):[0-5]\d$'
                    description: >-
                        Local check-out time, 24-hour `HH:MM`. The platform default is `12:00`.
                    example: '12:00'
                amenities:
                    type: array
                    description: Amenity slugs. See `GET /catalog/amenities`.
                    items: { type: string }
                house_rules:
                    type: array
                    description: >-
                        Every house rule that has been answered for this unit. A rule that was never
                        asked is absent.
                    items:
                        $ref: '#/components/schemas/HouseRuleAnswer'
                listings:
                    type: array
                    description: The sellable views of this unit. One today; the model allows more.
                    items:
                        $ref: '#/components/schemas/Listing'

        Property:
            type: object
            title: Property
            description: |
                One property, with its units and their listings.

                `console_url` is a deep link into the Maat host console.
            required:
                [
                    property_id,
                    provider_id,
                    property_type,
                    address,
                    timezone,
                    amenities,
                    units,
                    publishable,
                    missing,
                    created_at,
                    updated_at,
                ]
            additionalProperties: false
            properties:
                property_id:
                    type: string
                    format: uuid
                    description: >-
                        Maat's id for the property. The id the targeted edits take — never your
                        `provider_id`.
                provider_id:
                    type: [string, 'null']
                    description: |
                        **Your** identifier for the property, when you pushed it. `null` when you
                        did not.
                property_type:
                    type: string
                    description: The property-type slug, from `GET /catalog/property-types`.
                address:
                    $ref: '#/components/schemas/PropertyReadAddress'
                timezone:
                    type: [string, 'null']
                    description: IANA zone.
                    example: Africa/Cairo
                amenities:
                    type: array
                    description: |
                        Building-level amenity slugs: every slug offered by at least one of this
                        property's units, de-duplicated. For a property that is one unit — which
                        is every property the write operations accept — it is exactly that unit's
                        set, so `PUT /v1/properties/{property_id}/amenities` and this field agree.
                        The per-unit sets are still on each unit. A hotel's own facilities
                        (property-scoped amenities) are not on this contract yet.
                    items: { type: string }
                units:
                    type: array
                    description: >-
                        The physical inventory under the property. One today for anything this API
                        can create.
                    items:
                        $ref: '#/components/schemas/Unit'
                publishable:
                    type: boolean
                    description: True when every listing under this property satisfies the gate.
                missing:
                    type: array
                    description: |
                        The de-duplicated union of its listings' reasons.

                        See [`MissingReason`](/api-reference/enums/missing-reason).
                    items:
                        $ref: '#/components/schemas/MissingReason'
                created_at:
                    type: string
                    format: date-time
                    description: When the property was created in Maat.
                updated_at:
                    type: string
                    format: date-time
                    description: >-
                        When the property was last changed, by anyone — this API, the host app, or
                        Maat.
                console_url:
                    type: string
                    format: uri
                    description: The property's page in the Maat host console.

        PropertyListResponse:
            type: object
            title: PropertyListResponse
            required: [items, has_more]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: One page of properties, newest first.
                    items:
                        $ref: '#/components/schemas/Property'
                has_more:
                    type: boolean
                    description: |
                        Whether another page exists. There is no total: counting the whole set to
                        answer one page is work neither of us needs.

                        **Pages are numbered, and the list is newest first, so a row that lands
                        while you are walking shifts the ones behind it.** A long walk can
                        therefore see a row twice or miss one. Key what you collect by its own
                        id and treat the walk as eventually complete rather than exact, or
                        narrow with the filters this operation offers so the set you are paging
                        cannot move.

        CalendarNight:
            type: object
            title: CalendarNight
            required: [night, status, blocked_by, external_uid]
            additionalProperties: false
            properties:
                night:
                    type: string
                    format: date
                    description: >-
                        The night this row is about. A night is named by the date you sleep on it.
                status:
                    $ref: '#/components/schemas/CalendarNightStatus'
                    description: |
                        `held` is a checkout in flight and may expire; `booked` will not. Both
                        are sales rather than blocks.

                        See [`CalendarNightStatus`](/api-reference/enums/calendar-night-status).
                blocked_by:
                    oneOf:
                        - $ref: '#/components/schemas/BlockedBy'
                        - type: 'null'
                    description: |
                        Who put a block on this night. `null` on every status but `blocked` — a
                        sale is not a block.

                        See [`BlockedBy`](/api-reference/enums/blocked-by).
                external_uid:
                    type: [string, 'null']
                    description: |
                        Your own identifier for the block, on blocks **you** pushed. `null`
                        everywhere else.

        ListingCalendar:
            type: object
            title: ListingCalendar
            description: |
                `unit_id` is the physical inventory availability is keyed on.
            required: [listing_id, items]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: The listing these nights belong to.
                unit_id:
                    type: string
                    format: uuid
                    description: The unit these nights are kept on.
                items:
                    type: array
                    description: >-
                        One row per night in the window you asked for, in date order. Not paged —
                        the window is the bound, so there is no `has_more` here.
                    items:
                        $ref: '#/components/schemas/CalendarNight'

        PropertyType:
            type: object
            title: PropertyType
            required: [slug]
            additionalProperties: false
            properties:
                slug:
                    type: string
                    description: The value to send as `property_type` on a property push.

        PropertyTypeListResponse:
            type: object
            title: PropertyTypeListResponse
            required: [items]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: >-
                        Every property type the platform accepts. The same list in every workspace.
                    items:
                        $ref: '#/components/schemas/PropertyType'

        Amenity:
            type: object
            title: Amenity
            required: [slug, category]
            additionalProperties: false
            properties:
                slug:
                    type: string
                    description: The value to send in `amenities` on a property push.
                category:
                    type: string
                    description: The section it belongs to.

        AmenityListResponse:
            type: object
            title: AmenityListResponse
            required: [items]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: >-
                        Every amenity the platform accepts. The same list in every workspace.
                    items:
                        $ref: '#/components/schemas/Amenity'

        HouseRule:
            type: object
            title: HouseRule
            required: [slug, category, kind, sort_order, retired]
            additionalProperties: false
            properties:
                slug:
                    type: string
                    description: The value to send as a house-rule answer.
                category:
                    type: string
                    description: Which group the rule belongs to on a listing page.
                kind:
                    $ref: '#/components/schemas/HouseRuleKind'
                    description: |
                        A `permission` has two polarities ("Pets allowed" / "No pets"). A
                        `requirement` is shown only when answered and has no negated form.

                        See [`HouseRuleKind`](/api-reference/enums/house-rule-kind).
                sort_order:
                    type: integer
                    description: Groups rules within a category. Bands do not overlap.
                retired:
                    type: boolean
                    description: |
                        A retired rule is still listed, because existing properties answered it.
                        Do not offer one on a new push.

        HouseRuleListResponse:
            type: object
            title: HouseRuleListResponse
            required: [items]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: >-
                        Every house rule the platform accepts. The same list in every workspace.
                    items:
                        $ref: '#/components/schemas/HouseRule'
        AddressWriteRequest:
            type: object
            title: AddressWriteRequest
            description: |
                A property's whole address, replaced.

                Every member is written on every call — as the value you sent, or as `null`. One
                you omit is CLEARED, not kept.

                The last four are private delivery detail — which building, which floor, which
                door — and they are accepted on this contract only. They are not part of the
                property push.
            required: [line1, city, country, latitude, longitude]
            additionalProperties: false
            properties:
                line1:
                    type: string
                    minLength: 1
                    maxLength: 200
                    description: Street line.
                city:
                    type: string
                    minLength: 1
                    maxLength: 120
                    description: City or town.
                region:
                    type: [string, 'null']
                    maxLength: 120
                    description: State, province or governorate. Absent or `null` clears it.
                postal_code:
                    type: [string, 'null']
                    maxLength: 20
                    description: Absent or `null` clears it.
                country:
                    type: string
                    pattern: '^[A-Z]{2}$'
                    description: ISO 3166-1 alpha-2, uppercase.
                latitude:
                    type: number
                    minimum: -90
                    maximum: 90
                    description: |
                        The map position, the platform's area assignment and the property's time
                        zone are all re-derived from this pair — do not send them. A pair that
                        falls outside every curated area leaves the area unset, which is not an
                        error.
                longitude:
                    type: number
                    minimum: -180
                    maximum: 180
                    description: Decimal degrees, WGS 84.
                building:
                    type: [string, 'null']
                    maxLength: 200
                    description: Private delivery detail. Absent or `null` clears it.
                floor:
                    type: [string, 'null']
                    maxLength: 40
                    description: |
                        Private delivery detail, and a STRING rather than a number — real answers
                        include "G", "Mezzanine" and "3A". Absent or `null` clears it.
                apartment:
                    type: [string, 'null']
                    maxLength: 40
                    description: Private delivery detail. Absent or `null` clears it.
                directions:
                    type: [string, 'null']
                    maxLength: 400
                    description: |
                        Private delivery detail — how to find the door once you are at the
                        building. Absent or `null` clears it.

        AmenitySetRequest:
            type: object
            title: AmenitySetRequest
            description: The amenity set, replaced. A slug you omit is removed.
            required: [amenities]
            additionalProperties: false
            properties:
                amenities:
                    type: array
                    minItems: 1
                    maxItems: 60
                    uniqueItems: true
                    description: |
                        Amenity slugs, from `GET /catalog/amenities`. At least one: Maat's
                        validity gate requires one, so a replace to nothing would take a live
                        listing off sale as a side effect of an edit nobody meant that way.
                    items:
                        type: string
                        pattern: '^[a-z0-9][a-z0-9_-]*$'
                        maxLength: 60
                    example: [wifi, air-conditioning, washing-machine]

        AmenitySetResponse:
            type: object
            title: AmenitySetResponse
            description: What landed, and what did not.
            required: [amenities, unknown_amenities]
            additionalProperties: false
            properties:
                amenities:
                    type: array
                    description: |
                        The set now stored — the slugs that RESOLVED, never an echo of what you
                        sent.
                    items: { type: string }
                    example: [wifi, air-conditioning, washing-machine]
                unknown_amenities:
                    type: array
                    description: |
                        Slugs we do not recognise, echoed rather than silently dropped so you can
                        see which entries of your own vocabulary did not land. They are not
                        stored. If NONE of the slugs you sent resolved, the call is a `400`
                        instead and nothing is written — applying that result would clear the
                        set.
                    items: { type: string }
                    example: [wi-fi]

        UnitPatchRequest:
            type: object
            title: UnitPatchRequest
            description: |
                A partial edit of one physical unit. An absent member is untouched, and nothing
                here is nullable — every one of these is a required value on the platform.

                Send at least one member. An empty body is a `400`, not a silent no-op that reads
                as success.
            minProperties: 1
            additionalProperties: false
            properties:
                max_guests:
                    type: integer
                    minimum: 1
                    maximum: 50
                    description: The most people the unit sleeps.
                bedrooms:
                    type: integer
                    minimum: 0
                    maximum: 30
                    description: Rooms intended for sleeping.
                beds:
                    type: integer
                    minimum: 0
                    maximum: 60
                    description: Beds in total, across every room.
                bathrooms:
                    type: integer
                    minimum: 0
                    maximum: 30
                    description: |
                        Whole bathrooms. A fractional value is refused rather than truncated into
                        a different property than you described; half-bathrooms are not
                        expressible here.
                unit_type:
                    $ref: '#/components/schemas/UnitType'
                    description: |
                        What the unit is: the whole property, or one room inside it. Setting
                        `whole_property` on a property that has another unit is a
                        `409 conflict`.

                        See [`UnitType`](/api-reference/enums/unit-type).
                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$'
                    description: >-
                        Local check-out time, 24-hour `HH:MM`. The platform default is `12:00`.
                    example: '12:00'

        HouseRulesWriteRequest:
            type: object
            title: HouseRulesWriteRequest
            description: |
                The house-rule answer set, replaced — and the door times, which ride along.

                Send the rules you decided. Every active rule in Maat's catalogue you did not
                name is written `allowed: false`, which is what keeps "a rule with no answer"
                meaning **never asked** on the read side.
            required: [answers]
            additionalProperties: false
            properties:
                answers:
                    type: array
                    maxItems: 100
                    description: |
                        At most a hundred, and no repeated slug — a repeat is a `400`. An EMPTY
                        array is legitimate and means "nothing is allowed"; it is not the same as
                        never having answered.

                        A slug the catalogue does not have, INCLUDING a retired one, refuses the
                        whole call with `400` naming the slugs.
                    items:
                        $ref: '#/components/schemas/HouseRuleAnswer'
                check_in_time:
                    type: string
                    pattern: '^([01]\d|2[0-3]):[0-5]\d$'
                    description: |
                        Local time, 24-hour. NOT a rule — a value on the unit, written in the
                        same transaction as the answers. Omit it to leave the stored time alone.
                    example: '15:00'
                check_out_time:
                    type: string
                    pattern: '^([01]\d|2[0-3]):[0-5]\d$'
                    description: >-
                        Local check-out time, 24-hour `HH:MM`. The platform default is `12:00`.
                    example: '12:00'

        ListingPatchRequest:
            type: object
            title: ListingPatchRequest
            description: |
                A partial edit of one listing. An absent member is untouched.

                Send at least one member. An empty body is a `400`, not a silent no-op that reads
                as success.
            minProperties: 1
            additionalProperties: false
            properties:
                title:
                    type: string
                    minLength: 1
                    maxLength: 120
                    description: The listing's title, as guests see it.
                description:
                    type: [string, 'null']
                    maxLength: 4000
                    description: |
                        The one member that accepts `null`: "this listing has no description" is
                        a real state you must be able to reach.
                status:
                    $ref: '#/components/schemas/ListingStatus'
                    description: |
                        Not a column write. `listed` runs Maat's validity gate and publishes only
                        if it passes — a refusal is still a `200`, with the returned listing's
                        `publishable` and `missing` saying why and nothing about publication
                        changed. `unlisted` holds the listing off sale.

                        See [`ListingStatus`](/api-reference/enums/listing-status).
                instant_booking:
                    type: boolean
                    description: Whether a guest can book without the host confirming first.
                is_exclusive:
                    type: boolean
                    description: A merchandising badge.
                is_best_price:
                    type: boolean
                    description: A merchandising badge. Not an amount, despite the name.

        ListingPricingRequest:
            type: object
            title: ListingPricingRequest
            description: |
                A listing's price, replaced.

                `base_rate.value` must be greater than zero; `cleaning_fee.value` may be zero.

                `cleaning_fee.currency` must EQUAL `base_rate.currency` — a listing carries one
                currency, and accepting two would mean silently picking one of them. Sending a
                currency different from the stored one is allowed and is a RE-PRICE: the number
                is taken at face value in the new currency and nothing is converted.
            required: [base_rate]
            additionalProperties: false
            properties:
                base_rate:
                    $ref: '#/components/schemas/Money'
                cleaning_fee:
                    oneOf:
                        - $ref: '#/components/schemas/Money'
                        - type: 'null'
                    description: |
                        Absent, or `null`, charges nothing — this is a `PUT`, so an omitted
                        member is a cleared one. A read publishes a zero fee as `null`, so a
                        listing read straight back into this write round-trips.

        QuickActionTemplateKey:
            type: string
            title: QuickActionTemplateKey
            description: |
                The identifier of a quick action. A CLOSED set, and every member of it is
                permanent: a template that is withdrawn keeps its identifier and is reported
                `available: false, unavailable_reason: "retired"` rather than disappearing.

                This list is checked in CI against the platform's seeded templates, so it
                cannot lag what we actually run.
            enum:
                - booking.min_stay
                - booking.max_stay
                - booking.no_checkout_days
                - booking.weekend_min_stay
                - pricing.weekend_surcharge
                - pricing.long_stay_discount
                - pricing.large_group_surcharge
                - pricing.cleaning_fee
            example: pricing.cleaning_fee
            x-enum-descriptions:
                booking.min_stay: >-
                    Fewest nights a stay may be.
                booking.max_stay: >-
                    Most nights a stay may be.
                booking.no_checkout_days: >-
                    Days of the week a guest may not check out on.
                booking.weekend_min_stay: >-
                    A separate minimum for stays that include a weekend.
                pricing.weekend_surcharge: >-
                    A percentage added to weekend nights.
                pricing.long_stay_discount: >-
                    A percentage taken off longer stays.
                pricing.large_group_surcharge: >-
                    A percentage added for larger parties.
                pricing.cleaning_fee: >-
                    A one-off fee added to the stay total.
            x-maat-internal-enum:
                name: RuleQuickActionTemplateKey
                relation: equals

        HostBlockWrite:
            type: object
            title: HostBlockWrite
            description: |
                The nights to close, and why. There is no `note` and no other free-text field —
                an unrecognised property is refused by name.
            required: [dates, category]
            additionalProperties: false
            properties:
                dates:
                    type: array
                    description: |
                        The nights to close, `YYYY-MM-DD`. Need not be contiguous. Each must be a
                        real calendar date, today or later in UTC.
                    minItems: 1
                    maxItems: 366
                    items:
                        type: string
                        format: date
                    example: ['2026-09-14', '2026-09-15']
                category:
                    $ref: '#/components/schemas/HostBlockCategory'
                    description: |
                        Why the nights are closed. A closed set, and required.

                        See [`HostBlockCategory`](/api-reference/enums/host-block-category).
                    example: maintenance

        HostBlockRelease:
            type: object
            title: HostBlockRelease
            description: The nights to reopen. Only host blocks are released.
            required: [dates]
            additionalProperties: false
            properties:
                dates:
                    type: array
                    minItems: 1
                    maxItems: 366
                    description: >-
                        The nights to release. Nights that were not blocked are ignored rather than
                        refused.
                    items:
                        type: string
                        format: date
                    example: ['2026-09-14']

        HostBlockReceipt:
            type: object
            title: HostBlockReceipt
            required: [listing_id, blocked, already_held]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: The listing whose nights were blocked.
                blocked:
                    type: array
                    description: |
                        Every night you named that carries a host block now — including ones that
                        were already blocked before this call. The question is "is it closed?",
                        not "did this call close it?".
                    items:
                        type: string
                        format: date
                already_held:
                    type: array
                    description: |
                        Nights you named that are closed by something else — a calendar feed, or a
                        block you pushed under your own `external_uid` — and so carry no host block
                        of their own.
                        Not a failure: the night is unavailable, which is what you asked for. It
                        is reported because that closure lifts when its own source lifts.

                        A night that is BOOKED or HELD never appears here. It refuses the whole
                        request with `409 conflict` instead.
                    items:
                        type: string
                        format: date

        QuickActionParam:
            type: object
            title: QuickActionParam
            description: |
                The tunable input of a quick action. Absent bounds mean the kind has none — not
                that the value is unbounded.
            required: [kind, default]
            additionalProperties: false
            properties:
                kind:
                    $ref: '#/components/schemas/QuickActionParamKind'
                    description: |
                        How to type and render this action's one value.

                        See [`QuickActionParamKind`](/api-reference/enums/quick-action-param-kind).
                default:
                    description: |
                        The value applied when a `PUT` carries none. A number for `number` and
                        `percent`, a day-name array for `days_of_week`, and a Money object in the
                        listing's currency for `currency`.
                    oneOf:
                        - type: number
                        - type: array
                          items:
                              type: string
                        - $ref: '#/components/schemas/Money'
                min:
                    type: number
                    description: The smallest value the platform accepts, when the kind is bounded.
                max:
                    type: number
                    description: The largest value the platform accepts, when the kind is bounded.
                step:
                    type: number
                    description: The increment the platform accepts between `min` and `max`.

        QuickActionApplied:
            type: object
            title: QuickActionApplied
            description: What `PUT` answers, and what a listing currently has switched on.
            required: [template_key, family, result, value, active]
            additionalProperties: false
            properties:
                template_key:
                    $ref: '#/components/schemas/QuickActionTemplateKey'
                    description: See [`QuickActionTemplateKey`](/api-reference/enums/quick-action-template-key).
                family:
                    $ref: '#/components/schemas/RuleFamily'
                    description: >-
                        Which engine evaluates the rule this action created — booking rules gate a
                        stay, pricing rules move a rate.

                        See [`RuleFamily`](/api-reference/enums/rule-family).
                result:
                    $ref: '#/components/schemas/WriteResult'
                    description: |
                        Whether this call created the quick action or re-pointed one that
                        already existed. The same field `upsertProperty` and
                        `upsertCalendarBlock` publish — one `200` and a field in the body,
                        rather than a status code a caller has to branch on.

                        See [`WriteResult`](/api-reference/enums/write-result).
                value:
                    description: |
                        The stored value, in the shape the action's `param` publishes — which is
                        what we actually stored, not what you sent: omit `value` on the `PUT` and
                        this is the action's own default, resolved by the service that owns it.

                        `null` only for a corrupt row — one whose stored value does not match the
                        kind its descriptor declares. We publish `null` rather than coerce it,
                        because inventing a number for a value that is not one would put it on a
                        guest's receipt. The read publishes `null` in the same case, so the two
                        surfaces cannot disagree. You should never see it; if you do, the rule
                        needs a human.
                    oneOf:
                        - type: number
                        - type: array
                          items:
                              type: string
                        - $ref: '#/components/schemas/Money'
                        - type: 'null'
                active:
                    type: boolean
                    description: |
                        Whether the rule is live. A rule can be attached but paused in the Maat
                        app; nothing on this API pauses one.

        QuickAction:
            type: object
            title: QuickAction
            required:
                [template_key, family, category, param, applied, available, unavailable_reason]
            additionalProperties: false
            properties:
                template_key:
                    $ref: '#/components/schemas/QuickActionTemplateKey'
                    description: See [`QuickActionTemplateKey`](/api-reference/enums/quick-action-template-key).
                family:
                    $ref: '#/components/schemas/RuleFamily'
                    description: |
                        Which engine evaluates the rule this action would create.

                        See [`RuleFamily`](/api-reference/enums/rule-family).
                category:
                    type: [string, 'null']
                    description: |
                        A grouping slug, not copy. `null` on a retired member, whose template is
                        gone.
                    example: other
                param:
                    oneOf:
                        - $ref: '#/components/schemas/QuickActionParam'
                        - type: 'null'
                    description: '`null` for an on/off action whose values are final.'
                applied:
                    description: '`null` when the action is off — which is the common case.'
                    oneOf:
                        - $ref: '#/components/schemas/QuickActionApplication'
                        - type: 'null'
                available:
                    type: boolean
                    description: Whether a `PUT` would be accepted right now.
                unavailable_reason:
                    oneOf:
                        - $ref: '#/components/schemas/QuickActionUnavailableReason'
                        - type: 'null'
                    description: |
                        Why not, when `available` is `false`. `inventory_kind` is published for
                        completeness and is not returned today: a listing that is not a stay is
                        refused outright with `inventory_not_supported`.

                        `insufficient_scope` is about your KEY rather than the listing: it is
                        returned for every `pricing.*` template when your key does not carry
                        `pricing:write`. The catalogue entry is still complete — `param` and its
                        bounds are the same for every listing on Maat — but `applied` comes back
                        `null`, because what the listing charges is pricing, and `rules:write`
                        alone does not grant reading it. Ask whoever issued
                        the key to issue one that carries `pricing:write`.

                        See [`QuickActionUnavailableReason`](/api-reference/enums/quick-action-unavailable-reason).

        QuickActionList:
            type: object
            title: QuickActionList
            required: [items, warnings]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: >-
                        Every quick action the platform publishes, each with whether it is applied
                        to this listing.
                    items:
                        $ref: '#/components/schemas/QuickAction'
                warnings:
                    type: array
                    description: |
                        Families this read could not reach. Their actions are absent from `items`
                        entirely — NOT reported as retired, which would turn a five-minute deploy
                        into a permanent-looking withdrawal.

                        See [`QuickActionWarning`](/api-reference/enums/quick-action-warning).
                    items:
                        $ref: '#/components/schemas/QuickActionWarning'
                    example: []

        RateRange:
            type: object
            title: RateRange
            description: |
                One inclusive night range at one rate.

                `first_night` and `last_night` are BOTH nights and BOTH inclusive. They are not
                called `from`/`to` so that there is no exclusive checkout day to misread.
            required: [first_night, last_night, nightly_rate]
            additionalProperties: false
            properties:
                first_night:
                    type: string
                    format: date
                    description: First night, inclusive. Today or later.
                    example: '2026-12-20'
                last_night:
                    type: string
                    format: date
                    description: |
                        Last night, inclusive. Not before `first_night`, and at most 730 nights
                        from today.
                    example: '2026-12-31'
                nightly_rate:
                    $ref: '#/components/schemas/Money'
                    description: |
                        What one night costs. Its `currency` must equal the listing's own
                        currency — nothing is ever converted.
                min_stay:
                    type: integer
                    minimum: 1
                    maximum: 365
                    description: |
                        Nights. A stay that includes any of these nights must be at least this
                        long. Omit for no restriction; there is no zero.
                    example: 3

        RateUpsert:
            type: object
            title: RateUpsert
            required: [rates]
            additionalProperties: false
            properties:
                rates:
                    type: array
                    minItems: 1
                    maxItems: 730
                    description: |
                        Non-overlapping ranges, every night today or later and none more than 730
                        nights ahead, at most 730 nights in total across the whole request.
                    items:
                        $ref: '#/components/schemas/RateRange'

        PricingModeValue:
            type: string
            title: PricingModeValue
            description: |
                What prices a listing, as far as you are entitled to see it.

                `external` is deliberately undifferentiated: this API never names which source
                prices the listing.
            enum: [rules, partner, external]
            example: partner
            x-enum-descriptions:
                rules: >-
                    Maat's own pricing rules price the listing.
                partner: >-
                    You price it — the rates you push are the ones quoted.
                external: >-
                    Another source prices it. Deliberately undifferentiated: this API never names
                    which one.
            x-maat-internal-enum:
                name: PartnerPricingMode
                relation: equals

        RateUpsertResult:
            type: object
            title: RateUpsertResult
            required:
                [
                    listing_id,
                    nights_written,
                    currency,
                    mode,
                    binding,
                    coverage_until,
                    gap_nights_next_90_days,
                ]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: The listing the rates were written to.
                nights_written:
                    type: integer
                    description: Nights stored or replaced by this request.
                    example: 43
                currency:
                    type: string
                    pattern: '^[A-Z]{3}$'
                    description: The listing's currency, which every rate you sent had to match.
                    example: EGP
                mode:
                    $ref: '#/components/schemas/PricingModeValue'
                    description: See [`PricingModeValue`](/api-reference/enums/pricing-mode-value).
                binding:
                    type: boolean
                    description: |
                        `true` only when `mode` is `partner` and you own it — i.e. these rates
                        are what a guest is quoted. `false` means they are stored and inert,
                        which is the normal state before you take the pricing mode.
                    example: false
                coverage_until:
                    type: [string, 'null']
                    format: date
                    description: |
                        Last night of the CONTIGUOUS run of **your own** rates that starts today,
                        or `null` when you have not priced tonight. Not the furthest night you
                        have priced: a horizon with a hole in it is not covered past the hole.

                        It describes YOUR calendar whether or not you hold the pricing mode — so
                        a fill sent before you take the mode reports a real horizon, and reading
                        it straight back with `GET …/rates` gives the same number. Whether those
                        nights are in force is `binding` and `mode`.
                    example: '2027-01-31'
                gap_nights_next_90_days:
                    type: integer
                    description: Nights with no rate in the next 90. Every one of them is unquotable.
                    example: 0

        RateNight:
            type: object
            title: RateNight
            description: One night you sent, read back.
            required: [night, nightly_rate, min_stay]
            additionalProperties: false
            properties:
                night:
                    type: string
                    format: date
                    description: The night this rate is for.
                    example: '2026-12-20'
                nightly_rate:
                    $ref: '#/components/schemas/Money'
                min_stay:
                    type: [integer, 'null']
                    description: Nights, or `null` for no restriction. Never zero.
                    example: 3

        RateList:
            type: object
            title: RateList
            required: [listing_id, items, currency, mode, binding, coverage_until]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: The listing these rates belong to.
                items:
                    type: array
                    description: >-
                        Only nights YOU sent, in date order. A night you never sent is absent,
                        not zero. Not paged — the window is the bound, so there is no `has_more`
                        here.
                    items:
                        $ref: '#/components/schemas/RateNight'
                currency:
                    type: [string, 'null']
                    pattern: '^[A-Z]{3}$'
                    description: >-
                        The listing's currency, which is the currency of every night below;
                        `null` when there are no nights. It cannot disagree with a night's own
                        `nightly_rate.currency` — a rate in another currency is refused on the
                        way in — and where you are reading a single amount, the one on the
                        amount is the one to use. It is repeated here so a caller that renders a
                        heading over an empty or partial window has one to render.
                    example: EGP
                mode:
                    $ref: '#/components/schemas/PricingModeValue'
                    description: See [`PricingModeValue`](/api-reference/enums/pricing-mode-value).
                binding:
                    type: boolean
                    description: Whether these rates are what a guest is currently quoted.
                    example: true
                coverage_until:
                    type: [string, 'null']
                    format: date
                    description: |
                        Last night of the CONTIGUOUS run of **your own** rates that starts today,
                        or `null` when you have not priced tonight. Not the furthest night you
                        have priced: a horizon with a hole in it is not covered past the hole.

                        It describes YOUR calendar whether or not you hold the pricing mode, and
                        it is the same number `PUT /v1/listings/{listing_id}/rates` returns — so
                        pushing and reading straight back always agree. Whether those nights are
                        in force is `binding` and `mode`, never this field.
                    example: '2027-01-31'

        PricingModeTake:
            type: object
            title: PricingModeTake
            description: |
                `partner` is the only value. Releasing is `DELETE` on the same path, not
                `{"mode":"rules"}` — one verb that both takes and releases is a verb a retry can
                get backwards.
            required: [mode]
            additionalProperties: false
            properties:
                mode:
                    type: string
                    const: partner
                    description: >-
                        Always `partner`. There is one mode to take; releasing is a `DELETE`, not
                        another mode.
                    example: partner
                fallback_to_base_price:
                    type: boolean
                    default: false
                    description: |
                        What a night you have sent no rate for is worth.

                        `false` (the default) refuses to quote it — a gap means your feed is
                        broken, and inventing a price for a peak night would under-charge it
                        silently. `true` prices it at the host's own base price, so a listing
                        stays bookable past the end of your horizon. Your rates always win on
                        the nights you have sent.

                        A `PUT` states the whole mode, so omitting this field sets it to
                        `false` — send it on every take, not only the one that turns it on.
                        Changing it on a listing you already price is allowed and takes effect
                        on the next quote.
                    example: true

        PricingModeTakeResult:
            type: object
            title: PricingModeTakeResult
            required:
                [
                    listing_id,
                    mode,
                    fallback_to_base_price,
                    rules_deactivated,
                    coverage_until,
                    warnings,
                ]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: The listing whose pricing mode you now hold.
                mode:
                    type: string
                    const: partner
                    description: Always `partner` — the mode you now hold.
                fallback_to_base_price:
                    type: boolean
                    description: >-
                        What is in force for this listing now — `true` when an unpriced night
                        falls back to the host's base price, `false` when it is refused.
                    example: true
                rules_deactivated:
                    $ref: '#/components/schemas/RulesDeactivated'
                    description: |
                        The listing's own pricing rules this switched off. Booking rules are
                        switched off too, a moment later, and are deliberately not counted here
                        rather than reported as a number that could be stale by the time you read
                        it.
                coverage_until:
                    type: [string, 'null']
                    format: date
                    description: >-
                        The last night you have sent a rate for, or `null` when you have sent none
                        yet.
                    example: '2027-01-31'
                warnings:
                    type: array
                    description: |
                        `no_rates_next_30_nights` when the coming month is not fully priced. A
                        warning, never a refusal. What those nights are worth depends on
                        `fallback_to_base_price`: unquotable until you send a rate when it is
                        `false`, the host's base price when it is `true`.

                        See [`PricingModeTakeWarning`](/api-reference/enums/pricing-mode-take-warning).
                    items:
                        $ref: '#/components/schemas/PricingModeTakeWarning'
                    example: []

        PricingMode:
            type: object
            title: PricingMode
            description: |
                Who prices this listing.

                Everything past `mode` is present ONLY when you own it. A caller that does not
                gets two fields and no way to tell one external source from another.
            required: [listing_id, mode]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: The listing this mode is about.
                mode:
                    $ref: '#/components/schemas/PricingModeValue'
                    description: See [`PricingModeValue`](/api-reference/enums/pricing-mode-value).
                owned_by_you:
                    type: boolean
                    description: Present and `true` only when you hold the mode.
                    example: true
                coverage_until:
                    type: [string, 'null']
                    format: date
                    description: The last night a rate has been sent for, or `null` when none has.
                    example: '2027-01-31'
                fallback_to_base_price:
                    type: boolean
                    description: >-
                        Present only when you hold the mode. `true` when a night past
                        `coverage_until`, or any night you have not priced, is quoted at the
                        host's base price rather than refused.
                    example: true
                currency:
                    type: [string, 'null']
                    pattern: '^[A-Z]{3}$'
                    description: >-
                        The currency every rate on this listing must be in, or `null` when no mode
                        is held.
                    example: EGP
                last_received_at:
                    type: [string, 'null']
                    format: date-time
                    description: |
                        When YOU last sent a rate for this listing. Watch it: revoking your own
                        credentials does not release the mode, so a horizon can run out night by
                        night with nobody re-pushing.
                    example: '2026-09-01T09:14:22Z'

        QuickActionApply:
            type: object
            title: QuickActionApply
            required: []
            additionalProperties: false
            properties:
                value:
                    description: |
                        Omit to take the action's own default. Typed by the action's `param.kind`;
                        a `currency` kind takes a Money object whose `currency` equals the
                        listing's.
                    oneOf:
                        - type: number
                        - type: array
                          items:
                              type: string
                        - $ref: '#/components/schemas/Money'

        ListingRule:
            type: object
            title: ListingRule
            description: |
                One rule attached to a listing. Read-only, and deliberately without its logic —
                a rule's node graph is an internal shape, not a contract.
            required: [id, family, title, active, source, template_key]
            additionalProperties: false
            properties:
                id:
                    type: string
                    format: uuid
                    description: >-
                        Maat's id for the rule. The one identifier in this document not named
                        `<noun>_id`, because it is the rule's own id on a schema already called
                        `ListingRule`; it is published as-is rather than renamed on the wire.
                family:
                    $ref: '#/components/schemas/RuleFamily'
                    description: |
                        Which engine evaluates the rule.

                        See [`RuleFamily`](/api-reference/enums/rule-family).
                title:
                    type: string
                    description: The property owner's own label for the rule.
                    example: Cleaning fee
                active:
                    type: boolean
                    description: The rule and its link to this listing are both on.
                source:
                    $ref: '#/components/schemas/ListingRuleSource'
                    description: >-
                        Whether the rule came from a quick action on this API or was authored by the
                        workspace in the Maat app.

                        See [`ListingRuleSource`](/api-reference/enums/listing-rule-source).
                template_key:
                    type: [string, 'null']
                    description: |
                        The quick action this rule came from — a `QuickActionTemplateKey` — or
                        `null` when it was drawn by hand in the Maat app. Not declared as the
                        enum here so that a rule created before a key existed still reads back.

        ListingRuleList:
            type: object
            title: ListingRuleList
            required: [items, warnings]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: Every active rule attached to the listing, from either source.
                    items:
                        $ref: '#/components/schemas/ListingRule'
                warnings:
                    type: array
                    description: |
                        Why part of this answer is missing. Two different causes, and they are
                        deliberately not spelled the same:

                        `booking_unavailable` / `pricing_unavailable` — that service could not be
                        reached. Its rules are absent from `items`. Retryable.

                        `pricing_withheld` — your key does not carry `pricing:write`, so the
                        pricing rules are absent. A rule's `title` is written by the host and,
                        with `active`, says what this workspace charges and when; `rules:write`
                        alone does not grant it. **Never retry this** — ask whoever issued the
                        key for one that carries the scope.

                        See [`ListingRuleWarning`](/api-reference/enums/listing-rule-warning).
                    items:
                        $ref: '#/components/schemas/ListingRuleWarning'
                    example: []

        DeveloperWebhookEndpoint:
            type: object
            title: DeveloperWebhookEndpoint
            description: |
                Your own delivery endpoint, read back.

                There is no secret here, and there cannot be: the row stores a GENERATION
                number, and the secret itself is derived from it and a value that lives only in
                our own environment. It is returned once — at registration, or at rotation — and
                nowhere else, ever.
            required:
                [
                    url,
                    events,
                    status,
                    verified_at,
                    challenge_sent_at,
                    last_challenge_error,
                    last_challenge_error_at,
                    secret_generation,
                    previous_secret_expires_at,
                    created_at,
                    updated_at,
                ]
            additionalProperties: false
            properties:
                url:
                    type: string
                    format: uri
                    description: The HTTPS URL deliveries are posted to.
                events:
                    type: array
                    description: |
                        The event types this endpoint is subscribed to.

                        See [`WebhookEventType`](/api-reference/enums/webhook-event-type).
                    items:
                        $ref: '#/components/schemas/WebhookEventType'
                status:
                    $ref: '#/components/schemas/WebhookEndpointStatus'
                    description: >-
                        Whether the endpoint is delivering. A re-registration returns it to
                        `pending_verification` until the new challenge is echoed.

                        See [`WebhookEndpointStatus`](/api-reference/enums/webhook-endpoint-status).
                verified_at:
                    type: [string, 'null']
                    format: date-time
                    description: When the challenge was echoed. `null` while it has not been.
                challenge_sent_at:
                    type: [string, 'null']
                    format: date-time
                    description: When we last POSTed the challenge to this URL.
                last_challenge_error:
                    type: [string, 'null']
                    description: |
                        A code from a closed vocabulary (`challenge_mismatch`, `http_error`, …),
                        never a message and never your own response body.
                last_challenge_error_at:
                    type: [string, 'null']
                    format: date-time
                    description: >-
                        When the last failed challenge was attempted, or `null` if none has failed.
                secret_generation:
                    type: integer
                    description: Which generation of the signing secret deliveries are signed with.
                previous_secret_expires_at:
                    type: [string, 'null']
                    format: date-time
                    description: |
                        Until when the PREVIOUS secret is still accepted alongside the current
                        one. `null` outside a rotation.
                created_at:
                    type: string
                    format: date-time
                    description: When the endpoint was first registered.
                updated_at:
                    type: string
                    format: date-time
                    description: >-
                        When the registration was last changed — a re-registration, a rotation or a
                        status change.

        DeveloperWebhookEndpointResponse:
            title: DeveloperWebhookEndpointResponse
            description: |
                What `GET /webhooks/endpoint` answers: the registration itself, or `null` when
                nothing is registered — not a `404`.

                The endpoint IS the body. A single resource on this API is the bare object, the
                way `GET /properties/by-id/{property_id}`, `GET /units/{unit_id}` and
                `GET /bookings/{booking_reference}` answer.
            oneOf:
                - $ref: '#/components/schemas/DeveloperWebhookEndpoint'
                - type: 'null'

        RotateWebhookSecretResponse:
            type: object
            title: RotateWebhookSecretResponse
            description: What `POST /webhooks/endpoint/rotate-secret` answers.
            required: [endpoint, signing_secret, previous_secret_expires_at]
            additionalProperties: false
            properties:
                endpoint:
                    $ref: '#/components/schemas/DeveloperWebhookEndpoint'
                signing_secret:
                    type: string
                    description: |
                        The new secret. Shown ONCE, here, and nowhere else — there is nowhere to
                        read it back from afterward.
                previous_secret_expires_at:
                    type: string
                    format: date-time
                    description: Until when the previous secret is still accepted.

        SendTestEventRequest:
            type: object
            title: SendTestEventRequest
            description: |
                Which event to send yourself. One field; a test event is never bulk and takes no
                payload of yours — the body is the fixture, so what you verify is what we send.
            required: [event_type]
            additionalProperties: false
            properties:
                event_type:
                    $ref: '#/components/schemas/WebhookEventType'
                    description: |
                        Any event on the list, subscribed or not.

                        See [`WebhookEventType`](/api-reference/enums/webhook-event-type).

        SendTestEventResponse:
            type: object
            title: SendTestEventResponse
            description: |
                What `POST /webhooks/endpoint/test` answers: the delivery as queued — `pending`,
                `attempts` at zero, `test` set — and how much of your allowance is left.
            required: [delivery, test_events_remaining_today]
            additionalProperties: false
            properties:
                delivery:
                    $ref: '#/components/schemas/WebhookDelivery'
                test_events_remaining_today:
                    type: integer
                    description: |
                        How many test events are left in the trailing 24 hours, after this one.
                        The allowance is per credential and rolls forward continuously — it does
                        not reset at midnight.

        WebhookDelivery:
            type: object
            title: WebhookDelivery
            description: |
                One delivery's ENVELOPE — what `GET /webhooks/deliveries` carries for every row.
                No `payload`, and no field that could hold one: a delivery's body is the most
                sensitive thing this platform sends anywhere, so the list carries only whether one
                is still held.
            required:
                [
                    delivery_id,
                    event_id,
                    event_type,
                    status,
                    attempts,
                    last_error_code,
                    last_response_status,
                    next_attempt_at,
                    occurred_at,
                    created_at,
                    payload_present,
                    test,
                ]
            additionalProperties: false
            properties:
                delivery_id:
                    type: string
                    format: uuid
                    description: Our id for this delivery. What the single read and the redrive address.
                event_id:
                    type: string
                    format: uuid
                    description: |
                        The id carried in the body we sent, and the same UUID the delivered
                        envelope's `event_id` holds. It SURVIVES a redrive — quote it to us, not
                        `delivery_id`.
                event_type:
                    $ref: '#/components/schemas/WebhookEventType'
                    description: |
                        The event this delivery carries.

                        See [`WebhookEventType`](/api-reference/enums/webhook-event-type).
                status:
                    $ref: '#/components/schemas/WebhookDeliveryStatus'
                    description: >-
                        Where the delivery is in its lifecycle. `redacted` means we kept the row and
                        dropped the body.

                        See [`WebhookDeliveryStatus`](/api-reference/enums/webhook-delivery-status).
                attempts:
                    type: integer
                    description: How many times we have POSTed this delivery. Reset to 0 by a redrive.
                last_error_code:
                    type: [string, 'null']
                    description: A code from our own closed vocabulary, never your response body.
                last_response_status:
                    type: [integer, 'null']
                    description: The HTTP status your endpoint answered with, when there was one.
                next_attempt_at:
                    type: [string, 'null']
                    format: date-time
                    description: When the next attempt is due. `null` when none is scheduled.
                occurred_at:
                    type: string
                    format: date-time
                    description: |
                        When Maat prepared the delivery. On every event except `booking.cancelled`
                        that is when we consumed the source event rather than when the thing
                        happened, so it is a delivery timestamp and not an ordering key.
                created_at:
                    type: string
                    format: date-time
                    description: When Maat queued the delivery.
                payload_present:
                    type: boolean
                    description: Whether there is still a body to read or redrive.
                test:
                    type: boolean
                    description: |
                        `true` when this delivery came from `POST /webhooks/endpoint/test`
                        rather than from something that happened. The body we POSTed
                        carries the same flag.

        WebhookDeliveryList:
            type: object
            title: WebhookDeliveryList
            required: [items, has_more, retention_days]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: One page of deliveries to your endpoint, newest first.
                    items:
                        $ref: '#/components/schemas/WebhookDelivery'
                has_more:
                    type: boolean
                    description: |
                        Whether another page exists. There is no total: counting the whole set to
                        answer one page is work neither of us needs.

                        **Pages are numbered, and the list is newest first, so a row that lands
                        while you are walking shifts the ones behind it.** A long walk can
                        therefore see a row twice or miss one. Key what you collect by its own
                        id and treat the walk as eventually complete rather than exact, or
                        narrow with the filters this operation offers so the set you are paging
                        cannot move.
                retention_days:
                    type: integer
                    description: |
                        How many days of delivery history we keep, so you read the window from us
                        rather than from a number in your own notes.

        WebhookDeliveryDetail:
            type: object
            title: WebhookDeliveryDetail
            description: |
                One delivery, WITH the body we sent you. Everything `WebhookDelivery` carries,
                plus `payload` and `payload_state` — the single-delivery read is the one place
                either appears.

                `payload` is the stored bytes, VERBATIM — never re-serialized from a parsed
                object. That is deliberate: it is literally what crossed the wire, so you can
                re-verify our signature against it, and re-serializing would silently change key
                order and whitespace and break exactly that. `signature_headers` is the other
                half of that input.
            required:
                [
                    delivery_id,
                    event_id,
                    event_type,
                    status,
                    attempts,
                    last_error_code,
                    last_response_status,
                    next_attempt_at,
                    occurred_at,
                    created_at,
                    payload_present,
                    test,
                    payload,
                    payload_state,
                    signature_headers,
                ]
            additionalProperties: false
            properties:
                delivery_id:
                    type: string
                    format: uuid
                    description: Maat's id for this attempt record. The id `redrive` takes.
                event_id:
                    type: string
                    format: uuid
                    description: >-
                        The `event_id` in the delivered body, as a UUID. Stable across retries
                        and a redrive.
                event_type:
                    $ref: '#/components/schemas/WebhookEventType'
                    description: |
                        The event this delivery carries.

                        See [`WebhookEventType`](/api-reference/enums/webhook-event-type).
                status:
                    $ref: '#/components/schemas/WebhookDeliveryStatus'
                    description: >-
                        Where the delivery is in its lifecycle. `redacted` means we kept the row and
                        dropped the body.

                        See [`WebhookDeliveryStatus`](/api-reference/enums/webhook-delivery-status).
                attempts:
                    type: integer
                    description: >-
                        How many times we have tried to deliver it. A redrive resets this to zero.
                last_error_code:
                    type: [string, 'null']
                    description: >-
                        Why the last attempt failed, from a closed vocabulary, or `null` if none has
                        failed.
                last_response_status:
                    type: [integer, 'null']
                    description: >-
                        The HTTP status your endpoint answered with on the last attempt, or `null`
                        if it never answered.
                next_attempt_at:
                    type: [string, 'null']
                    format: date-time
                    description: When we will try again, or `null` when no attempt is scheduled.
                occurred_at:
                    type: string
                    format: date-time
                    description: >-
                        The `occurred_at` in the delivered body. See the delivery-timestamp note on
                        the webhook envelopes.
                created_at:
                    type: string
                    format: date-time
                    description: When Maat queued the delivery.
                payload_present:
                    type: boolean
                    description: >-
                        Whether the body is still stored. `false` once it has aged out or been
                        redacted — the row survives, the body does not.
                test:
                    type: boolean
                    description: |
                        `true` when this delivery came from `POST /webhooks/endpoint/test`
                        rather than from something that happened. The body we POSTed
                        carries the same flag.
                payload:
                    type: [string, 'null']
                    description: |
                        The exact bytes we sent, as a JSON STRING — not a re-parsed object.
                        `null` in every state but `held`.
                payload_state:
                    $ref: '#/components/schemas/WebhookPayloadState'
                    description: |
                        `held` — we still have the exact bytes; the only state a redrive can use.
                        `redacted` — the delivery was still owed when the guest exercised their
                        right to erasure, so the body was destroyed and it will never be sent;
                        nothing is broken, there is simply nothing left to send. `purged` — the
                        body aged out of the retention window, or was destroyed by an erasure
                        after the delivery had already settled. Either way, nothing is left to
                        send.

                        See [`WebhookPayloadState`](/api-reference/enums/webhook-payload-state).
                signature_headers:
                    type: [object, 'null']
                    additionalProperties:
                        type: string
                    description: |
                        The signature headers the LAST attempt carried, exactly as we sent them:
                        `X-Signature`, `X-Signature-Timestamp`, and `X-Signature-Previous` when a
                        rotation overlap was open. That third key's ABSENCE is itself a fact — no
                        second signature was sent — and not an omission.

                        With the byte-exact `payload` beside them you have the complete input to
                        your own verifier, so a failure is diagnosable without asking anyone:
                        a wrong secret, a wrong canonical string and a clock look different once
                        you can compare. The values were already on the wire and are already in
                        your own access log; this is the same disclosure to the same party.

                        Recomputed on read from the timestamp and the secret generations the
                        attempt recorded — **the signing secret is never stored and is never
                        returned here**. `POST /webhooks/endpoint/rotate-secret` is the only
                        thing that ever returns a secret, once.

                        `null` in three honest cases: nothing was ever attempted; the body is
                        gone (`payload_state` is `redacted` or `purged`), so a signature over
                        bytes we no longer have cannot be rebuilt; or the platform cannot sign at
                        all right now.
                    example:
                        X-Signature-Timestamp: '1789200000'
                        X-Signature: b9eeb16c320e853232bad7b7200b69a4bfa3abce6580e1de5025a6c441e4fdd6

        WebhookDeliveryDetailResponse:
            title: WebhookDeliveryDetailResponse
            description: |
                One delivery, with its body — the bare object. The redrive and the test event
                nest a `delivery` because each carries a second member beside it (the remaining
                allowance); this one has nothing to sit next to.
            allOf:
                - $ref: '#/components/schemas/WebhookDeliveryDetail'

        RedriveWebhookDeliveryResponse:
            type: object
            title: RedriveWebhookDeliveryResponse
            description: |
                What `POST /webhooks/deliveries/{delivery_id}/redrive` answers: the delivery,
                re-read after being put back in the queue — `pending`, `attempts` at zero, the
                SAME `event_id`. That last part is the contract: your idempotency key must
                survive a replay, or a redrive becomes a duplicate in your own system rather than
                a retry.
            required: [delivery, redrives_remaining_today]
            additionalProperties: false
            properties:
                delivery:
                    $ref: '#/components/schemas/WebhookDelivery'
                redrives_remaining_today:
                    type: integer
                    description: |
                        How many redrives are left in today's allowance, after this one. The
                        allowance is per credential and rolls forward continuously — it does not
                        reset at midnight.

        DeveloperBookingStay:
            type: object
            title: DeveloperBookingStay
            description: |
                The stay itself — when, and how many people. Never who.

                The two door times are the UNIT's own, for a booking whose
                `inventory_kind` is `unit`. A **service** or an **experience** hangs off no
                unit and has no door: those bookings carry the platform defaults (`15:00` /
                `12:00`) so the field is always present and always a real time, and an
                integration that staffs a door should branch on `inventory_kind` rather than
                treat them as the operator's own answer.
            required: [check_in, check_out, check_in_time, check_out_time, guests]
            additionalProperties: false
            properties:
                check_in:
                    type: string
                    format: date
                    description: Arrival date.
                check_out:
                    type: string
                    format: date
                    description: EXCLUSIVE — the guest is gone by then.
                check_in_time:
                    type: string
                    pattern: '^([01]\d|2[0-3]):[0-5]\d$'
                    description: >-
                        The unit's own local time when `inventory_kind` is `unit` — a date
                        alone cannot be staffed. The platform default (`15:00`) for a service
                        or an experience, which has no unit.
                check_out_time:
                    type: string
                    pattern: '^([01]\d|2[0-3]):[0-5]\d$'
                    description: >-
                        The unit's own local time when `inventory_kind` is `unit`; the platform
                        default (`12:00`) for a service or an experience.
                guests:
                    type: integer
                    minimum: 1
                    description: A COUNT. There is no per-guest anything on this contract.

        DeveloperBookingCancellation:
            type: object
            title: DeveloperBookingCancellation
            description: |
                How a booking ended, when it ended that way. Present only on a cancelled booking.

                `initiated_by` decides what you do next: `guest` frees the dates to re-sell,
                `host` is an operational failure somebody has to chase. The resolved cancellation
                policy — refund percent, penalty percent, the applied stage — is not here: that
                is a settlement between Maat, the guest and the host.
            required: [cancelled_at, initiated_by]
            additionalProperties: false
            properties:
                cancelled_at:
                    type: string
                    format: date-time
                    description: When the booking was cancelled.
                initiated_by:
                    $ref: '#/components/schemas/CancellationInitiator'
                    description: |
                        Which side cancelled. Never who they are.

                        See [`CancellationInitiator`](/api-reference/enums/cancellation-initiator).

        DeveloperBooking:
            type: object
            title: DeveloperBooking
            description: |
                One booking, as your workspace reads it.

                There is NO guest identity here, anywhere: no name, no initial, no email, no
                phone, no reference you could join against another read. Every field is a fact
                about a STAY, not a fact about a person. `console_url` is the honest pointer to
                where a person IS seen — the host console, where an authenticated human sees who
                is arriving. That is deliberately slower than a field on this wire, and it is the
                point.

                There is no cancellation over this API. Cancelling a confirmed stay creates
                refunds and penalties and belongs to a person in the app.
            required:
                [
                    booking_reference,
                    property,
                    inventory_kind,
                    status,
                    stay,
                    amount_paid,
                    instant_booking,
                    cancellation,
                    created_at,
                    console_url,
                ]
            additionalProperties: false
            properties:
                booking_reference:
                    type: string
                    format: uuid
                    description: >-
                        Maat's own booking id, and the correlation key for every later event.
                        Treat it as OPAQUE: store it and echo it back, but do not parse it or
                        derive anything from its shape. It is a UUID today and the contract
                        does not promise it always will be.
                property:
                    $ref: '#/components/schemas/DeveloperBookingProperty'
                    description: |
                        Which of your inventory was booked. One object rather than four ids at
                        the root, so that the same accessor reads a booking, a booking event and
                        a publication event.
                inventory_kind:
                    $ref: '#/components/schemas/InventoryKind'
                    description: >-
                        What was sold. This contract manages `unit` stays; a write against the other
                        two is refused with `inventory_not_supported`.

                        See [`InventoryKind`](/api-reference/enums/inventory-kind).
                status:
                    $ref: '#/components/schemas/BookingStatus'
                    description: |
                        A closed vocabulary, not the platform's internal state. `pending` is the
                        only state `confirm` and `reject` act on.

                        See [`BookingStatus`](/api-reference/enums/booking-status).
                stay:
                    $ref: '#/components/schemas/DeveloperBookingStay'
                amount_paid:
                    oneOf:
                        - $ref: '#/components/schemas/Money'
                        - type: 'null'
                    description: |
                        What the guest paid, as one object. `null` when it cannot be stated — a
                        legacy row with no recorded amount resolves to `null`, never to `"0.00"`:
                        zero would read as free, and `null` means "we do not know".
                instant_booking:
                    type: boolean
                    description: True when the listing takes bookings without the host approving each one.
                cancellation:
                    oneOf:
                        - $ref: '#/components/schemas/DeveloperBookingCancellation'
                        - type: 'null'
                    description: Present only on a cancelled booking.
                created_at:
                    type: string
                    format: date-time
                    description: When the booking was created in Maat.
                console_url:
                    type: string
                    format: uri
                    description: |
                        The host console's page for this booking — where a person is seen.

        DeveloperBookingList:
            type: object
            title: DeveloperBookingList
            required: [items, has_more]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: One page of bookings on your inventory, newest first.
                    items:
                        $ref: '#/components/schemas/DeveloperBooking'
                has_more:
                    type: boolean
                    description: |
                        Whether another page exists. There is no total: counting the whole set to
                        answer one page is work neither of us needs.

                        **Pages are numbered, and the list is newest first, so a row that lands
                        while you are walking shifts the ones behind it.** A long walk can
                        therefore see a row twice or miss one. Key what you collect by its own
                        id and treat the walk as eventually complete rather than exact, or
                        narrow with the filters this operation offers so the set you are paging
                        cannot move.

        DeveloperReviewCategoryScore:
            type: object
            title: DeveloperReviewCategoryScore
            description: How a guest scored one axis of a stay. The platform's own closed vocabulary.
            required: [category, rating]
            additionalProperties: false
            properties:
                category:
                    type: string
                    description: Which axis of the stay was scored.
                rating:
                    type: number
                    description: The score on that axis, out of five.

        DeveloperReviewReply:
            type: object
            title: DeveloperReviewReply
            description: The host's reply, when there is one. Authored BY THE WORKSPACE, not a person.
            required: [body, created_at]
            additionalProperties: false
            properties:
                body:
                    type: string
                    description: The workspace's reply, as published.
                created_at:
                    type: string
                    format: date-time
                    description: When the reply was posted.

        DeveloperReview:
            type: object
            title: DeveloperReview
            description: |
                One review, in its PUBLIC form — the same shortened reviewer label anyone reading
                the listing page sees. The full name stays on the host console, where a person
                authenticated to see it; there is no field here that carries it.
            required:
                [
                    review_id,
                    listing_id,
                    reviewer_name,
                    rating,
                    comment,
                    categories,
                    created_at,
                    reply,
                    console_url,
                ]
            additionalProperties: false
            properties:
                review_id:
                    type: string
                    format: uuid
                    description: Maat's id for the review.
                listing_id:
                    type: string
                    format: uuid
                    description: The listing the review is about.
                reviewer_name:
                    type: string
                    description: The shortened public label ("Sarah M."), never the full name.
                    example: Sarah M.
                rating:
                    type: number
                    description: The overall score.
                comment:
                    type: string
                    description: >-
                        What the guest wrote, in its public form. Empty string when they left a
                        score and no words.
                categories:
                    type: array
                    description: The per-axis scores behind the overall rating.
                    items:
                        $ref: '#/components/schemas/DeveloperReviewCategoryScore'
                created_at:
                    type: string
                    format: date-time
                    description: When the review was published.
                reply:
                    description: The workspace's one reply, or `null` when there is none yet.
                    oneOf:
                        - $ref: '#/components/schemas/DeveloperReviewReply'
                        - type: 'null'
                console_url:
                    type: string
                    format: uri
                    description: The host console's page for this listing's reputation.

        DeveloperReviewList:
            type: object
            title: DeveloperReviewList
            required: [items, has_more]
            additionalProperties: false
            properties:
                items:
                    type: array
                    description: One page of reviews on the listing, newest first.
                    items:
                        $ref: '#/components/schemas/DeveloperReview'
                has_more:
                    type: boolean
                    description: |
                        Whether another page exists. There is no total: counting the whole set to
                        answer one page is work neither of us needs.

                        **Pages are numbered, and the list is newest first, so a row that lands
                        while you are walking shifts the ones behind it.** A long walk can
                        therefore see a row twice or miss one. Key what you collect by its own
                        id and treat the walk as eventually complete rather than exact, or
                        narrow with the filters this operation offers so the set you are paging
                        cannot move.

        SandboxBookingStatus:
            type: string
            title: SandboxBookingStatus
            description: |
                Which half of the booking lifecycle a sandbox fixture booking starts on, and
                therefore which webhook it sends first.

                Required rather than defaulted: the two produce different events, and a caller
                who did not say which they wanted should be told to say so.

                See [SandboxBookingStatus](/api-reference/enums/sandbox-booking-status).
            enum:
                - pending
                - confirmed
            x-enum-descriptions:
                pending: >-
                    Waiting on a host decision. Sends `booking.requested`; drive it on with
                    `POST /v1/bookings/{booking_reference}/confirm` or `/reject`.
                confirmed: >-
                    Already confirmed, as an instant-booking stay would be. Sends
                    `booking.confirmed`.
            x-maat-internal-enum:
                name: DeveloperBookingStatus
                relation: subset_of

        CreateSandboxBookingRequest:
            type: object
            title: CreateSandboxBookingRequest
            description: |
                What to mint. There is no guest field, no amount and no currency, and none of
                them is coming: the guest is synthetic and minted by us, and the amount comes
                from your listing's own price for those dates — which is what makes the figures
                on the resulting webhook the shape a real booking's are.
            required: [listing_id, check_in, check_out, guests, status]
            additionalProperties: false
            properties:
                listing_id:
                    type: string
                    format: uuid
                    description: >-
                        The listing to book. Must be in a **sandbox** workspace, and must be a
                        stay — an experience or a service is a `409 inventory_not_supported`.
                check_in:
                    type: string
                    format: date
                    description: First night of the stay.
                check_out:
                    type: string
                    format: date
                    description: >-
                        Departure day, EXCLUSIVE — the guest is gone by then. At most 30 nights
                        after `check_in`, because the booking holds those nights on a real
                        sandbox calendar.
                guests:
                    type: integer
                    minimum: 1
                    maximum: 50
                    description: The party SIZE. Never a per-guest anything.
                status:
                    allOf:
                        - $ref: '#/components/schemas/SandboxBookingStatus'
                    description: >-
                        Which half of the lifecycle to watch. See
                        [SandboxBookingStatus](/api-reference/enums/sandbox-booking-status).

        DeveloperReviewReplyRequest:
            type: object
            title: DeveloperReviewReplyRequest
            description: |
                One field. There is no `author` and there cannot be: a reply is published as the
                WORKSPACE, exactly as the host app publishes it, and letting a caller name an
                author would put a person's name on public content through an API key.
            required: [body]
            additionalProperties: false
            properties:
                body:
                    type: string
                    minLength: 1
                    maxLength: 2000
                    description: >-
                        The reply to publish. One workspace reply per review; a second is a `409
                        conflict`.

        DeveloperReviewReplyResponse:
            title: DeveloperReviewReplyResponse
            description: The review, read back with its reply on it — the bare object.
            allOf:
                - $ref: '#/components/schemas/DeveloperReview'

        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

        RulesDeactivated:
            type: object
            title: RulesDeactivated
            description: How many of the workspace's own rules a pricing-mode take switched off.
            required: [pricing]
            additionalProperties: false
            properties:
                pricing:
                    type: integer
                    description: >-
                        How many of the workspace's pricing rules were switched off by taking the
                        mode.
                    example: 2

        QuickActionApplication:
            type: object
            title: QuickActionApplication
            description: |
                A quick action as it is currently applied to a listing. Present only when the
                action is on.
            required: [value, active]
            additionalProperties: false
            properties:
                value:
                    description: >-
                        The value the action is applied with, typed by `param.kind`. Absent from
                        the schema as a single type because it is one of several.
                    oneOf:
                        - type: number
                        - type: array
                          items:
                              type: string
                        - $ref: '#/components/schemas/Money'
                active:
                    type: boolean
                    description: Whether the rule this action created is currently on.

        ListingStatus:
            type: string
            title: ListingStatus
            description: |
                Whether a listing is on sale. `unlisted` covers both "taken off sale" and "not
                publishable yet" — read `missing` to tell them apart.
            enum: [listed, unlisted]
            x-enum-descriptions:
                listed: >-
                    On sale on Maat right now.
                unlisted: >-
                    Not on sale — read `missing` to tell "held back" from "taken off".
            x-maat-internal-enum:
                name: PartnerPropertyStatus
                relation: equals

        BookingStatus:
            type: string
            title: BookingStatus
            description: |
                Where a booking is in its life. A closed vocabulary; `pending` is the only status
                `confirm` and `reject` accept.
            enum: [pending, confirmed, checked_in, completed, cancelled, rejected]
            x-enum-descriptions:
                pending: >-
                    Paid, and waiting for the host to approve it. The only status `confirm` and
                    `reject` accept.
                confirmed: >-
                    Approved. The nights are sold.
                checked_in: >-
                    The guest confirmed their own arrival.
                completed: >-
                    The stay is over.
                cancelled: >-
                    Cancelled by the guest or the host; read `cancellation.initiated_by`.
                rejected: >-
                    The host declined the request.
            x-maat-internal-enum:
                name: DeveloperBookingStatus
                relation: equals

        WriteResult:
            type: string
            title: WriteResult
            description: |
                What an idempotent `PUT` keyed by your own identifier did: created the row, or
                replaced one you had already pushed. Both are a `200`.
            enum: [created, updated]
            x-enum-descriptions:
                created: >-
                    There was nothing under that identifier; this call made it.
                updated: >-
                    Something was already there under that identifier; this call replaced it.
            x-maat-internal-enum:
                name: PartnerWriteResult
                relation: equals

        RuleFamily:
            type: string
            title: RuleFamily
            description: |
                Which engine evaluates a rule. A booking rule gates a stay; a pricing rule moves a
                rate. A closed vocabulary.
            enum: [booking, pricing]
            x-enum-descriptions:
                booking: >-
                    A rule that gates a stay. Reachable with `rules:write`.
                pricing: >-
                    A rule that moves a rate. Reachable with `pricing:write`.
            x-maat-internal-enum:
                name: DeveloperQuickActionFamily
                relation: equals

        WebhookDeliveryStatus:
            type: string
            title: WebhookDeliveryStatus
            description: |
                Where a delivery is in its life. `redacted` is not a failure — the attempt record
                survives and the body has been dropped.
            enum: [pending, retrying, delivering, delivered, failed, redacted]
            x-enum-descriptions:
                pending: >-
                    Queued and not yet attempted.
                retrying: >-
                    An attempt failed and another is scheduled.
                delivering: >-
                    An attempt is in flight right now.
                delivered: >-
                    Your endpoint answered 2xx.
                failed: >-
                    Every attempt was spent without a 2xx.
                redacted: >-
                    Not a failure. The delivery was still owed when the guest exercised their right
                    to erasure, so the body was destroyed and it will never be sent.
            x-maat-internal-enum:
                name: ChannelPartnerDeliveryStatus
                relation: equals

        BlockReason:
            type: string
            title: BlockReason
            description: |
                Why nights are held on your side. A closed set, deliberately coarse: it is the only
                descriptive field on a block and it can never carry anything about a guest.
            enum: [reserved, maintenance, owner_stay, other]
            x-enum-descriptions:
                reserved: >-
                    Sold on your side.
                maintenance: >-
                    Closed for work on the property.
                owner_stay: >-
                    The owner is using it.
                other: >-
                    Anything else. There is deliberately no free-text alternative.
            x-maat-internal-enum:
                name: PartnerBlockReason
                relation: equals

        WebhookEndpointStatus:
            type: string
            title: WebhookEndpointStatus
            description: |
                Whether an endpoint is delivering. A new or re-registered endpoint is
                `pending_verification` until it echoes the challenge; `suspended` is set by Maat.
            enum: [pending_verification, active, suspended]
            x-enum-descriptions:
                pending_verification: >-
                    Registered, and not yet delivering: it has still to echo the challenge.
                active: >-
                    Delivering.
                suspended: >-
                    Stopped by Maat. Nothing is delivered and nothing is queued.
            x-maat-internal-enum:
                name: PartnerWebhookEndpointStatus
                relation: equals

        CancellationInitiator:
            type: string
            title: CancellationInitiator
            description: |
                Which side cancelled a booking. Never who they are.
            enum: [guest, host]
            x-enum-descriptions:
                guest: >-
                    The guest cancelled. Free the nights and re-sell them.
                host: >-
                    The host cancelled. An operational failure somebody has to chase.
            x-maat-internal-enum:
                name: PartnerCancellationInitiator
                relation: equals

        InventoryKind:
            type: string
            title: InventoryKind
            description: |
                What is being sold. This contract manages `unit` stays; a write against a `service`
                or an `experience` is refused with `inventory_not_supported`.
            enum: [unit, service, experience]
            x-enum-descriptions:
                unit: >-
                    A place somebody sleeps. The only kind this contract manages.
                service: >-
                    A provider's time, sold in slots. Refused with `inventory_not_supported`.
                experience: >-
                    A scheduled session, sold in seats. Refused with `inventory_not_supported`.
            x-maat-internal-enum:
                name: PartnerInventoryKind
                relation: equals

        PhotoState:
            type: string
            title: PhotoState
            description: |
                What one photo is doing. Only a `ready` photo has a `url`, and only a `rejected` one
                carries a `rejection`.
            enum:
                - pending
                - ready
                - rejected
            x-enum-descriptions:
                pending: >-
                    Reserved. The bytes have not arrived, or have not been processed yet — poll
                    until it moves.
                ready: >-
                    Processed, reviewed and published. The only state with a `url`.
                rejected: >-
                    Terminal. `rejection` says why, from a closed vocabulary.
            x-maat-internal-enum:
                name: PartnerPhotoState
                relation: equals

        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

        BookingEventType:
            type: string
            title: BookingEventType
            description: |
                The booking transitions that carry the full booking envelope.

                A narrowing of `WebhookEventType` to the seven events that arrive in THIS shape, so a
                generated client's union names only what can actually turn up here.
            enum:
                - booking.requested
                - booking.confirmed
                - booking.rejected
                - booking.checked_in
                - booking.checked_out
                - booking.cancelled
                - booking.modified
            x-enum-descriptions:
                booking.requested: >-
                    A guest paid for a stay the host still has to approve.
                booking.confirmed: >-
                    The stay is sold.
                booking.rejected: >-
                    The host declined the request.
                booking.checked_in: >-
                    The guest confirmed their own arrival.
                booking.checked_out: >-
                    The guest confirmed their own departure.
                booking.cancelled: >-
                    The stay was cancelled; `cancellation` is present.
                booking.modified: >-
                    The stay window moved; `previous_stay` is present.
            x-maat-internal-enum:
                name: PartnerWebhookEventType
                relation: subset_of

        ListingPublicationStatus:
            type: string
            title: ListingPublicationStatus
            description: |
                Which side of a publication transition a listing is now on.

                Not `ListingStatus`: this is the transition an event reports, and it is spelled
                `published` rather than `listed` because that is the word the event is named after.
            enum:
                - published
                - unlisted
            x-enum-descriptions:
                published: >-
                    The listing went on sale.
                unlisted: >-
                    The listing came off sale. The event never says why.
            x-maat-internal-enum:
                name: DeveloperListingPublicationStatus
                relation: equals

        ListingPublicationEventType:
            type: string
            title: ListingPublicationEventType
            description: |
                The two publication transitions that carry the listing envelope. A narrowing of
                `WebhookEventType`.
            enum:
                - listing.published
                - listing.unlisted
            x-enum-descriptions:
                listing.published: >-
                    A listing went on sale.
                listing.unlisted: >-
                    A listing came off sale.
            x-maat-internal-enum:
                name: PartnerWebhookEventType
                relation: subset_of

        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

        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

        CalendarNightStatus:
            type: string
            title: CalendarNightStatus
            description: |
                What one night on a listing calendar is doing.

                `booked` and `held` are sales; `blocked` is a closure. `blocked_by` says whose closure it
                is, and is `null` on every other status.
            enum:
                - available
                - booked
                - held
                - blocked
            x-enum-descriptions:
                available: >-
                    Sellable.
                booked: >-
                    Sold, and it will not become available again on its own.
                held: >-
                    A checkout is in flight. It may expire back to `available`.
                blocked: >-
                    Closed rather than sold. `blocked_by` says by whom.
            x-maat-internal-enum:
                name: PartnerNightStatus
                relation: equals

        BlockedBy:
            type: string
            title: BlockedBy
            description: |
                Who closed a night. Only ever set on a `blocked` night — a sale is not a block.
            enum:
                - host
                - ical
                - partner
            x-enum-descriptions:
                host: >-
                    The property owner closed it in the Maat app.
                ical: >-
                    It arrived on a calendar feed the host connected.
                partner: >-
                    You closed it, through the blocks contract.
            x-maat-internal-enum:
                name: PartnerNightBlockedBy
                relation: equals

        HouseRuleKind:
            type: string
            title: HouseRuleKind
            description: |
                How a house rule reads on a listing page, which decides how to offer it.
            enum:
                - permission
                - requirement
            x-enum-descriptions:
                permission: >-
                    Has two polarities — "Pets allowed" / "No pets" — so both answers say something.
                requirement: >-
                    Shown only when answered, and has no negated form. Leaving it unanswered is not
                    a policy.
            x-maat-internal-enum:
                name: HouseRuleKind
                relation: equals

        UnitType:
            type: string
            title: UnitType
            description: |
                What a unit is: the whole property, or one room inside it.
            enum:
                - whole_property
                - private_room
            x-enum-descriptions:
                whole_property: >-
                    The guest has the property to themselves. Setting this on a property that has
                    another unit is a `409 conflict`.
                private_room: >-
                    One room inside a property that has more than one.
            x-maat-internal-enum:
                name: PartnerUnitType
                relation: equals

        HostBlockCategory:
            type: string
            title: HostBlockCategory
            description: |
                Why nights on your own listing are closed. Required, and a closed set — there is no
                free-text alternative, because the obvious thing to write in one is a guest's name.
            enum:
                - maintenance
                - personal_use
                - renovation
                - cleaning
                - other
            x-enum-descriptions:
                maintenance: >-
                    Repairs or upkeep.
                personal_use: >-
                    The owner or someone they invited is staying.
                renovation: >-
                    Building work.
                cleaning: >-
                    A turnaround that needs the night.
                other: >-
                    Anything else.
            x-maat-internal-enum:
                name: BlockReasonCategory
                relation: equals

        QuickActionParamKind:
            type: string
            title: QuickActionParamKind
            description: |
                How to type and render a quick action's one tunable value, and therefore what shape to
                send it in.
            enum:
                - number
                - percent
                - currency
                - days_of_week
            x-enum-descriptions:
                number: >-
                    A plain number — nights, guests, a count.
                percent: >-
                    A number read as a percentage.
                currency: >-
                    A Money object in the listing's own currency.
                days_of_week: >-
                    An array of day names.
            x-maat-internal-enum:
                name: RuleQuickActionParamKind
                relation: subset_of

        QuickActionUnavailableReason:
            type: string
            title: QuickActionUnavailableReason
            description: |
                Why a quick action cannot be switched on right now. `null` when it can.
            enum:
                - external_pricing
                - inventory_kind
                - retired
                - insufficient_scope
            x-enum-descriptions:
                external_pricing: >-
                    The listing's price is set by another source.
                inventory_kind: >-
                    The action does not apply to this listing's inventory kind. Published for
                    completeness and not returned today — a listing that is not a stay is refused
                    outright.
                retired: >-
                    The template was withdrawn. Its identifier stays published.
                insufficient_scope: >-
                    About your KEY rather than the listing: a `pricing.*` template read by a key
                    without `pricing:write`. Ask whoever issued the key for one that carries it.
            x-maat-internal-enum:
                name: DeveloperQuickActionUnavailableReason
                relation: equals

        QuickActionWarning:
            type: string
            title: QuickActionWarning
            description: |
                Why part of a quick-action list is missing. Both are retryable — the actions of that
                family are absent from `items` entirely rather than reported as retired, which would turn
                a short outage into a permanent-looking withdrawal.
            enum:
                - booking_unavailable
                - pricing_unavailable
            x-enum-descriptions:
                booking_unavailable: >-
                    The booking family could not be reached. Retryable.
                pricing_unavailable: >-
                    The pricing family could not be reached. Retryable.
            x-maat-internal-enum:
                name: DeveloperQuickActionWarning
                relation: equals

        PricingModeTakeWarning:
            type: string
            title: PricingModeTakeWarning
            description: |
                Something worth knowing about the mode you now hold. A warning, never a refusal.
            enum:
                - no_rates_next_30_nights
            x-enum-descriptions:
                no_rates_next_30_nights: >-
                    The coming month is not fully priced. Every unpriced night is simply unquotable
                    until you send a rate for it.
            x-maat-internal-enum:
                name: PartnerPricingWarning
                relation: equals

        ListingRuleSource:
            type: string
            title: ListingRuleSource
            description: |
                Where a rule attached to a listing came from.
            enum:
                - quick_action
                - authored
            x-enum-descriptions:
                quick_action: >-
                    A quick action on this API. `template_key` names which.
                authored: >-
                    Drawn by hand in the Maat app. Never writable over this API.
            x-maat-internal-enum:
                name: DeveloperRuleSource
                relation: equals

        ListingRuleWarning:
            type: string
            title: ListingRuleWarning
            description: |
                Why part of a rules answer is missing.

                A superset of `QuickActionWarning`, and deliberately not spelled the same: two of these
                are outages worth retrying and one never is.
            enum:
                - booking_unavailable
                - pricing_unavailable
                - pricing_withheld
            x-enum-descriptions:
                booking_unavailable: >-
                    The booking family could not be reached. Retryable.
                pricing_unavailable: >-
                    The pricing family could not be reached. Retryable.
                pricing_withheld: >-
                    Not an outage. Your key does not carry `pricing:write`, so the pricing rules are
                    absent. Never retry this — ask whoever issued the key for one that carries the
                    scope.
            x-maat-internal-enum:
                name: DeveloperRuleWarning
                relation: equals

        WebhookPayloadState:
            type: string
            title: WebhookPayloadState
            description: |
                Whether the exact bytes of a delivery are still stored. Only `held` can be redriven.
            enum:
                - held
                - redacted
                - purged
            x-enum-descriptions:
                held: >-
                    We still have the exact bytes. The only state a redrive can use.
                redacted: >-
                    The delivery was still owed when the guest exercised their right to erasure, so
                    the body was destroyed and it will never be sent. Nothing is broken; there is
                    simply nothing left to send.
                purged: >-
                    The body aged out of the retention window, or was destroyed by an erasure after
                    the delivery had already settled.
            x-maat-internal-enum:
                name: PartnerDeliveryPayloadState
                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

        HostBlockForbidden:
            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.
                - `insufficient_scope` — the key was not issued with `calendar:write`.
            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 `calendar: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

        Conflict:
            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: |
                Two different refusals share this status. **Branch on `code`, never on the
                status alone** — one is worth retrying with a narrower range and the other
                never is.

                `conflict` — the nights collide with a Maat booking or a live hold. **Nothing was
                written**: the whole range is refused rather than the part of it that was free,
                because a block you were told had applied is a block you would stop holding
                yourself. `conflicts` carries every colliding date. Re-push a range that excludes
                them, or leave the block off entirely if the stay cannot be honoured.

                `listing_pooled_unsupported` — the listing sells a room type over several rooms,
                and a block closes one unit. Nothing was written. Retrying cannot help.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    examples:
                        conflict:
                            summary: Nights already booked or held
                            value:
                                type: https://developer.maatstays.wtf/errors/conflict
                                title: Conflict
                                status: 409
                                detail: >-
                                    These nights are already booked or held on Maat, so the block was
                                    not applied: 2026-09-02, 2026-09-03. Nothing was written — the
                                    whole range was refused rather than part of it.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: conflict
                                conflicts: ['2026-09-02', '2026-09-03']
                        listing_pooled_unsupported:
                            summary: A room type with several rooms
                            value:
                                type: https://developer.maatstays.wtf/errors/listing-pooled-unsupported
                                title: Listing sells a pool of rooms
                                status: 409
                                detail: >-
                                    This listing sells a room type over several rooms, and a block
                                    closes one unit. Nothing was blocked.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: listing_pooled_unsupported

        RateLimited:
            description: |
                Too many requests for this API key. `Retry-After` gives the wait in seconds.
            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'
                    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

        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

        PhotoOrderNotFound:
            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: |
                Nothing was changed. Two codes share this status — branch on `code`:

                - `property_not_found` — no such property in your workspace. One answer also
                  covers a property that exists in another workspace.
                - `photo_not_found` — the property is yours, but one of the ids you sent is not a
                  photo on it. One answer covers a photo that does not exist and one that belongs
                  to a different property; `detail` names the ids.

                Read the photos back with `GET /v1/properties/{property_id}/photos` and send the
                order again.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    examples:
                        propertyNotFound:
                            summary: No such property in this workspace
                            value:
                                type: https://developer.maatstays.wtf/errors/property-not-found
                                title: Property not found
                                status: 404
                                detail: No such property in this workspace. Nothing was changed.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: property_not_found
                        photoNotFound:
                            summary: One of the photo ids is not on this property
                            value:
                                type: https://developer.maatstays.wtf/errors/photo-not-found
                                title: Photo not found
                                status: 404
                                detail: >-
                                    No such photo on this property. Nothing was changed — read the
                                    photos back and send the order again.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: photo_not_found

        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

        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

        TokenUnauthorized:
            description: |
                No usable key. ONE answer for an unknown key, a revoked key, a wrong secret, a
                key whose partner is suspended and a key whose workspace is suspended — telling
                them apart would let anyone probe credentials. `detail` is `API key required`
                when the header is absent and `Invalid API key` for every other reason; there is
                no third string, and neither one tells you which of the five you hit.
            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' }
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/unauthorized
                        title: Unauthorized
                        status: 401
                        detail: Invalid API key
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: unauthorized

        PropertyNotEnabled:
            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 the property push returns against a production workspace.** The same
                refusal as `NotEnabled` on every other operation, with a `detail` written for
                this contract.
            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

        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

        ListingNotFound:
            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 listing in the workspace your key addresses. ONE answer for "it does not
                exist" and "it is not yours".
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/listing-not-found
                        title: Listing not found
                        status: 404
                        detail: No such listing in this workspace.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: listing_not_found

        UnitNotFound:
            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 unit in the workspace your key addresses. ONE answer for "it does not
                exist" and "it is not yours".
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/unit-not-found
                        title: Unit not found
                        status: 404
                        detail: No such unit in this workspace.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: unit_not_found

        ReadRefused:
            description: |
                **Two codes share this status — branch on `code`.** Neither is `conflict`, and
                nothing was read.

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

                Both are refusals about the **kind of thing** you addressed, not about its
                state, so re-sending will never help. Read the property with
                `GET /properties/by-id/{property_id}` to see what it is.
            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' }
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/property-not-simple
                        title: Conflict
                        status: 409
                        detail: >-
                            This property has more than one unit, so there is no single set of
                            photos to read. Nothing was read.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: property_not_simple

        WriteRefused:
            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 thing it
                addresses is the wrong kind of thing to address that way. **Nothing was
                written.** Three codes share this status; branch on `code`:

                - `property_not_simple` — the property has more than one unit, or more than one
                  listing (or the unit is pooled inventory under a parent unit and is not
                  individually editable). A write addressed at "the property" then has no single
                  thing to change. This is a **temporary refusal**: the write surface manages a
                  property that is one unit with one listing today, and multi-unit routes are
                  additions later, not renames of these.
                - `inventory_not_supported` — the listing is an experience or a service, sold by
                  the seat, and this contract manages stays. Bedrooms, door times and a nightly
                  rate have no answer for one. The READ operations deliberately return such a
                  listing **with its `inventory_kind`** rather than pretending it does not exist;
                  the write surface is where the refusal belongs.
                - `conflict` — on `PATCH /units/{unit_id}` only: `unit_type: "whole_property"`
                  cannot sit beside another unit on the same property.
            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 written.
                                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 written.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: inventory_not_supported
                        wholePropertyBesideAnotherUnit:
                            summary: A whole-property unit beside another unit
                            value:
                                type: https://developer.maatstays.wtf/errors/conflict
                                title: Conflict
                                status: 409
                                detail: >-
                                    A whole-property unit cannot sit beside another unit on the
                                    same property. Nothing was written.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: conflict

        InventoryNotSupported:
            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 listing is an experience or a service, and this contract is stays-only.
                Nothing was read or written.

                A listing does not change its inventory kind, so retrying cannot help.
                `GET /v1/listings/{listing_id}` reports the kind.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/inventory-not-supported
                        title: Inventory kind not supported
                        status: 409
                        detail: This listing is an experience, and this contract is stays-only. Nothing was read or written.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: inventory_not_supported

        SandboxOnlyConflict:
            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 listing cannot take a fixture booking, and nothing was created. Three codes
                share this status and they mean different things — **branch on `code`, never on
                the status alone**. One of them is worth retrying and two of them never are:

                * `conflict` — the nights are not free. They are already held by a booking, a
                  live hold or a block on your sandbox calendar. `conflicts` carries every
                  closed night, never truncated, in the same shape `PUT
                  /v1/blocks/{external_uid}` answers a collision with — so a handler you
                  already wrote for one works for the other. Move the window and retry.
                * `sandbox_only` — the listing is the one in your own LIVE workspace, which
                  is almost always an id copied from your production dashboard. The fixture
                  only ever creates bookings on sandbox inventory, and it will not be persuaded
                  otherwise. It is a `409` and not a `404` because the listing is yours and you
                  can see it; telling you it does not exist would send you hunting through your
                  own catalogue. A listing outside your own live/sandbox pair is a `404`
                  instead — this code never reports on anybody else's inventory.
                * `inventory_not_supported` — the listing is an experience or a service. The
                  fixture creates stays only, and a listing does not change its kind, so
                  retrying cannot help.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    examples:
                        conflict:
                            summary: Those nights are already taken
                            value:
                                type: https://developer.maatstays.wtf/errors/conflict
                                title: Conflict
                                status: 409
                                detail: >-
                                    These nights are already booked, held or blocked on your sandbox
                                    calendar, so no booking was created: 2026-09-14, 2026-09-15. Move
                                    the window, or free the nights from the Maat console.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: conflict
                                conflicts: ['2026-09-14', '2026-09-15']
                        sandbox_only:
                            summary: Your own LIVE listing, under a sandbox key
                            value:
                                type: https://developer.maatstays.wtf/errors/sandbox-only
                                title: Sandbox inventory only
                                status: 409
                                detail: >-
                                    That listing is in your live workspace. The fixture only ever
                                    creates bookings on sandbox inventory — use the matching listing
                                    from your sandbox workspace.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: sandbox_only
                        inventory_not_supported:
                            summary: Not a stay listing
                            value:
                                type: https://developer.maatstays.wtf/errors/inventory-not-supported
                                title: Inventory kind not supported
                                status: 409
                                detail: >-
                                    That listing is an experience or a service. The sandbox booking
                                    fixture creates stays only.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: inventory_not_supported

        RulesForbidden:
            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.
                - `insufficient_scope` — the key was not issued with `rules:write`, or the request
                  names a `pricing.*` template and the key lacks `pricing:write`.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    examples:
                        insufficientScope:
                            summary: The key lacks pricing:write for a pricing.* template
                            value:
                                type: https://developer.maatstays.wtf/errors/insufficient-scope
                                title: Insufficient scope
                                status: 403
                                detail: This key was not issued with the `pricing: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

        HostBlockConflict:
            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: |
                Two different refusals share this status. **Branch on `code`, never on the
                status alone** — one is worth retrying with a narrower range and the other
                never is.

                `conflict` — the nights collide with a Maat booking or a live hold. **Nothing
                was written**: the whole range is refused rather than the part of it that was
                free, because a block you were told had applied is a block you would stop
                holding yourself. `conflicts` carries every colliding date. Re-push a range
                that excludes them, or leave the block off if the stay cannot be honoured.

                `inventory_not_supported` — the listing is an experience or a service, and
                calendar blocks are stays-only. Retrying cannot help; a listing does not
                change its inventory kind.

                `listing_pooled_unsupported` — the listing sells a room type over several
                rooms, and a host block here closes one unit. Nothing was written. Retrying
                cannot help.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    examples:
                        conflict:
                            summary: Nights already booked or held
                            value:
                                type: https://developer.maatstays.wtf/errors/conflict
                                title: Conflict
                                status: 409
                                detail: >-
                                    These nights are already booked or held on Maat, so the block was
                                    not applied: 2026-09-02, 2026-09-03. Nothing was written — the
                                    whole range was refused rather than part of it.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: conflict
                                conflicts: ['2026-09-02', '2026-09-03']
                        inventory_not_supported:
                            summary: Not a stay listing
                            value:
                                type: https://developer.maatstays.wtf/errors/inventory-not-supported
                                title: Inventory kind not supported
                                status: 409
                                detail: This listing is an experience, and calendar blocks are stays-only. Nothing was blocked.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: inventory_not_supported
                        listing_pooled_unsupported:
                            summary: A room type with several rooms
                            value:
                                type: https://developer.maatstays.wtf/errors/listing-pooled-unsupported
                                title: Listing sells a pool of rooms
                                status: 409
                                detail: >-
                                    This listing sells a room type over several rooms, and a host
                                    block closes one unit. Nothing was blocked.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: listing_pooled_unsupported

        PricingModeConflict:
            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: |
                Two different refusals share this status. **Branch on `code`, never on the
                status alone** — one is worth retrying after the host acts and the other never
                is.

                `pricing_mode_conflict` — another source prices this listing: a pricing tool
                connected in the Maat app. **This API does not name it.** Nothing was written,
                and nothing is half-applied. Release the other source from the Maat app, after
                which this succeeds.

                `inventory_not_supported` — the listing is an experience or a service, and a
                rate calendar prices NIGHTS. Retrying cannot help; a listing does not change its
                inventory kind.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    examples:
                        pricing_mode_conflict:
                            summary: Priced by another source
                            value:
                                type: https://developer.maatstays.wtf/errors/pricing-mode-conflict
                                title: Pricing mode conflict
                                status: 409
                                detail: >-
                                    This listing's price is set by another source, so these rates would
                                    never take effect. Nothing was written. The host can release the
                                    other source from the Maat app.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: pricing_mode_conflict
                        inventory_not_supported:
                            summary: Not a stay listing
                            value:
                                type: https://developer.maatstays.wtf/errors/inventory-not-supported
                                title: Inventory kind not supported
                                status: 409
                                detail: This listing is an experience, and the rate calendar prices NIGHTS. Nothing was written.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: inventory_not_supported

        QuickActionApplyConflict:
            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: |
                Two different refusals share this status. **Branch on `code`, never on the
                status alone.**

                `pricing_mode_conflict` — this listing's price is set by another source, so the
                write would never take effect. Nothing was written. It becomes applicable again
                if the host returns the listing to Maat pricing.

                `inventory_not_supported` — the listing is an experience or a service, and this
                contract is stays-only. Retrying cannot help.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    examples:
                        pricing_mode_conflict:
                            summary: Priced by another source
                            value:
                                type: https://developer.maatstays.wtf/errors/pricing-mode-conflict
                                title: Pricing mode conflict
                                status: 409
                                detail: This listing's price is set by an external pricing source, so quick actions on it would never take effect. Nothing was applied.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: pricing_mode_conflict
                        inventory_not_supported:
                            summary: Not a stay listing
                            value:
                                type: https://developer.maatstays.wtf/errors/inventory-not-supported
                                title: Inventory kind not supported
                                status: 409
                                detail: This listing is an experience, and quick actions are stays-only. Nothing was applied.
                                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                                code: inventory_not_supported

        BookingNotFound:
            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 booking in the workspace this key addresses. ONE answer for "it does not
                exist" and "it is not yours" — telling them apart would confirm the existence of
                another workspace's reservation.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/booking-not-found
                        title: Booking not found
                        status: 404
                        detail: No such booking in this workspace.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: booking_not_found

        ReviewNotFound:
            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 review in the workspace this key addresses. ONE answer for "it does not
                exist" and "it is not yours".
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/review-not-found
                        title: Review not found
                        status: 404
                        detail: No such review in this workspace.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: review_not_found

        DeliveryNotFound:
            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 delivery with that `delivery_id` — for you. ONE answer for "it does not
                exist", "it belongs to another recipient" and "it is past the retention window".
                Telling them apart would let you learn something about a delivery that was never
                yours.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/delivery-not-found
                        title: Delivery not found
                        status: 404
                        detail: No such delivery.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: delivery_not_found

        RotateSecretConflict:
            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 rotation was not performed. **Nothing was rotated** and your current secret is
                unchanged. Two codes share this status and they mean different things — branch on
                `code`:

                * `conflict` — there is no endpoint registered to rotate a secret for. Register
                  one with `PUT /webhooks/endpoint` first; the response to a first registration
                  carries the signing secret.
                * `rotation_in_progress` — an overlap window is still open, so a second rotation
                  is refused until it closes. `detail` names the instant.

                **Why the second one is a refusal.** We only ever sign with two secrets: your
                current one, and the previous one until its overlap closes. A second rotation
                inside that window would shift the pair by one and push the secret you are
                verifying with today out of the set — including for deliveries already queued —
                with nothing to tell you it had happened.

                This operation mints a value that exists in one response and nowhere else, so it
                cannot be idempotent, and the failure it has to survive is the ordinary one: a
                lost response, a proxy timeout, a retry you are not sure about. Refusing the
                retry is what makes the retry safe. Read a `rotation_in_progress` as proof that
                the call you are retrying already landed, and keep the secret it returned.

                If your secret has LEAKED, rotating again would not help — the leaked generation
                stays in the signing set as the previous one. Delete the endpoint and register it
                again instead.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/rotation-in-progress
                        title: Rotation already in progress
                        status: 409
                        detail: >-
                            A rotation is already in progress on this endpoint: we keep signing
                            with your previous secret until 2026-09-08T00:00:00.000Z. Nothing was
                            rotated and the secret from your last rotation is still the current
                            one. Rotate again after that time.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: rotation_in_progress

        RedriveConflict:
            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 delivery cannot be redriven right now. `detail` names which of three reasons:
                it is already queued, retrying or being sent; its body is gone (erased, or past
                the payload retention window); or your endpoint is not currently active. Nothing
                was changed.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/conflict
                        title: Conflict
                        status: 409
                        detail: >-
                            This delivery's body is no longer held, so there is nothing left to
                            redrive.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: conflict

        BookingStatusConflict:
            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 booking is not `pending`, so it cannot be confirmed or rejected. Nothing was
                changed.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/conflict
                        title: Conflict
                        status: 409
                        detail: >-
                            This booking is already confirmed. Only a pending booking can be
                            confirmed or rejected.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: conflict

        ReviewReplyConflict:
            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: |
                This review already has a workspace reply. One workspace reply per review over
                this API — edit it in the host app. Nothing was changed.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/conflict
                        title: Conflict
                        status: 409
                        detail: This review already has a reply. Nothing was changed.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: conflict

        RedriveBudgetExhausted:
            description: |
                **Two codes share this status here — branch on `code`.**

                `redrive_budget_exhausted` is this credential's own replay allowance, over a
                **rolling 24 hours**: it does not reset at midnight or any other fixed time, it
                frees up as the trailing window moves past an earlier replay. `Retry-After` is
                the wait until the oldest replay falls out of that window. We do not publish
                the figure — it is an operational setting we tune, not a contract.

                `rate_limited` is the shared hourly ceiling every contract call draws on, and
                means the same thing here as everywhere else.

                A refused redrive costs nothing either way: it spends no allowance and nothing
                about the delivery changes.
            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'
                    example:
                        type: https://developer.maatstays.wtf/errors/redrive-budget-exhausted
                        title: Redrive allowance exhausted
                        status: 429
                        detail: This credential has used its redrive allowance for the day.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: redrive_budget_exhausted

        TestEventConflict:
            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: |
                There is nothing to send the test to. `detail` names which of two reasons: no
                endpoint is registered, or the one that is registered is not `active` — it has not
                answered the challenge, or was suspended. Nothing was queued and no allowance was
                spent.
            content:
                application/problem+json:
                    schema:
                        $ref: '#/components/schemas/Problem'
                    example:
                        type: https://developer.maatstays.wtf/errors/conflict
                        title: Conflict
                        status: 409
                        detail: >-
                            There is no registered endpoint to send a test event to. Register one
                            with PUT /webhooks/endpoint and answer the challenge first.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: conflict

        TestEventBudgetExhausted:
            description: |
                **Two codes share this status here — branch on `code`.**

                `test_event_budget_exhausted` is this credential's own test-event allowance, over
                a **rolling 24 hours**: it does not reset at midnight, it frees up as the trailing
                window moves past an earlier test. `Retry-After` is the wait until the oldest one
                falls out of that window. It is a separate counter from the redrive allowance —
                recovering a backlog and exercising a handler never compete. We do not publish the
                figure; it is an operational setting we tune, not a contract.

                `rate_limited` is the shared hourly ceiling every contract call draws on, and
                means the same thing here as everywhere else.

                A refused test costs nothing either way: it spends no allowance and queues
                nothing.
            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'
                    example:
                        type: https://developer.maatstays.wtf/errors/test-event-budget-exhausted
                        title: Test-event allowance exhausted
                        status: 429
                        detail: This credential has used its test-event allowance for the last 24 hours.
                        instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                        code: test_event_budget_exhausted
