openapi: 3.1.0
info:
  title: HomeDash Public API
  version: 1.0.0
  summary: Read-and-write access to your HomeDash portfolio (properties, contracts, rent, maintenance) and outbound webhooks for the 9 most useful property events.
  description: |
    The HomeDash Public API lets external tools, AI assistants, and automation
    platforms (Zapier, Make, n8n, custom scripts) read your portfolio data and
    perform a tightly scoped set of write actions on your behalf.

    ## Quick links
    - [Developer Dashboard (generate keys, manage webhooks)](/landlord-dashboard/developer)
    - [Quickstart guide](/developer-docs/quickstart)
    - [Authentication](/developer-docs/authentication)
    - [Error reference](/developer-docs/errors)
    - [Webhook reference](/developer-docs/webhooks)
    - [Zapier setup](/developer-docs/zapier)
    - [Claude AI setup](/developer-docs/claude)
    - [Rent invoices via API](/developer-docs/rent-invoices)

    ## Base URL

    All endpoints are served from `/api/public/v1` on your HomeDash host
    (for example, `https://app.homedash.co.uk/api/public/v1`).

    ## Authentication

    Authenticate with either:

    - `Authorization: Bearer <api-key>` (recommended), or
    - `x-api-key: <api-key>`

    Keys are generated in the Developer Dashboard. Keys are prefixed:

    - `hd_live_…` operates on real data.
    - `hd_sandbox_…` returns real data for `GET` endpoints but never writes
      to the database for `POST` / `PATCH` endpoints (the response is a
      mock with `"sandbox": true`).

    Every response carries the `X-HomeDash-Mode` header set to `live` or
    `sandbox` so integrators can detect the mode at runtime.

    ## Plan requirements

    | Capability | Plan required |
    | --- | --- |
    | List/read endpoints (`GET *`, including `GET /rent/{id}/invoice/pdf`, `GET /events`, `GET /team`, `GET /subscription`, `GET /mortgages`, `GET /tenants`, `GET /contacts`, `GET /chat/messages`, `GET /maintenance/issues`, `GET /maintenance/inspections`) | Portfolio or Enterprise |
    | Write endpoints (`POST /rent/{id}/invoice/email`, `POST /chat/messages`, maintenance issues/inspections CRUD writes, tenant CRUD writes, contact CRUD writes) | Enterprise |
    | Webhooks (delivery to your endpoint) | Portfolio or Enterprise |

    ## Rate limits

    Rate limits are enforced per API key on a sliding 60-second and 24-hour
    window. Every response includes `X-RateLimit-Limit`,
    `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (Unix seconds).

    | Plan | Reads/min | Writes/min | Reads/day | Writes/day |
    | --- | --- | --- | --- | --- |
    | Essential (no API access) | — | — | — | — |
    | Professional (no API access) | — | — | — | — |
    | Portfolio | 100 | 30 | 10,000 | 3,000 |
    | Enterprise | 500 | 100 | 50,000 | 10,000 |

    Exceeding a limit returns `429 RATE_LIMIT_EXCEEDED` with a
    `Retry-After` header.

    ## Response envelope

    Successful responses always use:

    ```json
    { "data": ... , "meta": { "page": 1, "limit": 20, "total": 42 } }
    ```

    Errors always use:

    ```json
    { "error": "Human-readable message", "code": "MACHINE_READABLE_CODE" }
    ```

    See the [error reference](/developer-docs/errors) for the complete list
    of `code` values.

    ## Webhooks

    Webhooks are not managed via this API yet — register them in the
    Developer Dashboard. The webhook delivery contract (HMAC signing,
    headers, payload shape) is documented in the
    [webhook reference](/developer-docs/webhooks).
  contact:
    name: HomeDash Support
    url: mailto:support@homedash.co.uk
    email: support@homedash.co.uk
  license:
    name: Proprietary
    url: https://homedash.co.uk/terms
servers:
  - url: https://app.homedash.co.uk/api/public/v1
    description: Production
  - url: http://localhost:3000/api/public/v1
    description: Local development
tags:
  - name: Properties
    description: Read your property portfolio.
  - name: Contracts
    description: |
      Read tenancy agreements and contract lifecycle data. Current property
      occupants are defined by each Active contract's `mainTenantId` and
      `additionalTenantIds` — not by tenant `propertyId` or tenant status alone.
  - name: Rent
    description: Read scheduled, paid, and overdue rent payments.
  - name: Maintenance
    description: Update the status of maintenance issues.
  - name: Tasks
    description: Create maintenance issues programmatically.
  - name: Calendar
    description: |
      Read-only aggregated calendar feed (compliance, rent, inspections,
      maintenance, contracts, and related reminders) for operational queries.
  - name: Mortgages
    description: |
      List and manage mortgage records (lender, balance, rate, monthly payment,
      product end dates, linked properties). Writes require Enterprise and a
      `read_write` API key; sandbox keys return mocked write responses.
  - name: Team
    description: |
      Read-only team roster for the landlord account, including the owner and
      current member roles/statuses.
  - name: Subscription
    description: |
      Read-only account subscription summary including current plan, property
      limits/usage, storage usage, and next billing date when available.
  - name: Contacts
    description: |
      List and manage business contacts (contractors, agents, services). Detail
      GET supports optional paginated work history (`includeHistory=true`) from
      maintenance issues assigned to the contact. Writes require Enterprise and a
      `read_write` API key; sandbox keys return mocked write responses.
  - name: Chat
    description: |
      Retrieve and send landlord–tenant chat messages. Messages are sent as the
      landlord account owner. Filter by `conversationId` (chat UUID) or `propertyId`.
      POST uses multipart form data (text and/or file attachment, 10MB max).
      WebSocket realtime is not part of this API. Writes require Enterprise and
      `read_write`; sandbox POST returns a mock message without DB writes.
  - name: Tenants
    description: |
      List and manage tenant records (contact details, compliance fields, property
      assignment). `POST` sends a portal invitation when the tenant has no account.
      Writes require Enterprise and a `read_write` API key.

      Tenant `propertyId` and tenant status are **not** reliable signals for who
      currently lives at a property. Resolve current occupants from the property's
      Active contract (`mainTenantId` + `additionalTenantIds`), then fetch tenant
      details by ID.
  - name: Webhooks
    description: |
      Outbound HMAC-signed event notifications. Subscriptions are managed
      in the Developer Dashboard, not via this API. The objects below
      describe the payloads HomeDash will POST to your endpoint.

security:
  - bearerAuth: []

paths:
  /properties:
    get:
      tags: [Properties]
      summary: List properties
      description: |
        Returns a paginated list of properties owned by the landlord
        identified by the API key. The response shape is identical to
        `GET /properties/{id}` so integrators can treat list and detail
        results interchangeably.
      operationId: listProperties
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: status
          schema:
            type: string
            enum: [Occupied, Vacant, Maintenance]
          description: Filter by current property status.
        - in: query
          name: search
          schema:
            type: string
            minLength: 1
            maxLength: 200
          description: Case-insensitive contains match against the address.
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyListResponse'
              example:
                data:
                  - id: 5b1e07e0-1cb9-4f8c-a3e1-6b6f0a72e9f1
                    address: 12 Test Street, London, SW1A 1AA
                    shortName: 12 Test St
                    addressLine1: 12 Test Street
                    city: London
                    postcode: SW1A 1AA
                    propertyType: Flat
                    bedrooms: 2
                    bathrooms: 1
                    status: Occupied
                    rentAmount: 1200
                    tenantName: A. Tenant
                meta:
                  page: 1
                  limit: 20
                  total: 1
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Properties]
      summary: Create a property
      description: |
        Creates a new property for the landlord identified by the API key.
        Requires Enterprise plan and a `read_write` API key.

        `rentAmount`, `tenantName`, `compliance`, and contract-derived statuses are
        read-only on responses. Only `Maintenance` may be set via `status` on create.
        Property images must be uploaded separately via `POST /properties/{id}/image`.
      operationId: createProperty
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PropertyCreateRequest'
      responses:
        '201':
          description: Property created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyDetailResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /properties/{id}:
    get:
      tags: [Properties]
      summary: Get a single property
      operationId: getProperty
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyDetailResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [Properties]
      summary: Update a property
      description: |
        Partially updates a property. Requires Enterprise and `read_write` scope.
        Set `status` to `Maintenance` to flag maintenance, or `null` to clear the
        maintenance flag. `rentAmount` cannot be changed here (use contracts).
      operationId: patchProperty
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PropertyPatchRequest'
      responses:
        '200':
          description: Updated property.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyDetailResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    delete:
      tags: [Properties]
      summary: Delete a property
      description: |
        Soft-deletes a property. Fails with `409 CONFLICT` when an active, pending,
        or notice-served contract is linked. Requires Enterprise and `read_write` scope.
      operationId: deleteProperty
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Property deleted.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [deleted, id]
                    properties:
                      deleted: { type: boolean, enum: [true] }
                      id: { type: string, format: uuid }
                      sandbox: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /properties/{id}/image:
    post:
      tags: [Properties]
      summary: Upload property image
      description: |
        Uploads a property photo (multipart `file`, max 10MB, `image/*` only) and
        links it to the property. Storage quota is enforced. Requires Enterprise and
        `read_write` scope. Returns the updated property with a public `image` URL.
      operationId: uploadPropertyImage
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: Image file (max 10MB).
      responses:
        '200':
          description: Image uploaded and property updated.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyDetailResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /properties/{id}/handbook:
    get:
      tags: [Properties]
      summary: Property handbook for a tenant welcome pack
      description: |
        Everything needed to build a tenant welcome pack for one property, in a
        single call: access and refuse details, meter locations and supply
        numbers, current suppliers, and the opening reading on each meter.

        This composes several tables on purpose, so integrators never need to
        know that meter locations belong to the meter and supplier names to the
        supply account. If a fact moves internally, this response does not
        change shape.

        Supplier **account numbers are not included**. A welcome pack is handed
        to a tenant, and the account number belongs to the landlord; the
        supplier name is what a tenant needs to open their own account.

        `openingReading` is the most recent check-in or changeover reading on
        each meter. A changeover counts because it is the incoming tenancy's
        check-in - one visit serving both sides of a handover.
      operationId: getPropertyHandbook
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Property handbook.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      propertyId: { type: string, format: uuid }
                      knowledge:
                        type: object
                        nullable: true
                        description: |
                          Null when nothing has been recorded for the property
                          yet, which is the normal state until someone has been
                          round it. Not an error.
                      completeness:
                        type: object
                        properties:
                          filled: { type: integer, example: 6 }
                          total: { type: integer, example: 14 }
                          missing:
                            type: array
                            items: { type: string }
                      meters:
                        type: array
                        items:
                          type: object
                          properties:
                            utilityType:
                              type: string
                              enum: [electricity, gas, water, heat_network, oil]
                            locationDescription:
                              type: string
                              nullable: true
                              example: Cupboard under the stairs
                            unit:
                              type: string
                              enum: [kWh, m3, ft3, litres]
                              description: |
                                `ft3` means the imperial dial unit, which is one
                                HUNDRED cubic feet, not one - that is how a UK
                                gas bill converts it. Reading an imperial meter
                                as metric understates consumption by nearly two
                                thirds.
                            mpan: { type: string, nullable: true }
                            mprn: { type: string, nullable: true }
                            serialNumber: { type: string, nullable: true }
                            openingReading:
                              type: object
                              nullable: true
                              properties:
                                readingDate: { type: string, format: date }
                                registers:
                                  type: array
                                  items:
                                    type: object
                                    properties:
                                      register:
                                        type: string
                                        enum: [single, day, night, export, heat]
                                      value: { type: number }
                      suppliers:
                        type: array
                        items:
                          type: object
                          properties:
                            utilityType: { type: string }
                            supplierName: { type: string, example: Octopus Energy }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /properties/{id}/documents:
    get:
      tags: [Properties]
      summary: List property documents
      description: |
        Lists compliance and tenancy documents attached to a property (not
        contract-scoped tenancy documents). Optional `expiringWithinDays`
        returns documents with an `expiryDate` on or before today (UTC) plus
        that many days (includes already-expired certificates). Each item
        includes a time-limited `downloadUrl` (1 hour). See
        [Error codes](/developer-docs/errors).
      operationId: listPropertyDocuments
      parameters:
        - $ref: '#/components/parameters/UuidPath'
        - in: query
          name: expiringWithinDays
          description: |
            When set, only documents with `expiryDate` not null and
            `expiryDate <= todayUtc + N` calendar days.
          schema:
            type: integer
            minimum: 0
            maximum: 3650
      responses:
        '200':
          description: Property documents.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyDocumentListResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Properties]
      summary: Upload property document
      description: |
        Uploads a document against a property (multipart `file`, `name`, `type`,
        optional `expiryDate`). Landlord storage quota is enforced (same as the
        dashboard). Requires Enterprise and `read_write` scope. See
        [Error codes](/developer-docs/errors).
      operationId: uploadPropertyDocument
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, name, type]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                  minLength: 1
                  maxLength: 500
                type:
                  $ref: '#/components/schemas/PropertyDocumentType'
                expiryDate:
                  type: string
                  format: date
                  description: Optional expiry (YYYY-MM-DD) for certificates and contracts.
      responses:
        '201':
          description: Document created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertyDocumentDetailResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /properties/{id}/documents/{docId}:
    delete:
      tags: [Properties]
      summary: Delete property document
      description: |
        Soft-deletes a property-level document. Storage objects are retained.
        Requires Enterprise and `read_write` scope. See
        [Error codes](/developer-docs/errors).
      operationId: deletePropertyDocument
      parameters:
        - $ref: '#/components/parameters/UuidPath'
        - in: path
          name: docId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Document deleted.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [deleted, id]
                    properties:
                      deleted: { type: boolean, enum: [true] }
                      id: { type: string, format: uuid }
                      sandbox: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /contracts:
    get:
      tags: [Contracts]
      summary: List contracts
      description: |
        Returns paginated contracts for the landlord tied to the API key.
        Filter by `status`, `propertyId`, `tenantId` (main tenant), and/or `search` (tenancy reference).

        ## Current occupants for a property

        To determine who currently lives at a property, resolve occupants from the
        property's **active contract** — do not rely on tenant `propertyId` or tenant
        status. A tenant's `propertyId` may remain set after they move out, and status
        alone does not mean they still occupy the property.

        1. Call `GET /contracts?propertyId={uuid}&status=Active`.
        2. Take `mainTenantId` from the active contract.
        3. Add every ID in `additionalTenantIds` (if present).
        4. Treat that set as the property's current occupants.
        5. When you need names or contact details, call `GET /tenants/{id}` for each
           occupant ID.

        Example:

        ```bash
        curl -s "https://app.homedash.co.uk/api/public/v1/contracts?propertyId=5b1e07e0-1cb9-4f8c-a3e1-6b6f0a72e9f1&status=Active" \
          -H "Authorization: Bearer YOUR_KEY"
        ```

        ```json
        {
          "data": [
            {
              "id": "c1111111-1111-4111-8111-111111111111",
              "status": "Active",
              "propertyId": "5b1e07e0-1cb9-4f8c-a3e1-6b6f0a72e9f1",
              "mainTenantId": "t2222222-2222-4222-8222-222222222222",
              "additionalTenantIds": [
                "t3333333-3333-4333-8333-333333333333"
              ]
            }
          ],
          "meta": { "page": 1, "limit": 20, "total": 1, "hasNextPage": false }
        }
        ```

        Current occupants for that property are then
        `t2222222-2222-4222-8222-222222222222` and
        `t3333333-3333-4333-8333-333333333333`. Fetch each with
        `GET /tenants/{id}` for full profile fields.
      operationId: listContracts
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: status
          schema:
            type: string
            enum: [Active, Pending, 'Notice Served', Ended]
        - in: query
          name: propertyId
          schema: { type: string, format: uuid }
        - in: query
          name: tenantId
          schema: { type: string, format: uuid }
        - in: query
          name: search
          description: Case-insensitive contains match against the tenancy reference.
          schema: { type: string, minLength: 1, maxLength: 200 }
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContractListResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Contracts]
      summary: Create a contract
      description: |
        Creates a tenancy contract. Requires Enterprise and `read_write` scope.
        Status is derived from start/end dates except when `status` is set to
        `Notice Served`. Assignment emails are sent to tenants (same as the dashboard).
        Contract document uploads are not supported on this endpoint.
      operationId: createContract
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContractCreateRequest'
      responses:
        '201':
          description: Contract created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContractDetailResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /contracts/{id}:
    get:
      tags: [Contracts]
      summary: Get a single contract
      operationId: getContract
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContractDetailResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [Contracts]
      summary: Update a contract
      description: |
        Partially updates a contract. Status is recalculated from dates unless
        `Notice Served` is set. Changing tenants may trigger assignment emails.
      operationId: patchContract
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContractPatchRequest'
      responses:
        '200':
          description: Updated contract.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContractDetailResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    delete:
      tags: [Contracts]
      summary: Delete a contract
      description: Soft-deletes a contract. Requires Enterprise and `read_write` scope.
      operationId: deleteContract
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Contract deleted.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [deleted, id]
                    properties:
                      deleted: { type: boolean, enum: [true] }
                      id: { type: string, format: uuid }
                      sandbox: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /contracts/{id}/documents:
    get:
      tags: [Contracts]
      summary: List contract documents
      description: |
        Lists tenancy documents attached to a contract. Each item includes a
        time-limited `downloadUrl` (1 hour). See
        [Error codes](/developer-docs/errors).
      operationId: listContractDocuments
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Contract documents.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContractDocumentListResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Contracts]
      summary: Upload contract document
      description: |
        Uploads a tenancy document against a contract (multipart `file`, `name`,
        `type`, optional `expiryDate`). Landlord storage quota is enforced (same
        as the dashboard). Requires Enterprise and `read_write` scope. See
        [Error codes](/developer-docs/errors).
      operationId: uploadContractDocument
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, name, type]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                  minLength: 1
                  maxLength: 500
                type:
                  $ref: '#/components/schemas/PropertyDocumentType'
                expiryDate:
                  type: string
                  format: date
                  description: Optional expiry (YYYY-MM-DD).
      responses:
        '201':
          description: Document created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContractDocumentDetailResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /contracts/{id}/documents/{docId}:
    delete:
      tags: [Contracts]
      summary: Delete contract document
      description: |
        Soft-deletes a contract-scoped document. Storage objects are retained.
        Requires Enterprise and `read_write` scope. See
        [Error codes](/developer-docs/errors).
      operationId: deleteContractDocument
      parameters:
        - $ref: '#/components/parameters/UuidPath'
        - in: path
          name: docId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Document deleted.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [deleted, id]
                    properties:
                      deleted: { type: boolean, enum: [true] }
                      id: { type: string, format: uuid }
                      sandbox: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /rent:
    get:
      tags: [Rent]
      summary: List rent payments
      description: |
        Lists scheduled, paid, and overdue rent payments. Combine with the
        `status` query parameter to power overdue dashboards or accounting
        reconciliation.
      operationId: listRentPayments
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: status
          schema:
            type: string
            enum: [Paid, Pending, Overdue]
        - in: query
          name: contractId
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RentListResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Rent]
      summary: Create a rent payment
      description: |
        Creates a manual rent payment record. Requires an Enterprise API key with
        `read_write` scope. In sandbox mode (`X-HomeDash-Mode: sandbox` or a sandbox
        API key), no database write is performed.

        `propertyId`, `tenantId`, and `contractId` must refer to the same contract
        for the authenticated landlord. `period` is normalized (extra whitespace
        collapsed). Duplicate `(contractId, period)` returns `409 CONFLICT`.

        When `status` is `Paid` and `amountReceived` is omitted, it defaults to
        `amount`. When `datePaid` is omitted, it defaults to today's date (UTC).

        Typical reconciliation flow: create a `Pending` record, then
        `PATCH /rent/{id}` with `status`, `amountReceived`, and `datePaid`.
      operationId: createRentPayment
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateRentRequest' }
      responses:
        '201':
          description: Created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RentDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /rent/{id}:
    patch:
      tags: [Rent]
      summary: Update a rent payment
      description: |
        Partially updates a rent payment for reconciliation (mark paid, adjust
        amount received, payment date, period window, or due date). Requires
        Enterprise `read_write`. At least one field is required. `amount` and
        contract/property/tenant identifiers cannot be changed via this endpoint.

        Sandbox mode returns a synthetic `RentPayment` without updating the database.
      operationId: patchRentPayment
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatchRentRequest' }
      responses:
        '200':
          description: Updated.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RentDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /rent/{id}/invoice/pdf:
    get:
      tags: [Rent]
      summary: Download rent invoice PDF
      description: |
        Returns the same invoice or receipt PDF as the landlord dashboard for the
        given rent payment. Requires Portfolio or Enterprise plan and `read_only` or
        `read_write` scope.

        A dedicated per-landlord rate limit applies: **20 requests/minute**
        (`public_api.rent_invoice_pdf`), enforced before PDF generation.

        PDF bytes may be served from a short-lived server cache keyed by rent id,
        `updated_at`, and template version (15 minute TTL).

        Sandbox keys run the real PDF pipeline against deterministic sandbox data.
      operationId: getRentInvoicePdf
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Invoice PDF bytes.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /rent/{id}/invoice/email:
    post:
      tags: [Rent]
      summary: Email rent invoice to tenant
      description: |
        Generates the invoice PDF and emails it to the tenant (or optional
        `email` override). Requires Enterprise `read_write`.

        **Server-side deduplication:** at most one successful
        `invoice_email_sent` per rent payment via the public API. Concurrent or
        duplicate requests return `200` with the stored success payload (no second
        email).

        Optional `Idempotency-Key` header (max 128 chars) enables HTTP-level
        replay of identical success responses for 24 hours.

        After a successful live send, a `rent.invoice_sent` webhook event is
        written to the outbox (`status: pending`) and delivered asynchronously by
        the platform cron — never inline in this request.

        Dedicated rate limit: **10 requests/minute**
        (`public_api.rent_invoice_email`), enforced before database or Puppeteer work.

        Sandbox mode runs PDF generation and validation but does not send SMTP
        or enqueue webhooks (`sent: false`, `sandbox: true`).
      operationId: sendRentInvoiceEmail
      parameters:
        - $ref: '#/components/parameters/UuidPath'
        - in: header
          name: Idempotency-Key
          schema:
            type: string
            maxLength: 128
          description: Optional key for safe retries of identical success responses (24h TTL).
      requestBody:
        required: true
        description: |
          Send at least an empty JSON object (`{}`). Integrations such as Zapier must
          set `Content-Type: application/json` and must not leave the body blank — an
          empty body returns `400` with `Invalid JSON body`.
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendRentInvoiceEmailRequest' }
            example: {}
      responses:
        '200':
          description: Email sent (live) or sandbox preview (no SMTP).
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RentInvoiceEmailResponse' }
        '400':
          description: Missing tenant email.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              examples:
                tenantEmailMissing:
                  value:
                    error: Tenant email not found. Cannot send invoice.
                    code: TENANT_EMAIL_MISSING
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Dedup conflict or idempotency in progress.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /events:
    get:
      tags: [Calendar]
      summary: List calendar events
      description: |
        Read-only feed of calendar events for the landlord tied to the API key.
        Events are derived from properties, contracts, rent, maintenance,
        compliance, inspections, and related modules (not created via this API).

        When `from` and `to` are omitted, the server uses a default inclusive
        window of 7 days in the past through 90 days in the future (local dates).
        When both are provided, the range must not exceed 400 days.

        Filter by `propertyId` (UUID) and/or `type` (single value or comma-separated,
        e.g. `Inspection` or `Rent,Schedule`). Results are sorted by `date` ascending,
        then `id`, and paginated with `page` and `limit`.
      operationId: listCalendarEvents
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: from
          schema: { type: string, format: date }
          description: Inclusive start date (YYYY-MM-DD). Must be sent together with `to`.
        - in: query
          name: to
          schema: { type: string, format: date }
          description: Inclusive end date (YYYY-MM-DD). Must be sent together with `from`.
        - in: query
          name: propertyId
          schema: { type: string, format: uuid }
          description: Return only events for this property when the event carries a property id.
        - in: query
          name: type
          schema:
            type: string
            maxLength: 500
          description: >
            Comma-separated event types to include. Allowed values: Task, Property,
            Contract, Tenant, Rent, Compliance, Insurance, Schedule, Inspection.
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventsListResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /team:
    get:
      tags: [Team]
      summary: List team members
      description: |
        Returns the landlord account owner and current team members with a safe
        public shape: `name`, `email`, `role`, and `status`.
        Error codes are documented in [/developer-docs/errors](/developer-docs/errors).
      operationId: listTeamMembers
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TeamListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /compliance/summary:
    get:
      tags: [Compliance]
      summary: Portfolio compliance summary
      description: |
        Portfolio-wide compliance roll-up using one canonical severity model, so
        consumers don't re-derive their own "critical / due soon" ladders from
        the per-property `compliance.items`. Severity covers the statutory
        required items (GSC, EICR, EPC, and the landlord licence where a property
        requires one). Thresholds: `expired` (past due or required-but-missing),
        `critical` (≤ 30 days), `urgent` (≤ 60), `due_soon` (≤ 90), `ok` (> 90).
        Also returns an EPC band summary and a severity-sorted watch list.
        Error codes are documented in [/developer-docs/errors](/developer-docs/errors).
      operationId: getComplianceSummary
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      generatedAt: { type: string, format: date-time }
                      portfolio:
                        type: object
                        properties:
                          propertyCount: { type: integer }
                          requiredItemsTotal: { type: integer }
                          attentionCount: { type: integer }
                          worstSeverity:
                            type: string
                            nullable: true
                            enum: [expired, missing, critical, urgent, due_soon, ok]
                          counts:
                            type: object
                            properties:
                              expired: { type: integer }
                              missing: { type: integer }
                              critical: { type: integer }
                              due_soon: { type: integer }
                              urgent: { type: integer }
                              ok: { type: integer }
                      epc:
                        type: object
                        properties:
                          rated: { type: integer }
                          unrated: { type: integer }
                          cOrAbove: { type: integer }
                          belowC: { type: integer }
                          distribution:
                            type: object
                            additionalProperties: { type: integer }
                      attention:
                        type: array
                        items:
                          type: object
                          properties:
                            propertyId: { type: string }
                            propertyName: { type: string }
                            code: { type: string }
                            dueDate: { type: string, nullable: true }
                            daysUntil: { type: integer, nullable: true }
                            severity:
                              type: string
                              enum: [expired, missing, critical, urgent, due_soon, ok]
                      properties:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            name: { type: string }
                            address: { type: string, nullable: true }
                            status: { type: string, nullable: true }
                            epc:
                              type: object
                              properties:
                                rating: { type: string, nullable: true }
                                band: { type: string, nullable: true }
                                score: { type: integer, nullable: true }
                                renewal: { type: string, nullable: true }
                                compliant: { type: boolean, nullable: true }
                            items:
                              type: array
                              items:
                                type: object
                                properties:
                                  code: { type: string }
                                  required: { type: boolean }
                                  dueDate: { type: string, nullable: true }
                                  daysUntil: { type: integer, nullable: true }
                                  state: { type: string, enum: [missing, valid, expired] }
                                  severity:
                                    type: string
                                    enum: [expired, missing, critical, urgent, due_soon, ok]
                            worstSeverity:
                              type: string
                              nullable: true
                              enum: [expired, missing, critical, urgent, due_soon, ok]
                            counts:
                              type: object
                              properties:
                                expired: { type: integer }
                                missing: { type: integer }
                                critical: { type: integer }
                                due_soon: { type: integer }
                                urgent: { type: integer }
                                ok: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /rent/summary:
    get:
      tags: [Rent]
      summary: Portfolio rent summary
      description: |
        Charge counts by status plus expected / received / overdue amounts and a
        collection percentage, in one call — instead of paging `/rent?status=X`
        and reducing client-side (which silently truncated past 100 charges).
        Optional scope: `propertyId`, `startDate` / `endDate` (YYYY-MM-DD,
        matched against the charge due date); defaults to every charge, all time.
        Error codes are documented in [/developer-docs/errors](/developer-docs/errors).
      operationId: getRentSummary
      parameters:
        - { name: propertyId, in: query, required: false, schema: { type: string, format: uuid } }
        - { name: startDate, in: query, required: false, schema: { type: string, format: date } }
        - { name: endDate, in: query, required: false, schema: { type: string, format: date } }
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total: { type: integer }
                      counts:
                        type: object
                        properties:
                          paid: { type: integer }
                          overdue: { type: integer }
                          pending: { type: integer }
                      expectedAmount:
                        type: number
                        description: All rent billed in the period, including charges not yet due.
                      dueToDateAmount:
                        type: number
                        description: >-
                          The slice of expectedAmount whose due date has arrived. This is the
                          denominator of collectionPercent.
                      receivedAmount:
                        type: number
                        description: Cash received, including part-payments against charges still unpaid.
                      overdueAmount:
                        type: number
                        description: Still owed on overdue charges (outstanding, not the original amount).
                      collectionPercent:
                        type: number
                        description: >-
                          Collection rate: receivedAmount / dueToDateAmount, as a percentage to
                          one decimal place. Changed 2026-08-24 - it previously returned
                          paid-charge-count / total-charge-count, which disagreed with the
                          money-based rate documented elsewhere and shown in the app. The old
                          count-based value is now paymentsReceivedPercent.
                      billedProgressPercent:
                        type: number
                        description: >-
                          receivedAmount / expectedAmount. Progress through the period's total
                          billing. Equals collectionPercent once every charge in the period is due.
                      paymentsReceivedPercent:
                        type: number
                        description: Paid charge count / total charge count, as a percentage.
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /subscription:
    get:
      tags: [Subscription]
      summary: Get subscription summary
      description: |
        Returns the current plan summary for the landlord tied to the API key,
        including property limits/usage, storage usage, and the next billing
        date when available. This endpoint is read-only.
        Error codes are documented in [/developer-docs/errors](/developer-docs/errors).
      operationId: getSubscriptionSummary
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubscriptionResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /mortgages:
    get:
      tags: [Mortgages]
      summary: List mortgages
      description: |
        Returns paginated mortgages for the landlord tied to the API key.
        Filter by `propertyId` and/or `status`. Response `meta.totalOutstandingBalance`
        is the sum of balances across all mortgages matching the filters (not only the current page).
      operationId: listMortgages
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: propertyId
          schema: { type: string, format: uuid }
          description: Return only mortgages linked to this property.
        - in: query
          name: status
          schema:
            type: string
            enum: [Active, Pending, Redeemed]
          description: Filter by mortgage status.
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MortgageListResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Mortgages]
      summary: Create a mortgage
      description: |
        Creates a mortgage linked to one or more properties. Requires a `read_write`
        API key and Enterprise plan. Sandbox keys return a mock mortgage with `sandbox: true`.
      operationId: createMortgage
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateMortgageRequest' }
      responses:
        '201':
          description: Created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MortgageCreateResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /mortgages/{id}:
    get:
      tags: [Mortgages]
      summary: Get a mortgage
      operationId: getMortgage
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MortgageDetailResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [Mortgages]
      summary: Update a mortgage
      description: |
        Partial update. Requires `read_write` + Enterprise. Sandbox returns a mock
        mortgage with `sandbox: true`.
      operationId: patchMortgage
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatchMortgageRequest' }
      responses:
        '200':
          description: Updated.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MortgageDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    delete:
      tags: [Mortgages]
      summary: Delete a mortgage
      description: |
        Soft-deletes the mortgage. Requires `read_write` + Enterprise. Sandbox returns
        `{ "data": { "deleted": true, "sandbox": true } }` without modifying data.
      operationId: deleteMortgage
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Deleted (soft delete).
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MortgageDeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      description: |
        Returns paginated business contacts for the landlord tied to the API key.
        Filter by `search` (name, company, email) and/or `category` (must appear in
        the contact's categories array).
      operationId: listContacts
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: search
          schema: { type: string, minLength: 1, maxLength: 200 }
          description: Case-insensitive substring match on name, company, or email.
        - in: query
          name: category
          schema: { type: string, minLength: 1, maxLength: 100 }
          description: Return contacts whose categories array contains this value.
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactListResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Contacts]
      summary: Create a contact
      description: |
        Creates a business contact. Requires a `read_write` API key and Enterprise plan.
        Sandbox keys return a mock contact with `sandbox: true` without writing to the database.
        `categories` defaults to `["General"]` when omitted or empty. `avatarUrl` must be HTTPS.
      operationId: createContact
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateContactRequest' }
      responses:
        '201':
          description: Created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactCreateResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /contacts/{id}:
    get:
      tags: [Contacts]
      summary: Get a contact
      description: |
        Returns a single contact. Pass `includeHistory=true` to embed paginated work
        history (maintenance issues assigned to this contact). History items exclude
        private notes and attachments.
      operationId: getContact
      parameters:
        - $ref: '#/components/parameters/UuidPath'
        - in: query
          name: includeHistory
          schema: { type: boolean }
          description: When true, includes `workHistory` and history pagination meta.
        - in: query
          name: historyPage
          schema: { type: integer, minimum: 1, default: 1 }
        - in: query
          name: historyLimit
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - in: query
          name: historyFrom
          schema: { type: string }
          description: Filter history by issue date_created (inclusive lower bound).
        - in: query
          name: historyTo
          schema: { type: string }
          description: Filter history by issue date_created (inclusive upper bound).
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactDetailResponse'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [Contacts]
      summary: Update a contact
      description: |
        Partial update. Requires `read_write` + Enterprise. Nullable fields clear the value.
        Sandbox returns a mock contact with `sandbox: true`.
      operationId: patchContact
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatchContactRequest' }
      responses:
        '200':
          description: Updated.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    delete:
      tags: [Contacts]
      summary: Delete a contact
      description: |
        Soft-deletes the contact. Requires `read_write` + Enterprise. Sandbox returns
        `{ "data": { "deleted": true, "id": "...", "sandbox": true } }` without modifying data.
      operationId: deleteContact
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Deleted (soft delete).
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactDeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /chat/messages:
    get:
      tags: [Chat]
      summary: List chat messages
      description: |
        Returns messages for a conversation or for all chats linked to a property.
        Provide exactly one of `conversationId` or `propertyId`. Messages are in
        chronological order (oldest first). Use `before` (ISO timestamp) for pagination.
      operationId: listChatMessages
      parameters:
        - in: query
          name: conversationId
          schema: { type: string, format: uuid }
          description: Chat (conversation) UUID.
        - in: query
          name: propertyId
          schema: { type: string, format: uuid }
          description: Return messages from chats with this property context.
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - in: query
          name: before
          schema: { type: string, format: date-time }
          description: Return messages created before this timestamp.
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatMessageListResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Chat]
      summary: Send a chat message
      description: |
        Sends a message as the landlord account owner. Use `multipart/form-data`.
        Provide exactly one of `conversationId` or `tenantId` (creates or resolves the
        1:1 chat). Requires `read_write` + Enterprise. Sandbox returns a mock message.
      operationId: sendChatMessage
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                conversationId:
                  type: string
                  format: uuid
                tenantId:
                  type: string
                  format: uuid
                propertyId:
                  type: string
                  format: uuid
                contractId:
                  type: string
                  format: uuid
                text:
                  type: string
                  maxLength: 10000
                attachment:
                  type: string
                  format: binary
                  description: Optional file (max 10MB).
      responses:
        '201':
          description: Message sent.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChatMessageCreateResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /tenants:
    get:
      tags: [Tenants]
      summary: List tenants
      description: |
        Returns paginated tenants for the landlord tied to the API key.
        Filter by `status`, `search` (name, email, phone, property address), and/or `propertyId`
        (`property_id` is accepted as an alias).

        `propertyId` is applied via contract linkage and `current_property_id` resolution at the
        database layer (not limited by the search prefetch window). When `search` is set without
        `propertyId`, the API may prefetch up to 5000 relationship rows before filtering so
        pagination and `meta.total` stay correct — slower on very large portfolios.

        **Current occupants by property:** do **not** use this list filter (or tenant
        `propertyId` / status alone) as the source of truth. A tenant's `propertyId` may
        remain connected after they move out. Instead:

        1. `GET /contracts?propertyId={uuid}&status=Active`
        2. Collect `mainTenantId` and all `additionalTenantIds`
        3. Call `GET /tenants/{id}` for each occupant ID when you need names or contact details

        See `GET /contracts` for a full example.
      operationId: listTenants
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: status
          schema:
            type: string
            enum: [active, inactive, invited, suspended, ended]
        - in: query
          name: search
          schema: { type: string, maxLength: 200 }
        - in: query
          name: propertyId
          schema: { type: string, format: uuid }
          description: |
            Filter tenants associated with this property (via contract linkage or
            `current_property_id`). Not a reliable way to list **current occupants** —
            use `GET /contracts?propertyId={uuid}&status=Active` and read `mainTenantId` /
            `additionalTenantIds` instead.
        - in: query
          name: property_id
          schema: { type: string, format: uuid }
          description: Alias for `propertyId`.
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantListResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Tenants]
      summary: Create a tenant
      description: |
        Creates a tenant and sends a portal invitation email when the tenant does not
        yet have an account (`auth_uid`). Requires `read_write` + Enterprise.
        Response `meta.invitationSent` indicates whether an email was dispatched.
      operationId: createTenant
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateTenantRequest' }
      responses:
        '201':
          description: Created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantCreateResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /tenants/{id}:
    get:
      tags: [Tenants]
      summary: Get a tenant
      operationId: getTenant
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantDetailResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [Tenants]
      summary: Update a tenant
      description: |
        Partial update. Requires `read_write` + Enterprise.
        Profile fields (`prefix`, `firstName`, `lastName`, `phone`, `mobilePhone`) may be sent alone.
        `email` is not patchable (create-only); use GET to read the current email.
      operationId: patchTenant
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatchTenantRequest' }
      responses:
        '200':
          description: Updated.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    delete:
      tags: [Tenants]
      summary: Delete a tenant
      description: |
        Soft-deletes the tenant when business rules allow (unassigned, no active contracts).
        Requires `read_write` + Enterprise.
      operationId: deleteTenant
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Deleted (soft delete).
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantDeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /maintenance/issues:
    get:
      tags: [Maintenance]
      summary: List maintenance issues
      description: |
        Returns paginated maintenance issues for the landlord tied to the API key.
        Filter by `status`, `priority`, `propertyId`, `assignedContactId`, and/or
        `search` (title, description). `assignee` is also accepted as an alias for
        `assignedContactId`; if both are provided they must match.
        Error codes are documented in [/developer-docs/errors](/developer-docs/errors).
      operationId: listMaintenanceIssues
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: status
          schema:
            type: string
            enum: [New, In_Progress, Completed, Tenant_Request]
        - in: query
          name: priority
          schema:
            type: string
            enum: [Low, Medium, High]
        - in: query
          name: propertyId
          schema: { type: string, format: uuid }
        - in: query
          name: assignedContactId
          description: Filter by assigned contact ID.
          schema: { type: string, format: uuid }
        - in: query
          name: assignee
          description: Alias for `assignedContactId`. If both are sent, values must match.
          schema: { type: string, format: uuid }
        - in: query
          name: search
          schema: { type: string, maxLength: 200 }
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaintenanceIssueListResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Maintenance]
      summary: Create a maintenance issue
      description: |
        Creates a maintenance issue on an existing property. Requires `read_write` + Enterprise.
        Use this endpoint for full issue creation; `POST /tasks` remains a minimal create-only shortcut.

        **Title prefixes become category chips.** A `title` of the form
        `"<prefix>: <rest>"` is split when rendered in the HomeDash UI: the text before
        the first colon is shown as an uppercase category chip and the remainder as the
        title. `"Plumbing: kitchen tap dripping"` displays as a PLUMBING chip above
        "kitchen tap dripping". The stored value is unchanged - the split is presentation
        only, and there is no separate category field - so omit the colon if you do not
        want a chip.
      operationId: createMaintenanceIssue
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateMaintenanceIssueRequest' }
      responses:
        '201':
          description: Created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaintenanceIssueDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /maintenance/issues/{id}:
    get:
      tags: [Maintenance]
      summary: Get a maintenance issue
      description: |
        Returns issue detail including up to three photo attachments as signed URLs
        when present (no private notes; no `storage_path` in response).
        Error codes are documented in [/developer-docs/errors](/developer-docs/errors).
      operationId: getMaintenanceIssue
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaintenanceIssueDetailResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [Maintenance]
      summary: Update a maintenance issue
      description: |
        Partial update including direct `status` changes (DB enum values).
        Requires `read_write` + Enterprise. Error codes are documented in
        [/developer-docs/errors](/developer-docs/errors).
      operationId: patchMaintenanceIssue
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatchMaintenanceIssueRequest' }
      responses:
        '200':
          description: Updated.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaintenanceIssueDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    delete:
      tags: [Maintenance]
      summary: Delete a maintenance issue
      description: >-
        Soft-deletes the issue when status is `New`, `Tenant_Request`, or `Completed`.
        Requires `read_write` + Enterprise. Error codes are documented in
        [/developer-docs/errors](/developer-docs/errors).
      operationId: deleteMaintenanceIssue
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Deleted (soft delete).
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaintenanceIssueDeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /maintenance/inspections:
    get:
      tags: [Maintenance]
      summary: List property inspections
      description: |
        Returns paginated inspections for the landlord tied to the API key.
        Filter by `propertyId`, `status`, `inspectionType`, `search`, and/or `from`/`to` date range on `inspectionDate`.
      operationId: listMaintenanceInspections
      parameters:
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
        - in: query
          name: propertyId
          schema: { type: string, format: uuid }
        - in: query
          name: status
          schema:
            type: string
            enum: [draft, in_progress, completed, signed, archived]
        - in: query
          name: inspectionType
          schema:
            type: string
            enum: [routine, move_in, move_out, mid_term, emergency]
        - in: query
          name: search
          schema: { type: string, maxLength: 200 }
        - in: query
          name: from
          schema: { type: string, format: date }
        - in: query
          name: to
          schema: { type: string, format: date }
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InspectionListResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    post:
      tags: [Maintenance]
      summary: Create a property inspection
      description: Requires `read_write` + Enterprise. Creates inspection areas from default or landlord template.
      operationId: createMaintenanceInspection
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateMaintenanceInspectionRequest' }
      responses:
        '201':
          description: Created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InspectionDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /maintenance/inspections/{id}:
    get:
      tags: [Maintenance]
      summary: Get a property inspection
      description: |
        Returns inspection metadata and area list. Media is omitted unless
        `includeMedia=true` (signed URLs, 1 hour TTL; no `storage_path` in response).
      operationId: getMaintenanceInspection
      parameters:
        - $ref: '#/components/parameters/UuidPath'
        - name: includeMedia
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: When true, include signed media URLs on each area (detail GET only).
      responses:
        '200':
          description: Successful response.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InspectionDetailResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    patch:
      tags: [Maintenance]
      summary: Update a property inspection
      description: Partial update of inspection metadata. Requires `read_write` + Enterprise.
      operationId: patchMaintenanceInspection
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatchMaintenanceInspectionRequest' }
      responses:
        '200':
          description: Updated.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InspectionDetailResponse' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    delete:
      tags: [Maintenance]
      summary: Delete a property inspection
      description: Soft-deletes the inspection. Requires `read_write` + Enterprise.
      operationId: deleteMaintenanceInspection
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      responses:
        '200':
          description: Deleted (soft delete).
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InspectionDeleteResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /maintenance/{id}:
    patch:
      deprecated: true
      tags: [Maintenance]
      summary: Update maintenance status (deprecated)
      description: |
        **Deprecated.** Use `PATCH /maintenance/issues/{id}` instead.

        Updates only the status of a maintenance issue. Requires an API key
        with `read_write` scope and an Enterprise plan. In sandbox mode
        this endpoint returns a mocked response with `"sandbox": true`
        and does not modify any data.
      operationId: updateMaintenance
      parameters:
        - $ref: '#/components/parameters/UuidPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateMaintenanceBody'
            example:
              status: In_Progress
      responses:
        '410':
          description: Endpoint removed; use `PATCH /maintenance/issues/{id}`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }
              example:
                error: This endpoint is deprecated. Use PATCH /api/public/v1/maintenance/issues/{id}.
                code: ENDPOINT_DEPRECATED
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /tasks:
    post:
      tags: [Tasks]
      summary: Create a maintenance issue (minimal)
      description: |
        Minimal create-only shortcut. Prefer `POST /maintenance/issues` for full CRUD.
        Requires an API key with `read_write` scope and an Enterprise plan.
        The created issue starts with `status: New` and priority defaults to `Medium` if omitted.
      operationId: createTask
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTaskBody'
            example:
              title: Boiler not heating water
              propertyId: 5b1e07e0-1cb9-4f8c-a3e1-6b6f0a72e9f1
              priority: High
              description: Tenant reported no hot water since last night.
      responses:
        '201':
          description: Created.
          headers:
            X-HomeDash-Mode: { $ref: '#/components/headers/HomeDashMode' }
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaskResponse'
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: hd_live_… or hd_sandbox_…
      description: |
        Generate API keys in the Developer Dashboard. Pass them as
        `Authorization: Bearer <key>`. Keys are scoped (`read_only` or
        `read_write`) and moded (`live` or `sandbox`).

  parameters:
    Page:
      in: query
      name: page
      schema: { type: integer, minimum: 1, default: 1 }
      description: 1-indexed page number.
    Limit:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 500, default: 20 }
      description: Page size (max 500).
    UuidPath:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
      description: UUID of the resource.

  headers:
    HomeDashMode:
      description: Indicates whether the request was authenticated with a `live` or `sandbox` key.
      schema: { type: string, enum: [live, sandbox] }
    RateLimitLimit:
      description: The smaller of the per-minute and per-day limits for this key.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining inside the active sliding window.
      schema: { type: integer }
    RateLimitReset:
      description: Unix epoch seconds when the active window will reset.
      schema: { type: integer }
    RetryAfter:
      description: Seconds the caller should wait before retrying.
      schema: { type: integer }

  responses:
    ValidationError:
      description: Request body or query parameters failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          example:
            error: 'limit: Invalid input'
            code: VALIDATION_ERROR
    Unauthorized:
      description: |
        Authentication failed. Possible `code` values: `UNAUTHORIZED`
        (missing or invalid key) or `REVOKED_KEY` (the key has been
        revoked).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          example:
            error: Missing API key
            code: UNAUTHORIZED
    Forbidden:
      description: |
        The request was refused for policy reasons. Possible `code`
        values: `INSUFFICIENT_SCOPE` (read-only key on a write
        endpoint), `PLAN_UPGRADE_REQUIRED` (subscription plan does
        not include API access), `WRITE_REQUIRES_ENTERPRISE` (write
        endpoints require an Enterprise plan), `HTTPS_REQUIRED`
        (production traffic must use HTTPS).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          example:
            error: Upgrade to Enterprise to use write endpoints
            code: WRITE_REQUIRES_ENTERPRISE
    NotFound:
      description: |
        The resource does not exist or does not belong to the
        landlord identified by the API key. HomeDash deliberately
        returns 404 (not 403) on cross-tenant lookups to avoid
        confirming the resource exists.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          example:
            error: maintenance issue
            code: NOT_FOUND
    Conflict:
      description: |
        Request conflicts with current data (e.g. tenant active with another landlord,
        or delete blocked while assigned to a property or active contract).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          examples:
            tenantActiveElsewhere:
              value:
                error: This tenant is active with another landlord
                code: TENANT_ACTIVE_ELSEWHERE
            deleteBlocked:
              value:
                error: Cannot delete tenant while assigned to an active property
                code: CONFLICT
    PayloadTooLarge:
      description: |
        Uploaded file exceeds the allowed size (storage bucket object limit).
        See [Error codes](/developer-docs/errors) (`FILE_TOO_LARGE`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          example:
            error: File is too large to upload. Please choose a smaller file.
            code: FILE_TOO_LARGE
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          example:
            error: Rate limit exceeded
            code: RATE_LIMIT_EXCEEDED
    ServerError:
      description: Unexpected server error. The response intentionally omits stack traces and internal details.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          example:
            error: Internal server error
            code: SERVER_ERROR
    ServiceUnavailable:
      description: |
        Temporarily degraded service. Most commonly returned with
        `code: RATE_LIMIT_UNAVAILABLE` when the rate-limit datastore
        cannot be queried. The middleware fails closed in this case.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
          example:
            error: Rate limiting unavailable
            code: RATE_LIMIT_UNAVAILABLE

  schemas:
    PaginationMeta:
      type: object
      required: [page, limit, total, hasNextPage]
      properties:
        page: { type: integer, minimum: 1 }
        limit: { type: integer, minimum: 1, maximum: 500 }
        total: { type: integer, minimum: 0 }
        hasNextPage:
          type: boolean
          description: True when another page exists (`page * limit < total`).
    ApiError:
      type: object
      required: [error, code]
      properties:
        error: { type: string, description: Human-readable description of the error. }
        code:
          type: string
          description: Machine-readable code. See the error reference for the full list.
          enum:
            - UNAUTHORIZED
            - REVOKED_KEY
            - INSUFFICIENT_SCOPE
            - PLAN_UPGRADE_REQUIRED
            - WRITE_REQUIRES_ENTERPRISE
            - HTTPS_REQUIRED
            - RATE_LIMIT_EXCEEDED
            - RATE_LIMIT_UNAVAILABLE
            - VALIDATION_ERROR
            - BAD_REQUEST
            - NOT_FOUND
            - ENDPOINT_DEPRECATED
            - CONFLICT
            - FILE_TOO_LARGE
            - FORBIDDEN
            - SERVICE_UNAVAILABLE
            - SERVER_ERROR

    PropertyDocumentType:
      type: string
      enum:
        - Contract
        - GSC_Certificate
        - EICR_Certificate
        - EPC_Certificate
        - Landlord_Licence
        - Manual
        - Report
        - Guide
        - Info
        - Misc
    PropertyDocument:
      type: object
      required: [id, propertyId, name, displayTitle, type, uploadedAt, sizeBytes]
      properties:
        id: { type: string, format: uuid }
        propertyId: { type: string, format: uuid }
        name:
          type: string
          description: The stored filename. Use displayTitle when showing this to a person.
        displayTitle:
          type: string
          description: >-
            The document's human name: an explicit title if one was set,
            otherwise a generated one such as "Gas Safety Certificate - April 2026".
            Never a filename. Added 8 September 2026.
        type: { $ref: '#/components/schemas/PropertyDocumentType' }
        expiryDate:
          type: string
          format: date
          description: Optional certificate or contract expiry (YYYY-MM-DD).
        uploadedAt:
          type: string
          format: date-time
        sizeBytes: { type: integer, minimum: 0 }
        downloadUrl:
          type: string
          format: uri
          description: Signed URL valid for approximately 1 hour.
        sandbox: { type: boolean }
    PropertyDocumentListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PropertyDocument' }
    PropertyDocumentDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PropertyDocument' }
    ContractDocument:
      type: object
      required: [id, contractId, propertyId, name, displayTitle, type, uploadedAt, sizeBytes]
      properties:
        id: { type: string, format: uuid }
        contractId: { type: string, format: uuid }
        propertyId: { type: string, format: uuid }
        name:
          type: string
          description: The stored filename. Use displayTitle when showing this to a person.
        displayTitle:
          type: string
          description: >-
            The document's human name. See PropertyDocument.displayTitle.
            Added 8 September 2026.
        type: { $ref: '#/components/schemas/PropertyDocumentType' }
        expiryDate:
          type: string
          format: date
          description: Optional document expiry (YYYY-MM-DD).
        uploadedAt:
          type: string
          format: date-time
        sizeBytes: { type: integer, minimum: 0 }
        downloadUrl:
          type: string
          format: uri
          description: Signed URL valid for approximately 1 hour.
        sandbox: { type: boolean }
    ContractDocumentListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/ContractDocument' }
    ContractDocumentDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/ContractDocument' }
    Property:
      type: object
      required: [id, address, status, rentAmount, keyDates, compliance]
      properties:
        id: { type: string, format: uuid }
        address: { type: string }
        shortName: { type: string }
        addressLine1: { type: string }
        city: { type: string }
        postcode: { type: string }
        propertyType: { type: string }
        yearBuilt: { type: string }
        bedrooms: { type: number }
        bathrooms: { type: number }
        image:
          type: string
          description: >
            Public URL of the property's cover photo, when available. A property
            can hold multiple photos; this field always points at the first one
            (the cover) and is kept in step automatically, so its meaning is
            unchanged for consumers written before `images` existed. The full
            gallery is in `images`.
        images:
          type: array
          description: >
            The property's full photo gallery, ordered by `sortOrder`, on both
            the list and detail responses. Omitted entirely when the property
            has no photos - never an empty array. `image` is the cover and is
            also the first entry here. Not included in `property.created` /
            `property.updated` webhook payloads: those notify you that a
            property changed, so fetch the property to read its photos.
          items:
            type: object
            required: [id, url, sortOrder]
            properties:
              id: { type: string, format: uuid }
              url:
                type: string
                description: Public URL of the photo, full resolution.
              sortOrder:
                type: number
                description: Gallery position, ascending. The cover is the lowest.
              caption: { type: string }
        status:
          type: string
          description: |
            Current status derived from contracts and maintenance flag. Typical values:
            `Occupied`, `Vacant`, `Maintenance`, `Let Agreed`, `Notice Served`.
        rentAmount:
          type: number
          description: Headline monthly rent at the property level (from active contract when set).
        tenantName: { type: string }
        purchasePrice:
          type: number
          description: Purchase price recorded on the property.
        currentValuation:
          type: number
          description: Latest valuation recorded on the property.
        keyDates:
          $ref: '#/components/schemas/PropertyKeyDates'
          description: |
            Key dates aligned with the landlord dashboard (ISO 8601 `date` only, UTC).
            Omitted optional fields are not set on the property.
        compliance:
          $ref: '#/components/schemas/PropertyCompliance'
          description: |
            Normalised compliance rows for automation. `state` uses **UTC calendar-day**
            comparison against “today” in UTC (`YYYY-MM-DD`): `expired` if `dueDate` is
            strictly before today; `valid` if a `dueDate` exists and is today or later;
            `missing` if no parseable date. GSC, EICR, and EPC are always `required`;
            landlord licence is `required` only when `keyDates.requiresLandlordLicence` is true.

    PropertyKeyDates:
      type: object
      required: [requiresLandlordLicence]
      properties:
        leaseStart: { type: string, format: date }
        leaseEnd: { type: string, format: date }
        nextInspection: { type: string, format: date }
        datePurchased: { type: string, format: date }
        dateLastValued: { type: string, format: date }
        dateLastValuedSource:
          type: string
          nullable: true
          enum: [MANUAL, SYNCED, null]
          description: How `dateLastValued` was set, when known; JSON `null` when explicitly cleared.
        gscRenewal: { type: string, format: date }
        eicrRenewal: { type: string, format: date }
        epcRenewal: { type: string, format: date }
        epcRating: { type: string }
        dateInspected: { type: string, format: date }
        inspectionFrequencyMonths: { type: number }
        landlordLicence: { type: string, format: date }
        requiresLandlordLicence: { type: boolean }
        insuranceRenewal: { type: string, format: date }

    PropertyKeyDatesInput:
      type: object
      description: Writable key dates (nested on create/patch requests).
      properties:
        leaseStart: { type: string, format: date, nullable: true }
        leaseEnd: { type: string, format: date, nullable: true }
        nextInspection: { type: string, format: date, nullable: true }
        datePurchased: { type: string, format: date, nullable: true }
        dateLastValued: { type: string, format: date, nullable: true }
        dateLastValuedSource:
          type: string
          nullable: true
          enum: [MANUAL, SYNCED, null]
        gscRenewal: { type: string, format: date, nullable: true }
        eicrRenewal: { type: string, format: date, nullable: true }
        epcRenewal: { type: string, format: date, nullable: true }
        epcRating: { type: string, nullable: true }
        dateInspected: { type: string, format: date, nullable: true }
        inspectionFrequencyMonths: { type: integer, nullable: true }
        landlordLicence: { type: string, format: date, nullable: true }
        requiresLandlordLicence: { type: boolean }
        insuranceRenewal: { type: string, format: date, nullable: true }

    PropertyCreateRequest:
      type: object
      description: |
        Provide `address`, or `addressLine1` together with `city` or `postcode`.
        Document uploads are not supported on this endpoint.
      properties:
        address: { type: string }
        shortName: { type: string }
        addressLine1: { type: string }
        city: { type: string }
        postcode: { type: string }
        propertyType: { type: string }
        yearBuilt: { type: string }
        bedrooms: { type: integer }
        bathrooms: { type: integer }
        purchasePrice: { type: number, minimum: 0 }
        currentValuation: { type: number, minimum: 0 }
        keyDates:
          $ref: '#/components/schemas/PropertyKeyDatesInput'
        status:
          type: string
          enum: [Maintenance]
          description: Only `Maintenance` may be set on create; other statuses are contract-derived.

    PropertyPatchRequest:
      type: object
      description: At least one field is required.
      properties:
        address: { type: string }
        shortName: { type: string }
        addressLine1: { type: string }
        city: { type: string }
        postcode: { type: string }
        propertyType: { type: string }
        yearBuilt: { type: string }
        bedrooms: { type: integer }
        bathrooms: { type: integer }
        purchasePrice: { type: number, minimum: 0 }
        currentValuation: { type: number, minimum: 0 }
        keyDates:
          $ref: '#/components/schemas/PropertyKeyDatesInput'
        status:
          type: string
          nullable: true
          enum: [Maintenance, null]
          description: Set `Maintenance` or `null` to clear maintenance flag.

    PropertyComplianceItem:
      type: object
      required: [code, dueDate, required, state]
      properties:
        code:
          type: string
          enum:
            - GSC
            - EICR
            - EPC
            - LANDLORD_LICENCE
            - INSURANCE
            - LEASE_END
            - NEXT_INSPECTION
            - DATE_INSPECTED
            - DATE_PURCHASED
            - DATE_LAST_VALUED
        dueDate:
          type: string
          format: date
          nullable: true
        date:
          type: string
          format: date
          nullable: true
          description: Semantic date for point-in-time codes (purchase, valuation); mirrors dueDate for those items.
        required: { type: boolean }
        state:
          type: string
          enum: [missing, valid, expired]

    PropertyComplianceSummary:
      type: object
      required: [requiredTotal, missingRequiredCount, expiredRequiredCount]
      properties:
        requiredTotal: { type: integer, minimum: 0 }
        missingRequiredCount: { type: integer, minimum: 0 }
        expiredRequiredCount: { type: integer, minimum: 0 }
        earliestRequiredDueDate:
          type: string
          format: date
          description: >-
            Lexicographic minimum of `dueDate` among items with `required: true`
            and a non-null date.

    PropertyCompliance:
      type: object
      required: [items, summary]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/PropertyComplianceItem' }
        summary: { $ref: '#/components/schemas/PropertyComplianceSummary' }

    Contract:
      type: object
      required: [id, tenancyId, status, propertyId, mainTenantId, startDate, rentAmount]
      properties:
        id: { type: string, format: uuid }
        tenancyId:
          type: string
          description: Stable human-readable tenancy reference.
        status:
          type: string
          description: 'Typical values: `Active`, `Pending`, `Notice Served`, `Ended`.'
        propertyId: { type: string, format: uuid }
        propertyName: { type: string }
        mainTenantId:
          type: string
          format: uuid
          description: |
            Primary tenant on the contract. When `status` is `Active`, this ID is
            part of the property's current occupant set (with `additionalTenantIds`).
        mainTenantName: { type: string }
        additionalTenantIds:
          type: array
          items: { type: string, format: uuid }
          description: |
            Additional tenants on the contract. When `status` is `Active`, combine
            with `mainTenantId` to get all current occupants of `propertyId`.
        startDate: { type: string, format: date }
        endDate:
          type: string
          format: date
          nullable: true
          description: Null for periodic tenancies with no fixed end.
        isPeriodic:
          type: boolean
          description: True when the tenancy has no fixed end date or is periodic rolling.
        rentAmount: { type: number }
        rentChangedDate: { type: string, format: date }
        firstEligibleRentReviewDate:
          type: string
          format: date
          description: Earliest eligible rent review (24 months after start date).
        nextRentReviewDate:
          type: string
          format: date
          description: Next upcoming rent review based on interval months.
        depositScheme: { type: string }
        depositAmount: { type: number }
        depositReference: { type: string }
        anchorDate:
          type: string
          format: date
          description: Rent schedule anchor day-of-month (defaults to start date).
        phase2AnchorDate:
          type: string
          format: date
          description: Payment day after phase 2 override takes effect.
        phase2StartDate:
          type: string
          format: date
          description: Date from which phase 2 payment day applies.
        rentChangeIntervalMonths:
          type: integer
          description: Months between rent reviews (default 12).

    ContractCreateRequest:
      type: object
      required: [propertyId, mainTenantId, startDate, rentAmount]
      properties:
        propertyId: { type: string, format: uuid }
        mainTenantId: { type: string, format: uuid }
        startDate: { type: string, format: date }
        rentAmount: { type: number, minimum: 0 }
        tenancyId: { type: string }
        additionalTenantIds:
          type: array
          items: { type: string, format: uuid }
        endDate: { type: string, format: date, nullable: true }
        rentChangedDate: { type: string, format: date, nullable: true }
        rentChangeIntervalMonths: { type: integer, minimum: 1, maximum: 120 }
        anchorDate: { type: string, format: date }
        phase2AnchorDate: { type: string, format: date }
        phase2StartDate: { type: string, format: date }
        depositScheme: { type: string, nullable: true }
        depositAmount: { type: number, minimum: 0, nullable: true }
        depositReference: { type: string, nullable: true }
        status:
          type: string
          enum: ['Notice Served']
          description: Manual override; other statuses are date-derived.

    ContractPatchRequest:
      type: object
      description: At least one field is required.
      properties:
        tenancyId: { type: string }
        propertyId: { type: string, format: uuid }
        mainTenantId: { type: string, format: uuid }
        additionalTenantIds:
          type: array
          nullable: true
          items: { type: string, format: uuid }
        startDate: { type: string, format: date }
        endDate: { type: string, format: date, nullable: true }
        rentAmount: { type: number, minimum: 0 }
        rentChangedDate: { type: string, format: date, nullable: true }
        rentChangeIntervalMonths: { type: integer, minimum: 1, maximum: 120 }
        anchorDate: { type: string, format: date, nullable: true }
        phase2AnchorDate: { type: string, format: date, nullable: true }
        phase2StartDate: { type: string, format: date, nullable: true }
        depositScheme: { type: string, nullable: true }
        depositAmount: { type: number, minimum: 0, nullable: true }
        depositReference: { type: string, nullable: true }
        status:
          type: string
          enum: ['Notice Served']
          description: Manual override only.

    RentPayment:
      type: object
      required: [id, propertyId, propertyName, tenantId, amount, dueDate, status, period]
      properties:
        id: { type: string, format: uuid }
        contractId: { type: string, format: uuid }
        propertyId: { type: string, format: uuid }
        propertyName: { type: string }
        tenantId: { type: string, format: uuid }
        tenantName: { type: string }
        amount: { type: number }
        amountReceived: { type: number }
        dueDate: { type: string, format: date }
        datePaid: { type: string, format: date }
        dateFrom: { type: string, format: date }
        dateTo: { type: string, format: date }
        status: { type: string, enum: [Pending, Paid, Overdue] }
        period:
          type: string
          description: 'Display label for the period, for example `January 2026`.'
        underPaymentPlan:
          type: boolean
          description: >
            True when this charge is covered by an active payment plan (an agreed
            schedule to clear it). The charge is still reported as Overdue; this
            flag means "already being dealt with, do not chase".
        daysOverdue:
          type: integer
          description: 'Whole days past the due date (UTC). Present only when status is Overdue.'
        arrearsTier:
          type: integer
          enum: [0, 1, 2, 3, 4]
          description: >
            Canonical arrears collection tier from `daysOverdue`: 0 none, 1 (≥1
            day), 2 (≥8), 3 (≥15), 4 (≥22). Tiers 1–2 are auto-chased; 3–4 are
            staged for manual review. Present only when status is Overdue.

    PropertyListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Property' }
        meta: { $ref: '#/components/schemas/PaginationMeta' }
    PropertyDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/Property' }
    ContractListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Contract' }
        meta: { $ref: '#/components/schemas/PaginationMeta' }
    ContractDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/Contract' }
    RentListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/RentPayment' }
        meta: { $ref: '#/components/schemas/PaginationMeta' }

    RentDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/RentPayment' }

    CreateRentRequest:
      type: object
      additionalProperties: false
      required:
        - propertyId
        - tenantId
        - contractId
        - period
        - amount
        - dateFrom
        - dateTo
        - dueDate
        - status
      properties:
        propertyId: { type: string, format: uuid }
        tenantId: { type: string, format: uuid }
        contractId: { type: string, format: uuid }
        period: { type: string, minLength: 1, maxLength: 100 }
        amount: { type: number, exclusiveMinimum: 0 }
        dateFrom: { type: string, format: date }
        dateTo: { type: string, format: date }
        dueDate: { type: string, format: date }
        status: { type: string, enum: [Pending, Paid, Overdue] }
        amountReceived: { type: number, minimum: 0 }
        datePaid: { type: string, format: date }

    SendRentInvoiceEmailRequest:
      type: object
      additionalProperties: false
      properties:
        email:
          type: string
          format: email
          maxLength: 320
          description: Optional override recipient (defaults to tenant email on file).
    RentInvoiceEmailResult:
      type: object
      required:
        - rentPaymentId
        - tenantEmail
        - tenantName
        - period
        - documentType
        - sentAt
        - sandbox
        - sent
      properties:
        rentPaymentId:
          type: string
          format: uuid
        tenantEmail:
          type: string
          format: email
        tenantName:
          type: string
        period:
          type: string
        documentType:
          type: string
          enum: [invoice, receipt]
        sentAt:
          type: string
          format: date-time
        sandbox:
          type: boolean
        sent:
          type: boolean
    RentInvoiceEmailResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/RentInvoiceEmailResult'
        meta:
          type: object
          properties:
            mode:
              type: string
              enum: [live, sandbox]
    PatchRentRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        status: { type: string, enum: [Pending, Paid, Overdue] }
        amountReceived: { type: number, minimum: 0 }
        datePaid: { type: string, format: date }
        dateFrom: { type: string, format: date }
        dateTo: { type: string, format: date }
        dueDate: { type: string, format: date }
        period: { type: string, minLength: 1, maxLength: 100 }

    PublicCalendarRentData:
      type: object
      required: [period, amount]
      properties:
        period: { type: string }
        amount: { type: number }
        dateFrom: { type: string, format: date }
        dateTo: { type: string, format: date }

    PublicCalendarEvent:
      type: object
      required: [id, title, date, type, refId, refLink]
      properties:
        id: { type: string, description: Stable event identifier within the feed. }
        title: { type: string }
        date: { type: string, format: date }
        type:
          type: string
          enum:
            [Task, Property, Contract, Tenant, Rent, Compliance, Insurance, Schedule, Inspection]
        status: { type: string }
        description: { type: string }
        priority:
          type: string
          enum: [low, medium, high, urgent]
        propertyId: { type: string, format: uuid }
        propertyName: { type: string }
        propertyShortName: { type: string }
        refId: { type: string, description: Referenced entity id (issue, contract, rent row, etc.). }
        refLink: { type: string, description: Logical module hint for deep-linking in the HomeDash UI. }
        icon: { type: string }
        rentData: { $ref: '#/components/schemas/PublicCalendarRentData' }

    EventsListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PublicCalendarEvent' }
        meta: { $ref: '#/components/schemas/PaginationMeta' }

    PublicTeamMember:
      type: object
      required: [name, email, role, status]
      properties:
        name: { type: string }
        email: { type: string, format: email }
        role:
          type: string
          enum: [owner, manager, assistant, viewer]
        status:
          type: string
          enum: [active, suspended]

    TeamListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PublicTeamMember' }

    PublicSubscriptionStorageUsage:
      type: object
      required: [usedBytes, limitBytes, usagePercentage, lastUpdated]
      properties:
        usedBytes: { type: integer, minimum: 0 }
        limitBytes: { type: integer, minimum: 0 }
        usagePercentage: { type: number, minimum: 0 }
        lastUpdated: { type: string, format: date-time, nullable: true }

    PublicSubscription:
      type: object
      required: [planName, propertyLimit, propertyCount, storageUsage, nextBillingDate]
      properties:
        planName: { type: string }
        propertyLimit:
          type: integer
          nullable: true
          description: Null when the current plan has no finite property cap.
        propertyCount: { type: integer, minimum: 0 }
        storageUsage:
          $ref: '#/components/schemas/PublicSubscriptionStorageUsage'
        nextBillingDate:
          type: string
          format: date-time
          nullable: true
          description: Null when the current account state has no next billing date.

    SubscriptionResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicSubscription' }

    MortgagePropertyWire:
      type: object
      required: [id]
      properties:
        id: { type: string, format: uuid }
        address: { type: string }
        shortName: { type: string }

    PublicMortgage:
      type: object
      required:
        - id
        - status
        - lender
        - balance
        - interestRate
        - monthlyPayment
        - loanType
        - fixedPeriodYears
        - latestValuation
        - expectedRent
        - brokerFee
        - lenderToBrokerFee
        - lenderFee
        - valuationFee
        - legalFee
        - propertyIds
        - properties
        - createdAt
        - updatedAt
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [Active, Pending, Redeemed] }
        lender: { type: string }
        reference: { type: string }
        balance: { type: number, description: Outstanding loan balance. }
        outstandingBalance:
          type: number
          description: Alias for `balance` (deprecated synonym).
        interestRate: { type: number }
        monthlyPayment: { type: number }
        loanType:
          type: string
          enum: ['Interest Only', Repayment, Variable]
        loanStartDate: { type: string }
        productEndDate:
          type: string
          description: Product / deal end date (loan end).
        ercEndDate: { type: string }
        fixedRateEndDate:
          type: string
          description: Alias for `ercEndDate` (deprecated synonym).
        fixedPeriodYears: { type: integer }
        latestValuation:
          type: number
          description: Sum of linked property valuations used for LTV on the dashboard.
        expectedRent:
          type: number
          description: Sum of expected rent from linked properties.
        ltvPercent:
          type: number
          description: Loan-to-value percentage when `latestValuation` > 0 (one decimal).
        brokerName: { type: string }
        brokerFee: { type: number }
        lenderToBrokerFee: { type: number }
        lenderFee: { type: number }
        valuationFee: { type: number }
        legalFee: { type: number }
        propertyIds:
          type: array
          items: { type: string, format: uuid }
        properties:
          type: array
          items: { $ref: '#/components/schemas/MortgagePropertyWire' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        sandbox:
          type: boolean
          description: Present and true only for sandbox write responses.

    MortgageListMeta:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          properties:
            totalOutstandingBalance:
              type: number
              description: Sum of balances for all mortgages matching filters (not only the page).

    MortgageListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PublicMortgage' }
        meta: { $ref: '#/components/schemas/MortgageListMeta' }

    MortgageDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicMortgage' }

    MortgageCreateResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicMortgage' }

    MortgageDeleteResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [deleted]
          properties:
            deleted: { type: boolean }
            sandbox:
              type: boolean
              description: Present and true only for sandbox keys.

    PublicContact:
      type: object
      required: [id, name, rating, categories, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        company: { type: string }
        email: { type: string, format: email }
        mobile: { type: string }
        workPhone: { type: string }
        address: { type: string }
        rating: { type: integer, minimum: 0, maximum: 5 }
        categories:
          type: array
          items: { type: string }
        avatarUrl:
          type: string
          format: uri
          description: HTTPS URL to avatar image (public bucket URL when set via dashboard).
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        sandbox:
          type: boolean
          description: Present and true only for sandbox write responses.

    PublicContactWorkHistoryItem:
      type: object
      required: [id, title, status, dateCreated]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        propertyId: { type: string, format: uuid }
        propertyName: { type: string }
        status: { type: string }
        dateCreated: { type: string }
        cost: { type: number }

    ContactListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PublicContact' }
        meta: { $ref: '#/components/schemas/PaginationMeta' }

    ContactDetailResponse:
      type: object
      required: [data]
      properties:
        data:
          allOf:
            - $ref: '#/components/schemas/PublicContact'
            - type: object
              properties:
                workHistory:
                  type: array
                  items: { $ref: '#/components/schemas/PublicContactWorkHistoryItem' }

    ContactDetailWithHistoryMeta:
      allOf:
        - $ref: '#/components/schemas/PaginationMeta'
        - type: object
          properties:
            historyPage: { type: integer }
            historyLimit: { type: integer }
            historyTotal: { type: integer }

    ContactCreateResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicContact' }

    ContactDeleteResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [deleted, id]
          properties:
            deleted: { type: boolean }
            id: { type: string, format: uuid }
            sandbox:
              type: boolean
              description: Present and true only for sandbox keys.

    CreateContactRequest:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 500 }
        company: { type: string, maxLength: 500 }
        email: { type: string, format: email, maxLength: 320 }
        mobile: { type: string, maxLength: 50 }
        workPhone: { type: string, maxLength: 50 }
        address: { type: string, maxLength: 1000 }
        rating: { type: integer, minimum: 0, maximum: 5 }
        categories:
          type: array
          maxItems: 30
          items: { type: string, minLength: 1, maxLength: 100 }
        avatarUrl:
          type: string
          format: uri
          description: Must use https.

    PatchContactRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        name: { type: string, minLength: 1, maxLength: 500 }
        company: { type: string, maxLength: 500, nullable: true }
        email: { type: string, format: email, maxLength: 320, nullable: true }
        mobile: { type: string, maxLength: 50, nullable: true }
        workPhone: { type: string, maxLength: 50, nullable: true }
        address: { type: string, maxLength: 1000, nullable: true }
        rating: { type: integer, minimum: 0, maximum: 5 }
        categories:
          type: array
          maxItems: 30
          nullable: true
          items: { type: string, minLength: 1, maxLength: 100 }
        avatarUrl:
          type: string
          format: uri
          nullable: true
          description: Must use https when set.

    PublicChatMessage:
      type: object
      required: [id, conversationId, text, senderId, createdAt]
      properties:
        id: { type: string, format: uuid }
        conversationId: { type: string, format: uuid }
        text: { type: string }
        senderId: { type: string, format: uuid }
        senderName: { type: string }
        senderRole:
          type: string
          enum: [landlord, tenant, manager, viewer]
        attachmentUrl: { type: string, format: uri }
        attachmentType: { type: string, enum: [image, file] }
        attachmentName: { type: string }
        readAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        sandbox:
          type: boolean
          description: Present and true only for sandbox write responses.

    ChatMessageListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PublicChatMessage' }
        meta:
          type: object
          required: [hasMore, limit]
          properties:
            hasMore: { type: boolean }
            limit: { type: integer }

    ChatMessageCreateResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicChatMessage' }

    CreateMortgageRequest:
      type: object
      additionalProperties: false
      required: [lender, balance, interestRate, monthlyPayment, propertyIds]
      properties:
        lender: { type: string, minLength: 1, maxLength: 500 }
        reference: { type: string, maxLength: 200 }
        balance: { type: number, minimum: 0 }
        interestRate: { type: number, minimum: 0 }
        monthlyPayment: { type: number, minimum: 0 }
        propertyIds:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string, format: uuid }
        loanType:
          type: string
          enum: ['Interest Only', Repayment, Variable]
        loanStartDate: { type: string }
        productEndDate: { type: string }
        ercEndDate: { type: string }
        status: { type: string, enum: [Active, Pending, Redeemed] }
        fixedPeriodYears: { type: integer, minimum: 0, maximum: 50 }
        brokerName: { type: string, maxLength: 500 }
        brokerFee: { type: number, minimum: 0 }
        lenderToBrokerFee: { type: number, minimum: 0 }
        lenderFee: { type: number, minimum: 0 }
        valuationFee: { type: number, minimum: 0 }
        legalFee: { type: number, minimum: 0 }

    PatchMortgageRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        lender: { type: string, minLength: 1, maxLength: 500 }
        reference: { type: string, nullable: true, maxLength: 200 }
        balance: { type: number, minimum: 0 }
        interestRate: { type: number, minimum: 0 }
        monthlyPayment: { type: number, minimum: 0 }
        propertyIds:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string, format: uuid }
        loanType:
          type: string
          enum: ['Interest Only', Repayment, Variable]
        loanStartDate: { type: string, nullable: true }
        productEndDate: { type: string, nullable: true }
        ercEndDate: { type: string, nullable: true }
        status: { type: string, enum: [Active, Pending, Redeemed] }
        fixedPeriodYears: { type: integer, minimum: 0, maximum: 50 }
        brokerName: { type: string, nullable: true, maxLength: 500 }
        brokerFee: { type: number, minimum: 0 }
        lenderToBrokerFee: { type: number, minimum: 0 }
        lenderFee: { type: number, minimum: 0 }
        valuationFee: { type: number, minimum: 0 }
        legalFee: { type: number, minimum: 0 }

    PublicTenant:
      type: object
      required:
        - id
        - status
        - relationshipStatus
        - effectiveStatus
        - canEdit
        - isActiveWithAnotherLandlord
        - firstName
        - lastName
        - email
        - hasPortalAccount
        - createdAt
        - updatedAt
      properties:
        id: { type: string, format: uuid }
        status: { type: string, description: Database tenant status (e.g. Invited, Active, Pending). }
        relationshipStatus: { type: string, enum: [ACTIVE, INACTIVE] }
        effectiveStatus:
          type: string
          description: >-
            Computed display status describing the TENANCY: ACTIVE, SUSPENDED
            (live tenancy, portal access switched off), ENDED, INACTIVE (no
            tenancy on record) or REJECTED. Whether the tenant has accepted
            their portal invitation is a separate question - read the raw
            `status` field for that. INVITED was removed on 2 Sep 2026: it was
            returned for any tenant who had never signed up, whatever their
            tenancy was doing, which hid current and departed tenants under one
            label.
        canEdit:
          type: boolean
          description: False when the tenant is read-only for this landlord (active elsewhere).
        isActiveWithAnotherLandlord:
          type: boolean
          description: True when the tenant has an active relationship with a different landlord.
        prefix: { type: string }
        firstName: { type: string }
        lastName: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        mobilePhone: { type: string }
        dob: { type: string, format: date }
        nationality: { type: string }
        rightToRentDate: { type: string, format: date }
        passportNumber: { type: string }
        propertyId:
          type: string
          format: uuid
          nullable: true
          description: |
            Linked property id on the tenant record. May remain set after the tenant
            has moved out — **not** authoritative for current occupancy. Resolve
            current occupants from the property's Active contract (`mainTenantId` +
            `additionalTenantIds`).
        propertyAddress: { type: string }
        contractPropertyAddress:
          type: string
          description: Address from the tenant's active contract with this landlord, when any.
        sponsorsName: { type: string }
        sponsorsMobile: { type: string }
        sponsorsEmail: { type: string, format: email }
        hasPortalAccount:
          type: boolean
          description: True when the tenant profile is linked to an auth account.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        sandbox:
          type: boolean
          description: Present and true only for sandbox write responses.

    TenantListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PublicTenant' }
        meta: { $ref: '#/components/schemas/PaginationMeta' }

    TenantDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicTenant' }

    TenantCreateResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicTenant' }
        meta:
          type: object
          properties:
            invitationSent:
              type: boolean
              description: True when a portal invitation email was sent.

    TenantDeleteResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [deleted, id]
          properties:
            deleted: { type: boolean }
            id: { type: string, format: uuid }
            sandbox:
              type: boolean
              description: Present and true only for sandbox keys.

    CreateTenantRequest:
      type: object
      additionalProperties: false
      required: [firstName, lastName, email, mobilePhone]
      properties:
        prefix: { type: string, maxLength: 50 }
        firstName: { type: string, minLength: 1, maxLength: 200 }
        lastName: { type: string, minLength: 1, maxLength: 200 }
        email: { type: string, format: email, maxLength: 320 }
        phone: { type: string, maxLength: 50 }
        mobilePhone: { type: string, minLength: 1, maxLength: 50 }
        dob: { type: string }
        nationality: { type: string, maxLength: 200 }
        rightToRentDate: { type: string }
        passportNumber: { type: string, maxLength: 100 }
        propertyId: { type: string, format: uuid, nullable: true }
        sponsorsName: { type: string, maxLength: 500 }
        sponsorsMobile: { type: string, maxLength: 50 }
        sponsorsEmail: { type: string, format: email, maxLength: 320 }
        status: { type: string, enum: [Active, Pending] }

    PatchTenantRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        prefix: { type: string, maxLength: 50 }
        firstName: { type: string, minLength: 1, maxLength: 200 }
        lastName: { type: string, minLength: 1, maxLength: 200 }
        phone: { type: string, maxLength: 50 }
        mobilePhone: { type: string, minLength: 1, maxLength: 50 }
        dob: { type: string, nullable: true }
        nationality: { type: string, nullable: true, maxLength: 200 }
        rightToRentDate: { type: string, nullable: true }
        passportNumber: { type: string, nullable: true, maxLength: 100 }
        propertyId: { type: string, format: uuid, nullable: true }
        sponsorsName: { type: string, nullable: true, maxLength: 500 }
        sponsorsMobile: { type: string, nullable: true, maxLength: 50 }
        sponsorsEmail: { type: string, format: email, nullable: true, maxLength: 320 }
        status: { type: string, enum: [Active, Pending] }

    PublicIssueAttachment:
      type: object
      required: [id, fileName, url]
      properties:
        id: { type: string, format: uuid }
        fileName: { type: string }
        mimeType: { type: string }
        url:
          type: string
          format: uri
          description: Time-limited signed URL (default 86400 seconds / 24 hours).

    PublicMaintenanceIssue:
      type: object
      required: [id, title, description, status, priority, dateCreated, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        description: { type: string }
        status:
          type: string
          enum: [New, In_Progress, Completed, Tenant_Request]
        priority:
          type: string
          enum: [Low, Medium, High]
        propertyId: { type: string, format: uuid }
        propertyName: { type: string }
        tenantId: { type: string, format: uuid }
        assignedContactId: { type: string, format: uuid }
        assignedContractor: { type: string }
        estimatedCost: { type: number }
        actualCost: { type: number }
        landlordNotes: { type: string }
        dateCreated: { type: string }
        dateClosed: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        attachments:
          type: array
          description: Signed URLs for issue photos (max 3). Omitted on list endpoints.
          items: { $ref: '#/components/schemas/PublicIssueAttachment' }
        sandbox:
          type: boolean
          description: Present and `true` only for sandbox write responses.

    MaintenanceIssueListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PublicMaintenanceIssue' }
        meta:
          type: object
          required: [page, limit, total]
          properties:
            page: { type: integer }
            limit: { type: integer }
            total: { type: integer }

    MaintenanceIssueDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicMaintenanceIssue' }

    MaintenanceIssueDeleteResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [deleted, id]
          properties:
            deleted: { type: boolean }
            id: { type: string, format: uuid }
            sandbox:
              type: boolean
              description: Present and `true` only when the request was made with a sandbox key.

    CreateMaintenanceIssueRequest:
      type: object
      required: [title, description, priority, propertyId]
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1, maxLength: 255 }
        description: { type: string, minLength: 1, maxLength: 5000 }
        priority:
          type: string
          enum: [Low, Medium, High]
        propertyId: { type: string, format: uuid }
        estimatedCost: { type: number, minimum: 0 }
        assignedContactId: { type: string, format: uuid }

    PatchMaintenanceIssueRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [New, In_Progress, Completed, Tenant_Request]
        priority:
          type: string
          enum: [Low, Medium, High]
        title: { type: string, minLength: 1, maxLength: 255 }
        description: { type: string, minLength: 1, maxLength: 5000 }
        landlordNotes: { type: string, maxLength: 5000, nullable: true }
        estimatedCost: { type: number, minimum: 0, nullable: true }
        actualCost: { type: number, minimum: 0, nullable: true }
        assignedContactId: { type: string, format: uuid, nullable: true }
        dateClosed: { type: string, nullable: true }
        dateCreated: { type: string, nullable: true }

    PublicInspectionMedia:
      type: object
      required: [id, fileName, url]
      properties:
        id: { type: string, format: uuid }
        fileName: { type: string }
        mimeType: { type: string }
        mediaType: { type: string, enum: [image, video] }
        url:
          type: string
          format: uri
          description: Time-limited signed URL (default 86400 seconds / 24 hours).
        timestampTaken: { type: string, format: date-time }

    PublicInspectionArea:
      type: object
      required: [id, areaName, floorLevel, areaOrder, condition, cleanliness, description]
      properties:
        id: { type: string, format: uuid }
        areaName: { type: string }
        floorLevel: { type: string, enum: [ground, upstairs] }
        areaOrder: { type: integer }
        condition: { type: string, enum: [GOOD, OKAY, POOR, ISSUE] }
        cleanliness: { type: string, enum: [GOOD, OKAY, POOR, ISSUE] }
        description: { type: string }
        media:
          type: array
          description: Present when GET inspection uses `includeMedia=true`.
          items: { $ref: '#/components/schemas/PublicInspectionMedia' }

    PublicInspection:
      type: object
      required: [id, propertyId, inspectionDate, inspectionType, status, isApproved, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        propertyId: { type: string, format: uuid }
        propertyAddress: { type: string }
        inspectionDate: { type: string, format: date }
        inspectionType:
          type: string
          enum: [routine, move_in, move_out, mid_term, emergency]
        status:
          type: string
          enum: [draft, in_progress, completed, signed, archived]
        isApproved: { type: boolean }
        approvedAt: { type: string, format: date-time }
        tenantId: { type: string, format: uuid }
        tenantName: { type: string }
        inspectorProfileId: { type: string, format: uuid }
        inspectorName: { type: string }
        evidenceOfDamp: { type: boolean }
        anyCondensation: { type: boolean }
        goodVentilation: { type: boolean }
        nonTenants: { type: boolean }
        pets: { type: boolean }
        smoking: { type: boolean }
        smokeDetectors: { type: boolean }
        testedSmokeDetectors: { type: boolean }
        co2Detectors: { type: boolean }
        testedCo2Detectors: { type: boolean }
        heatingHotWater: { type: boolean }
        generalGoodStandard: { type: boolean }
        landlordActions: { type: string }
        tenantActions: { type: string }
        excludedAreas:
          type: array
          items: { type: string }
        summary:
          type: object
          properties:
            totalAreas: { type: integer }
            problemAreas: { type: integer }
        areas:
          type: array
          items: { $ref: '#/components/schemas/PublicInspectionArea' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        sandbox:
          type: boolean
          description: Present and `true` only for sandbox write responses.

    InspectionListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/PublicInspection' }
        meta:
          type: object
          required: [page, limit, total]
          properties:
            page: { type: integer }
            limit: { type: integer }
            total: { type: integer }

    InspectionDetailResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: '#/components/schemas/PublicInspection' }

    InspectionDeleteResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [deleted, id]
          properties:
            deleted: { type: boolean }
            id: { type: string, format: uuid }
            sandbox:
              type: boolean
              description: Present and `true` only when the request was made with a sandbox key.

    CreateMaintenanceInspectionRequest:
      type: object
      required: [propertyId]
      additionalProperties: false
      properties:
        propertyId: { type: string, format: uuid }
        inspectionDate: { type: string, format: date }
        inspectionType:
          type: string
          enum: [routine, move_in, move_out, mid_term, emergency]
        tenantId: { type: string, format: uuid }
        inspectorProfileId: { type: string, format: uuid }
        templateId: { type: string, format: uuid }

    PatchMaintenanceInspectionRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [draft, in_progress, completed, signed]
        inspectionType:
          type: string
          enum: [routine, move_in, move_out, mid_term, emergency]
        inspectionDate: { type: string, format: date }
        tenantId: { type: string, format: uuid, nullable: true }
        inspectorProfileId: { type: string, format: uuid, nullable: true }
        evidenceOfDamp: { type: boolean }
        anyCondensation: { type: boolean }
        goodVentilation: { type: boolean }
        nonTenants: { type: boolean }
        pets: { type: boolean }
        smoking: { type: boolean }
        smokeDetectors: { type: boolean }
        testedSmokeDetectors: { type: boolean }
        co2Detectors: { type: boolean }
        testedCo2Detectors: { type: boolean }
        heatingHotWater: { type: boolean }
        generalGoodStandard: { type: boolean }
        landlordActions: { type: string, maxLength: 1000, nullable: true }
        tenantActions: { type: string, maxLength: 1000, nullable: true }
        excludedAreas:
          type: array
          items: { type: string }
          nullable: true

    UpdateMaintenanceBody:
      type: object
      required: [status]
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [New, In_Progress, Completed, Tenant_Request]
          description: Target status. `In_Progress` is the canonical "in progress" value (with underscore).

    MaintenanceUpdateResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, status, updatedAt]
          properties:
            id: { type: string, format: uuid }
            status: { type: string, enum: [New, In_Progress, Completed, Tenant_Request] }
            updatedAt: { type: string, format: date-time }
            sandbox:
              type: boolean
              description: Present and `true` only when the request was made with a sandbox key. No DB write occurred.

    CreateTaskBody:
      type: object
      required: [title, propertyId]
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1, maxLength: 255 }
        propertyId: { type: string, format: uuid }
        priority:
          type: string
          enum: [Low, Medium, High]
          default: Medium
        description:
          type: string
          maxLength: 5000
          description: Optional detail. Trimmed; empty strings are rejected.

    TaskResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, title, propertyId, priority, status, createdAt]
          properties:
            id: { type: string, format: uuid }
            title: { type: string }
            propertyId: { type: string, format: uuid }
            priority: { type: string, enum: [Low, Medium, High] }
            status: { type: string, enum: [New], description: All API-created tasks start in `New`. }
            createdAt: { type: string, format: date-time }
            sandbox:
              type: boolean
              description: Present and `true` only when the request was made with a sandbox key.

    WebhookEnvelope:
      type: object
      description: |
        The JSON body HomeDash POSTs to your registered webhook URL.
        Always verify the `X-HomeDash-Signature-256` header before
        trusting the body. See the [webhook reference](/developer-docs/webhooks)
        for verification snippets.
      required: [id, event, created_at, landlord_id, data]
      properties:
        id:
          type: string
          description: Stable event id, prefixed `evt_`. Use it for idempotency.
        event:
          type: string
          description: One of the 9 supported event types.
          enum:
            - rent.overdue
            - contract.expiring
            - contract.created
            - contract.ended
            - maintenance.created
            - document.signed
            - document.unsigned
            - inspection.scheduled
            - inspection.completed
        created_at: { type: string, format: date-time }
        landlord_id: { type: string, format: uuid }
        data:
          type: object
          description: Event-specific payload. See the per-event schemas under `WebhookEvent*` below.
        _test:
          type: boolean
          description: Present and `true` only on test deliveries fired from the Developer Dashboard. Real deliveries omit this field.

    RentOverdueEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: rent.overdue }
            data:
              type: object
              required: [rent_payment_id, contract_id, property_id, property_name, amount, due_date, days_overdue]
              properties:
                rent_payment_id: { type: string, format: uuid }
                contract_id: { type: string, format: uuid }
                property_id: { type: string, format: uuid }
                property_name: { type: string }
                amount: { type: number }
                due_date: { type: string, format: date }
                days_overdue: { type: integer }

    ContractExpiringEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: contract.expiring }
            data:
              type: object
              required: [contract_id, property_id, property_name, end_date, days_until_expiry]
              properties:
                contract_id: { type: string, format: uuid }
                property_id: { type: string, format: uuid }
                property_name: { type: string }
                end_date: { type: string, format: date }
                days_until_expiry: { type: integer }

    ContractCreatedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: contract.created }
            data:
              type: object
              required: [contract_id, property_id, status, start_date]
              properties:
                contract_id: { type: string, format: uuid }
                property_id: { type: string, format: uuid }
                status: { type: string }
                start_date: { type: string, format: date }
                end_date:
                  type: [string, 'null']
                  format: date

    ContractEndedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: contract.ended }
            data:
              type: object
              required: [contract_id, property_id, status, start_date, end_date]
              properties:
                contract_id: { type: string, format: uuid }
                property_id: { type: string, format: uuid }
                status: { type: string }
                start_date: { type: string, format: date }
                end_date: { type: string, format: date }

    MaintenanceCreatedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: maintenance.created }
            data:
              type: object
              required: [issue_id, property_id, title, priority, status]
              properties:
                issue_id: { type: string, format: uuid }
                property_id: { type: string, format: uuid }
                title: { type: string }
                priority: { type: string, enum: [Low, Medium, High] }
                status: { type: string }

    DocumentSignedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: document.signed }
            data:
              type: object
              required: [signature_request_id, document_name, signer_email, status]
              properties:
                signature_request_id: { type: string }
                document_name: { type: string }
                signer_email: { type: string, format: email }
                status: { type: string }
                signed_document_url: { type: string, format: uri }

    DocumentUnsignedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: document.unsigned }
            data:
              type: object
              required: [signature_request_id, document_name, signer_email, status]
              properties:
                signature_request_id: { type: string }
                document_name: { type: string }
                signer_email: { type: string, format: email }
                status: { type: string }

    InspectionScheduledEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: inspection.scheduled }
            data:
              type: object
              required: [inspection_id, property_id, inspection_date, inspection_type, status]
              properties:
                inspection_id: { type: string, format: uuid }
                property_id: { type: string, format: uuid }
                inspection_date: { type: string, format: date }
                inspection_type: { type: string }
                status: { type: string }

    InspectionCompletedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            event: { const: inspection.completed }
            data:
              type: object
              required: [inspection_id, property_id, inspection_date, inspection_type, status]
              properties:
                inspection_id: { type: string, format: uuid }
                property_id: { type: string, format: uuid }
                inspection_date: { type: string, format: date }
                inspection_type: { type: string }
                status: { type: string }
