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

# Send yourself a test event

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




## OpenAPI

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

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

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

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


    ```

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

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

    ```


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

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

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

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


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

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

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

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

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

    page shows. See the data boundary guide at

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


    # Contract status — read this before you build


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

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

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

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

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

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

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

    response.


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

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

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

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

    delivered on in its own description instead.


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


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

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

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


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

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

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

    against it now; nothing will change shape underneath you.


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

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

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

    succeeded is the one failure you could not detect.


    # Authentication


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

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

    hour.


    ```bash

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

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

    #    the key itself, never from the request.

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

    # → 200

    # {

    #   "access_token": "eyJhbGciOiJIUzI1NiIs…",

    #   "token_type": "Bearer",

    #   "expires_in": 900,

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

    #   "sandbox": true,

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

    # }


    # 2. Call a contract with the token.

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


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

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

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

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


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

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


    # Sandbox


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

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

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

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

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

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

    public listing page, or in any notification.


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

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

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


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

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


    # Idempotency


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

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

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

    can be forgotten.


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

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


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

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

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

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

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


    # Conventions


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

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

    on the wire. The guides write the full path.


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

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

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


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

    closed list:


    | Verb | When |

    | :--- | :--- |

    | `get` | one resource |

    | `list` | a collection |

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

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

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

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

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


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

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

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

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

    Nothing else invents a verb.


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

    `RotateWebhookSecretRequest` and `RedriveWebhookDeliveryRequest` exist so
    that the

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

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


    # Errors


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

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


    ```json

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

    ```


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

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

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

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


    | `code`                     | Status | What it
    means                                                        |

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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


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

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

    Distinguishing them would let anyone enumerate our customers.


    # Rate limits


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

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

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

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


    Every response carries:


    | Header                |
    Meaning                                              |

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

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

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

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

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


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

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


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

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

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

    on those responses like every other.


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

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

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

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


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

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


    # Webhooks


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

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


    ```

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

    X-Signature-Timestamp: 1788000000

    ```


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

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

    than five minutes old.


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

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

    address whose certificate chains to a publicly trusted root.


    # Example values


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

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

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

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

    typos and not endpoints to call.


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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

      settlement currency, and never converted or added across currencies.


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

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

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

      deliver.
  - name: booking-notifications
    description: >
      Where Maat delivers a booking on one of your properties, and the payloads
      it sends.
paths:
  /webhooks/endpoint/test:
    post:
      tags:
        - webhooks
      summary: Send yourself a test event
      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.**
      operationId: sendTestWebhookEvent
      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'
      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)
            }
components:
  schemas:
    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.
    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
    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.
    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'
    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
    ProblemCode:
      type: string
      title: ProblemCode
      description: >
        The contract of an error. Branch on this, never on `type`, `title` or
        `detail`.


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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


        - `workspace_scope_mismatch` — the token's environment does not match
        the
          workspace it addresses (a sandbox token against a production workspace, or the
          reverse; one answer also covers a workspace that does not resolve).
        - `insufficient_scope` — the key behind the token was not issued with
        the scope
          this operation requires (`x-maat-scope` on the operation). `detail` names the
          missing scope; ask whoever issued the key for one that carries it.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            workspaceScopeMismatch:
              summary: The token's environment does not match the workspace
              value:
                type: >-
                  https://developer.maatstays.wtf/errors/workspace-scope-mismatch
                title: Workspace scope mismatch
                status: 403
                detail: This token is not valid for the requested workspace.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: workspace_scope_mismatch
            insufficientScope:
              summary: The key lacks this operation's scope
              value:
                type: https://developer.maatstays.wtf/errors/insufficient-scope
                title: Insufficient scope
                status: 403
                detail: >-
                  This key was not issued with the `catalog:write` scope. Ask
                  whoever issued it to issue a key that carries it.
                instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
                code: insufficient_scope
    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
    InternalError:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: Our fault. Retry an idempotent request.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: https://developer.maatstays.wtf/errors/internal-error
            title: Internal error
            status: 500
            detail: The request could not be completed.
            instance: urn:maat:request:3b1f7a90-0d4c-4d4e-9b2a-6c1f0a2e5d33
            code: internal_error
    NotEnabled:
      headers:
        X-Correlation-Id:
          $ref: '#/components/headers/CorrelationId'
        RateLimit-Policy:
          $ref: '#/components/headers/RateLimitPolicy'
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
      description: >
        **What every contract endpoint returns against a production workspace.**
        The

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

        written, nothing was read and nothing will be delivered.


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

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

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

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

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

        situations that a caller acts on identically:


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

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

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

        `POST /token`. Send it as `Authorization: Bearer <access_token>`.


        Tokens live for fifteen minutes by default and never for more than an
        hour.

        Mint one per batch of work, not one per request.


        The token carries the workspace and the environment its key was issued
        for. You

        cannot change either by asking: the exchange ignores everything in the
        request

        body for exactly that reason.


        It also carries the key's **scopes** (`scope`, a list). Every operation
        names a

        scope as `x-maat-scope`, and a token whose key lacks it is refused with

        `403 insufficient_scope`, naming the scope. Scopes are fixed when a key
        is

        issued and are re-read from the key on every request, so a key narrowed
        today is

        narrowed on its next call — the token's `scope` is what the key held at
        exchange

        time, for your own reading, never what authorizes a request.


        **`x-maat-scope` is the floor, and on the quick-action operations it is
        not the

        whole requirement.** `PUT` and `DELETE
        /listings/{listing_id}/quick-actions/{template_key}`

        carry `rules:write`, and additionally require **`pricing:write` when

        `template_key` begins `pricing.`** — the capability depends on the
        template in

        the path, because a cleaning fee changes what a guest pays and a minimum
        stay

        does not. The refusal is the same `403 insufficient_scope`, naming

        `pricing:write`. The two READS apply the same requirement without
        erroring:

        `GET /listings/{listing_id}/quick-actions` still lists the `pricing.*`
        rows but

        withholds their `applied` state, and `GET /listings/{listing_id}/rules`
        omits the

        pricing rules entirely and reports `pricing_withheld` in `warnings`.
        These four

        are the only operations in this document whose requirement is not
        exactly their

        `x-maat-scope`, and each of them says so machine-readably:

        `x-maat-scope-additional` lists the second scope it can require and

        `x-maat-scope-additional-when` says what happens without it.


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

        API key, mint tokens from it.

````

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