> ## 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`. Nothing real is ever sent to an endpoint
that has not answered this.

Answer `2xx` with a JSON body echoing the same `challenge` string back:

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

Anything else leaves the endpoint `pending_verification` and no booking
notification is sent. Register again to retry.

### It is signed exactly like a real delivery

Same headers, same scheme. That is deliberate: your verification code is exercised
here, before a guest's arrival depends on it.

Verify the signature over the **raw request body**, before any JSON parsing:

```
expected = HMAC_SHA256(secret, "v1:" + X-Signature-Timestamp + ":" + raw_body)
```

compared in hex, in constant time, against `X-Signature`, with
`X-Signature-Timestamp` inside five minutes of now. `JSON.parse` followed by
`JSON.stringify` does not round-trip byte-identically — key order, whitespace and
`1.0` → `1` all move — so re-serialising the body will make every signature look
wrong.

Respond quickly: we wait five seconds, inside your own registration request.

Treat the challenge value as single-use. It is not a credential and it is not
reused, so nothing needs to store it.




## OpenAPI

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


    ```

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

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

    ```


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

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

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

    their right to erasure and you have records to delete.


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

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


    # Contract status — read this before you build


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

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

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

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

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

    Nothing is written, and nothing is delivered.


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


    That is deliberate, and it is the honest version of a pre-release API. A
    fabricated `200`

    would tell you a calendar block was applied — so you would stop holding
    those nights on

    your side, and the next guest would book a room that is already occupied. It
    would tell

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

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


    What IS live everywhere: authentication, authorization, the sandbox
    boundary, rate

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

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

    against it now; nothing will change shape underneath you.


    One thing you can only exercise in the sandbox is a **conflict**. If nights
    you push are

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

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

    succeeded is the one failure you could not detect.


    # Authentication


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

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

    hour.


    ```bash

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

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

    #    the key itself, never from the request.

    curl -X POST https://maatstays.wtf/v1/token \
      -H "Authorization: Bearer maat_partner_a1b2c3d4e5f60718_REPLACE_WITH_YOUR_SECRET"

    # → 200

    # {

    #   "access_token": "eyJhbGciOiJIUzI1NiIs…",

    #   "token_type": "Bearer",

    #   "expires_in": 900,

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

    #   "sandbox": true

    # }


    # 2. Call a contract with the token.

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


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

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

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

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

    other response in this API is one.


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

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


    # Sandbox


    Every key is issued for exactly one Maat workspace and is either a sandbox
    key or a

    production key. A sandbox workspace is a real tenancy with real endpoints
    and real

    create-then-read loops, and everything in it is kept off every public
    surface: it does not

    appear in search, on the map, in the guest feed, on a public listing page,
    or in any

    notification.


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

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

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


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

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


    # Idempotency


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

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

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

    can be forgotten.


    Your identifiers are unique to you. Two partners can use the same
    `provider_id` for

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


    # Errors


    Every response outside the token endpoint uses `application/problem+json`

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


    ```json

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

    ```


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

    contract; the rest is documentation.


    | `code`                     | Status | What it
    means                                                        |

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

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

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

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

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

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

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

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

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

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

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


    `instance` is an occurrence identifier, not a URL. Quote it when you contact
    us.


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

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

    Distinguishing them would let anyone enumerate our customers.


    # Rate limits


    600 requests an hour per API key across all contract endpoints. Every
    response carries:


    | Header                | Meaning                                     |

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

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

    | `RateLimit-Limit`     | The quota                                   |

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

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


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


    # Webhooks


    Booking notifications and deletion requests are delivered to one endpoint
    you register

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


    ```

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

    X-Signature-Timestamp: 1788000000

    ```


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

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

    than five minutes old.


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

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

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

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

      how you confirm you acted on one.
paths: {}
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.


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

        API key, mint tokens from it.

````