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

# Prove the endpoint is yours

> Delivered **during registration**, and again on every re-registration, to the URL you gave `PUT /v1/webhooks/endpoint`.



## OpenAPI

````yaml openapi/openapi.yaml webhook endpoint.challenge
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: {}
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.

````

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