openapi: 3.1.0
info:
  title: Borough API
  description: >
    NYC Real Estate Data API — structured access to rental and sale listings,

    building details, neighborhood data, and market statistics.


    ## Authentication


    All authenticated endpoints require a `Bearer` token in the `Authorization`
    header:

    ```

    Authorization: Bearer BOROUGH-<your-license-key>

    ```


    License keys are provisioned automatically when you subscribe via

    [Polar.sh](https://polar.sh/qwady-solutions-llc/portal). Free-tier endpoints

    (search, areas, autocomplete) work without authentication but are subject to

    IP-based rate limits of 10 requests per minute.


    ## Rate Limits


    | Tier     | Requests/min | Requests/month | Max perPage |

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

    | Free     | 10          | 100            | 10          |

    | Starter  | 30          | 5,000          | 50          |

    | Pro      | 60          | 25,000         | 500         |

    | Business | 120         | 100,000        | 500         |


    Every response includes `X-RateLimit-*` and `X-Quota-*` headers, plus an

    `X-Request-Id` header (UUID v4) for request tracing and debugging.


    ## Data Freshness


    Every response includes a `meta.dataAge` field (ISO 8601) indicating when

    the underlying data was last scraped, and a `meta.source` field indicating

    how the data was served (`cached`, `stale`, or `live`).


    Free-tier data is refreshed on a schedule (search every 4-6h, buildings

    every 24h). Paid tiers get fresher data — when cached data exceeds the

    tier's freshness threshold, a background refresh is triggered so subsequent

    requests return updated results.


    | Tier     | Search Freshness | Detail Freshness |

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

    | Free     | 4-6h (cached)   | 6-12h (cached)  |

    | Starter  | 2h              | 30min           |

    | Pro      | 1h              | 15min           |

    | Business | 30min           | 10min           |


    ## Photo URLs


    Listing and building responses include photo keys (32-character hex hashes).

    Construct photo URLs using the key and a size suffix:

    ```

    https://photos.zillowstatic.com/fp/{key}-se_large_800_400.webp

    ```
  version: 1.0.0
  contact:
    name: Qwady Solutions LLC
    url: https://borough.qwady.app
    email: api@qwady.com
  license:
    name: Proprietary
    url: https://borough.qwady.app/terms
servers:
  - url: https://borough.qwady.app/v1
    description: Production
security:
  - BearerAuth: []
tags:
  - name: Search
    description: Search rental and sale listings with filters
  - name: Property
    description: Individual listing details and price history
  - name: Building
    description: Building information, amenities, and active listings
  - name: Areas
    description: Neighborhoods, boroughs, and geographic boundaries
  - name: Market
    description: Market snapshots, trends, and area comparisons
  - name: Webhooks
    description: Webhook subscriptions for listing change notifications
  - name: Watchers
    description: Persistent watchers for real-time listing, building, and search monitoring
  - name: Streaming
    description: Server-Sent Events for live data and watcher changes
  - name: Utility
    description: Health checks and internal endpoints
paths:
  /search/rentals:
    get:
      operationId: searchRentals
      summary: Search rental listings
      description: |
        Search active NYC rental listings with comprehensive filters. Returns
        summary-level fields per listing. Use `/property/{id}` for full detail.

        **Tier:** Free+
      tags:
        - Search
      security: []
      parameters:
        - $ref: '#/components/parameters/areas'
        - $ref: '#/components/parameters/minPrice'
        - $ref: '#/components/parameters/maxPrice'
        - $ref: '#/components/parameters/minBeds'
        - $ref: '#/components/parameters/maxBeds'
        - $ref: '#/components/parameters/minBaths'
        - $ref: '#/components/parameters/maxBaths'
        - $ref: '#/components/parameters/buildingType'
        - $ref: '#/components/parameters/noFee'
        - $ref: '#/components/parameters/minDaysOnMarket'
        - $ref: '#/components/parameters/maxDaysOnMarket'
        - $ref: '#/components/parameters/listedAfter'
        - $ref: '#/components/parameters/listedBefore'
        - $ref: '#/components/parameters/hasOpenHouse'
        - $ref: '#/components/parameters/has3dTour'
        - $ref: '#/components/parameters/minSqft'
        - $ref: '#/components/parameters/maxSqft'
        - $ref: '#/components/parameters/petsAllowed'
        - $ref: '#/components/parameters/amenities'
        - $ref: '#/components/parameters/maxMonthlyCost'
        - $ref: '#/components/parameters/minTransitScore'
        - $ref: '#/components/parameters/fiberOnly'
        - $ref: '#/components/parameters/minDownloadMbps'
        - $ref: '#/components/parameters/sort'
        - $ref: '#/components/parameters/sortDir'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/perPage'
      responses:
        '200':
          description: Paginated list of rental listings
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Quota-Limit:
              $ref: '#/components/headers/X-Quota-Limit'
            X-Quota-Used:
              $ref: '#/components/headers/X-Quota-Used'
            X-Quota-Remaining:
              $ref: '#/components/headers/X-Quota-Remaining'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ListingSummary'
                  meta:
                    $ref: '#/components/schemas/PaginatedMeta'
              example:
                data:
                  - id: '4961849'
                    address:
                      street: 157 West 57th Street
                      unit: '#48B'
                      city: NEW YORK
                      state: NY
                      zipCode: '10019'
                    price: 27000
                    bedroomCount: 2
                    fullBathroomCount: 2
                    halfBathroomCount: 1
                    buildingType: CONDO
                    areaName: Midtown
                    status: ACTIVE
                    geoPoint:
                      latitude: 40.7654
                      longitude: -73.9791
                    leadPhotoKey: ed2987594843debc8e828fef155dbb6d
                    sourceGroupLabel: Douglas Elliman
                    urlPath: /building/one57-condominium/48b
                    listingType: rental
                meta:
                  total: 142
                  page: 1
                  perPage: 50
                  dataAge: '2026-02-12T14:30:00Z'
                  source: cached
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /search/sales:
    get:
      operationId: searchSales
      summary: Search sale listings
      description: |
        Search active NYC sale listings. Supports all rental filters plus
        `saleType` (resale, sponsor, foreclosure).

        **Tier:** Free+
      tags:
        - Search
      security: []
      parameters:
        - $ref: '#/components/parameters/areas'
        - $ref: '#/components/parameters/minPrice'
        - $ref: '#/components/parameters/maxPrice'
        - $ref: '#/components/parameters/minBeds'
        - $ref: '#/components/parameters/maxBeds'
        - $ref: '#/components/parameters/minBaths'
        - $ref: '#/components/parameters/maxBaths'
        - $ref: '#/components/parameters/buildingType'
        - name: saleType
          in: query
          description: Filter by sale type
          schema:
            type: string
            enum:
              - RESALE
              - SPONSOR_UNIT
              - FORECLOSURE
        - $ref: '#/components/parameters/minDaysOnMarket'
        - $ref: '#/components/parameters/maxDaysOnMarket'
        - $ref: '#/components/parameters/listedAfter'
        - $ref: '#/components/parameters/listedBefore'
        - $ref: '#/components/parameters/hasOpenHouse'
        - $ref: '#/components/parameters/has3dTour'
        - $ref: '#/components/parameters/minSqft'
        - $ref: '#/components/parameters/maxSqft'
        - $ref: '#/components/parameters/petsAllowed'
        - $ref: '#/components/parameters/amenities'
        - $ref: '#/components/parameters/maxMonthlyCost'
        - $ref: '#/components/parameters/minTransitScore'
        - $ref: '#/components/parameters/fiberOnly'
        - $ref: '#/components/parameters/minDownloadMbps'
        - $ref: '#/components/parameters/sort'
        - $ref: '#/components/parameters/sortDir'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/perPage'
      responses:
        '200':
          description: Paginated list of sale listings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ListingSummary'
                  meta:
                    $ref: '#/components/schemas/PaginatedMeta'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /property/{id}:
    get:
      operationId: getProperty
      summary: Get listing detail
      description: >
        Full listing detail including pricing, availability, sale and rental

        flags, building linkage, and freshness metadata. Related fees, price

        history, and open houses are available via dedicated subresource
        endpoints.


        **Tier:** Starter+
      tags:
        - Property
      parameters:
        - name: id
          in: path
          required: true
          description: Listing ID
          schema:
            type: string
          example: '4961849'
      responses:
        '200':
          description: Full listing detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ListingDetail'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /property/by-url:
    get:
      operationId: getPropertyByUrl
      summary: Look up listing by URL path
      description: >
        Find a listing using its URL path (e.g.,
        `/building/one57-condominium/48b`).


        **Tier:** Starter+
      tags:
        - Property
      parameters:
        - name: url
          in: query
          required: true
          description: Listing URL path
          schema:
            type: string
          example: /building/one57-condominium/48b
      responses:
        '200':
          description: Full listing detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/ListingDetail'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /property/{id}/history:
    get:
      operationId: getPropertyHistory
      summary: Get price history
      description: |
        Complete price history and event timeline for a listing. Includes
        price increases, decreases, listing date, and delisting events.

        **Tier:** Pro+
      tags:
        - Property
      parameters:
        - name: id
          in: path
          required: true
          description: Listing ID
          schema:
            type: string
      responses:
        '200':
          description: Price history timeline
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PriceEvent'
                  meta:
                    $ref: '#/components/schemas/Meta'
              example:
                data:
                  - date: '2026-01-15'
                    price: 5222
                    event: LISTED
                    pctChange: null
                  - date: '2026-02-01'
                    price: 4900
                    event: PRICE_DECREASE
                    pctChange: -6.2
                meta:
                  dataAge: '2026-02-12T14:30:00Z'
                  source: cached
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /property/{id}/fees:
    get:
      operationId: getPropertyFees
      summary: Get listing fees
      description: |
        Fee breakdown for a listing including broker fees, application fees,
        and other move-in costs.

        **Tier:** Starter+
      tags:
        - Property
      parameters:
        - name: id
          in: path
          required: true
          description: Listing ID
          schema:
            type: string
      responses:
        '200':
          description: Fee breakdown
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Fee'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /property/{id}/open-houses:
    get:
      operationId: getPropertyOpenHouses
      summary: Get upcoming open houses
      description: |
        Scheduled open houses for a listing. Only returns future events.

        **Tier:** Starter+
      tags:
        - Property
      parameters:
        - name: id
          in: path
          required: true
          description: Listing ID
          schema:
            type: string
      responses:
        '200':
          description: Upcoming open houses
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/OpenHouse'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /building/{id}:
    get:
      operationId: getBuilding
      summary: Get building detail
      description: |
        Comprehensive building data including amenities, policies, developer,
        architect, NYC regulatory data (community district, police precinct,
        building class), and tax/management details.

        **Tier:** Starter+
      tags:
        - Building
      parameters:
        - name: id
          in: path
          required: true
          description: Building ID
          schema:
            type: string
          example: '2555'
      responses:
        '200':
          description: Full building detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BuildingDetail'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /building/{id}/listings:
    get:
      operationId: getBuildingListings
      summary: Get listings in building
      description: |
        All active (or filtered) listings within a specific building.

        **Tier:** Starter+
      tags:
        - Building
      parameters:
        - name: id
          in: path
          required: true
          description: Building ID
          schema:
            type: string
        - name: status
          in: query
          description: Filter by listing status
          schema:
            type: string
            enum:
              - ACTIVE
              - IN_CONTRACT
              - OFF_MARKET
        - name: listingType
          in: query
          description: Filter by listing type
          schema:
            type: string
            enum:
              - rental
              - sale
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/perPage'
      responses:
        '200':
          description: Listings in building
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/ListingSummary'
                  meta:
                    $ref: '#/components/schemas/PaginatedMeta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /building/{id}/scores:
    get:
      operationId: getBuildingScores
      summary: Get building quality scores
      description: |
        Composite quality scores (0-100) derived from NYC Open Data:
        - **health_score**: Based on HPD violation count and severity
        - **noise_score**: Based on 311 noise complaints within 200m
        - **safety_score**: Based on NYPD crime data within 400m
        - **transit_score**: Based on subway/bus stop proximity and density

        Scores are updated weekly. Not all buildings have all scores;
        missing scores are returned as `null`.

        **Tier:** Starter+
      tags:
        - Building
      parameters:
        - name: id
          in: path
          required: true
          description: Building ID
          schema:
            type: string
      responses:
        '200':
          description: Building quality scores
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/BuildingScores'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /building/{id}/violations:
    get:
      operationId: getBuildingViolations
      summary: Get HPD violations
      description: |
        HPD (Housing Preservation & Development) violations for a building,
        ordered by date descending. Includes violation class (A/B/C),
        status, and description.

        Only available for buildings with a known BBL (Borough-Block-Lot).

        **Tier:** Starter+
      tags:
        - Building
      parameters:
        - name: id
          in: path
          required: true
          description: Building ID
          schema:
            type: string
      responses:
        '200':
          description: HPD violations list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/HpdViolation'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /areas:
    get:
      operationId: listAreas
      summary: List areas and neighborhoods
      description: |
        Returns all 349 NYC areas including boroughs, sub-boroughs, and
        neighborhoods. Optionally filter by level or parent area.

        **Tier:** Free+
      tags:
        - Areas
      security: []
      parameters:
        - name: level
          in: query
          description: 'Filter by hierarchy level: 0=borough, 1=sub-borough, 2=neighborhood'
          schema:
            type: integer
            enum:
              - 0
              - 1
              - 2
        - name: parentId
          in: query
          description: >-
            Filter by parent area ID (100=Manhattan, 200=Bronx, 300=Brooklyn,
            400=Queens, 500=Staten Island)
          schema:
            type: integer
        - name: q
          in: query
          description: >-
            Case-insensitive area name or slug lookup. Exact name matches are
            returned first, followed by broader suggestions.
          schema:
            type: string
            minLength: 1
        - name: include
          in: query
          description: Include optional boundary data in each area record.
          schema:
            type: string
            enum:
              - boundary
      responses:
        '200':
          description: List of areas
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Area'
                  meta:
                    $ref: '#/components/schemas/PaginatedMeta'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalError'
  /market/rentals/snapshot:
    get:
      operationId: getMarketSnapshot
      summary: Get market snapshot for an area
      description: |
        Pre-computed market statistics for a specific area: active inventory,
        median rent, average days on market, and concession rate. Optionally
        filter by bedroom count and listing type.

        Snapshots are recomputed daily at 5am UTC.

        **Tier:** Starter+
      tags:
        - Market
      parameters:
        - name: areaId
          in: query
          required: true
          description: Area ID (see `/areas` for the full list)
          schema:
            type: integer
          example: 120
        - name: bedrooms
          in: query
          description: Filter by bedroom count (0=studio, 1-4)
          schema:
            type: integer
            minimum: 0
            maximum: 4
        - name: listingType
          in: query
          description: Listing type
          schema:
            type: string
            enum:
              - rental
              - sale
            default: rental
      responses:
        '200':
          description: Market snapshots for the area
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketSnapshot'
                  meta:
                    $ref: '#/components/schemas/Meta'
              example:
                data:
                  - areaId: 120
                    bedrooms: 1
                    listingType: rental
                    inventory: 245
                    medianRent: 3500
                    avgDaysOnMarket: 28.5
                    concessionRate: 0.12
                    computedAt: '2026-02-15T05:00:00Z'
                meta:
                  dataAge: '2026-02-15T05:00:00Z'
                  source: cached
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /market/rentals/trends:
    get:
      operationId: getMarketTrends
      summary: Get market trends for an area
      description: |
        Price event time series for an area, grouped by date and event type.
        Returns up to 365 days of data. Useful for charting price movement
        and listing/delisting activity.

        **Tier:** Starter+
      tags:
        - Market
      parameters:
        - name: areaId
          in: query
          required: true
          description: Area ID
          schema:
            type: integer
        - name: listingType
          in: query
          description: Listing type
          schema:
            type: string
            enum:
              - rental
              - sale
            default: rental
      responses:
        '200':
          description: Time series of price events
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketTrend'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /market/compare:
    get:
      operationId: compareMarkets
      summary: Compare market snapshots across areas
      description: |
        Side-by-side market snapshots for up to 5 areas. Returns snapshot
        data grouped by area ID, with each area containing snapshots broken
        down by bedroom count.

        **Tier:** Starter+
      tags:
        - Market
      parameters:
        - name: areas
          in: query
          required: true
          description: Comma-separated area IDs (1-5)
          schema:
            type: string
          example: 120,200,300
        - name: bedrooms
          in: query
          description: Filter by bedroom count (0=studio, 1-4)
          schema:
            type: integer
            minimum: 0
            maximum: 4
        - name: listingType
          in: query
          description: Listing type
          schema:
            type: string
            enum:
              - rental
              - sale
            default: rental
      responses:
        '200':
          description: Market comparison across areas
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/MarketComparison'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /webhooks:
    get:
      operationId: listWebhooks
      summary: List webhook subscriptions
      description: |
        List all active webhook subscriptions for the authenticated account.

        **Tier:** Business
      tags:
        - Webhooks
      responses:
        '200':
          description: List of webhook subscriptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookSubscription'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      operationId: createWebhook
      summary: Register a webhook
      description: |
        Register a URL to receive real-time notifications when listings match
        your criteria. Returns a signing secret for payload verification.

        **Tier:** Business
      tags:
        - Utility
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCreateRequest'
            example:
              url: https://myapp.com/webhooks/borough
              events:
                - listing.created
                - listing.price_decreased
      responses:
        '201':
          description: Webhook registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookSubscriptionWithSecret'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /webhooks/{id}:
    delete:
      operationId: deleteWebhook
      summary: Delete a webhook
      description: Remove a webhook subscription. Deliveries stop immediately.
      tags:
        - Webhooks
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Webhook deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    patch:
      operationId: updateWebhook
      summary: Update a webhook
      description: Update the URL, events, or filters for an existing webhook subscription.
      tags:
        - Webhooks
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookUpdateRequest'
      responses:
        '200':
          description: Webhook updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WebhookSubscription'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /health:
    get:
      operationId: healthCheck
      summary: Health check
      description: Public uptime check. Returns service status and current timestamp.
      tags:
        - Utility
      security: []
      responses:
        '200':
          description: Service healthy
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - timestamp
                properties:
                  status:
                    type: string
                    example: ok
                  timestamp:
                    type: string
                    format: date-time
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalError'
  /listing/{id}:
    get:
      operationId: streamListing
      summary: Stream listing updates (SSE)
      description: >
        Server-Sent Events stream that delivers cached data immediately from D1,

        then attempts a live fetch via Decodo Scraping API. Events: `cached`,
        `live`,

        `error`, `done`. Error payloads include a machine-readable `code`, such
        as

        `fetch_failed`.


        **Tier:** Pro+
      tags:
        - Streaming
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Listing ID
      responses:
        '200':
          description: Server-Sent Events stream
          content:
            text/event-stream:
              schema:
                type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /ws/listing/{id}:
    get:
      operationId: wsListing
      summary: Open listing WebSocket stream (deprecated)
      deprecated: true
      description: >
        **Deprecated.** Use `POST /v1/watchers` + `GET /v1/watchers/{id}/stream`
        instead.


        Initiates a WebSocket upgrade for listing updates.


        **Tier:** Business
      tags:
        - Utility
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Listing ID
      responses:
        '101':
          description: WebSocket protocol switch
        '200':
          description: Upgrade not requested; returns error
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /watchers:
    post:
      operationId: createWatcher
      summary: Create a watcher
      description: >
        Create a persistent watcher that monitors a listing, building, or search
        for changes.

        The watcher uses a Durable Object that polls at the configured interval
        via Decodo

        Scraping API. Each poll counts as one API request against your monthly
        quota.


        **Tier:** Business+
      tags:
        - Watchers
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WatcherCreateRequest'
      responses:
        '201':
          description: Watcher created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/WatcherCreateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      operationId: listWatchers
      summary: List watchers
      description: |
        List all watcher subscriptions for the authenticated customer.

        **Tier:** Business+
      tags:
        - Watchers
      responses:
        '200':
          description: List of watchers
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Watcher'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /watchers/{id}:
    get:
      operationId: getWatcher
      summary: Get watcher detail
      description: |
        Returns full detail for a single watcher subscription.

        **Tier:** Business+
      tags:
        - Watchers
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Watcher ID (UUID)
      responses:
        '200':
          description: Watcher detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Watcher'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    patch:
      operationId: updateWatcher
      summary: Update a watcher
      description: >
        Update the poll interval or active status of a watcher. Setting `active:
        false`

        pauses the watcher without deleting it.


        **Tier:** Business+
      tags:
        - Watchers
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Watcher ID (UUID)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WatcherUpdateRequest'
      responses:
        '200':
          description: Watcher updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      operationId: deleteWatcher
      summary: Delete a watcher
      description: |
        Permanently deletes a watcher and stops the Durable Object alarm.

        **Tier:** Business+
      tags:
        - Watchers
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Watcher ID (UUID)
      responses:
        '204':
          description: Watcher deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /watchers/{id}/stream:
    get:
      operationId: streamWatcher
      summary: Stream watcher changes (SSE)
      description: >
        Opens a Server-Sent Events stream that delivers real-time change events
        from a watcher.

        Events include `connected`, `change`, `heartbeat`, `reconnect`,
        `stopped`, and `error`.


        Each poll interval, the stream checks the Durable Object for changes and
        emits events.


        **Tier:** Business+
      tags:
        - Watchers
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Watcher ID (UUID)
      responses:
        '200':
          description: Server-Sent Events stream
          content:
            text/event-stream:
              schema:
                type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /webhooks/polar:
    post:
      operationId: polarWebhook
      summary: Receive Polar webhook callback
      description: >-
        Inbound Polar billing webhook secured by HMAC-SHA256 using
        POLAR_WEBHOOK_SECRET.
      tags:
        - Utility
      security: []
      servers:
        - url: https://borough.qwady.app
          description: Root API host for inbound webhook callbacks
      responses:
        '200':
          description: Webhook accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  received:
                    type: boolean
                required:
                  - received
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/TierRestricted'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: BOROUGH-<uuid>
      description: 'Polar.sh license key. Format: `BOROUGH-<uuid>`'
  parameters:
    areas:
      name: areas
      in: query
      description: >-
        Comma-separated area IDs (e.g., `120,115`). See `/areas` for the full
        list.
      schema:
        type: string
      example: 120,115
    minPrice:
      name: minPrice
      in: query
      schema:
        type: integer
      example: 2000
    maxPrice:
      name: maxPrice
      in: query
      schema:
        type: integer
      example: 5000
    minBeds:
      name: minBeds
      in: query
      schema:
        type: integer
      example: 1
    maxBeds:
      name: maxBeds
      in: query
      schema:
        type: integer
      example: 3
    minBaths:
      name: minBaths
      in: query
      schema:
        type: integer
    maxBaths:
      name: maxBaths
      in: query
      schema:
        type: integer
    minSqft:
      name: minSqft
      in: query
      description: Minimum square footage
      schema:
        type: integer
        minimum: 1
      example: 500
    maxSqft:
      name: maxSqft
      in: query
      description: Maximum square footage
      schema:
        type: integer
        minimum: 1
      example: 1200
    buildingType:
      name: buildingType
      in: query
      description: Comma-separated building types
      schema:
        type: string
      example: CONDO,CO_OP
    noFee:
      name: noFee
      in: query
      schema:
        type: boolean
    petsAllowed:
      name: petsAllowed
      in: query
      description: Filter to listings that explicitly allow pets
      schema:
        type: boolean
    amenities:
      name: amenities
      in: query
      description: >-
        Comma-separated amenity codes. Listings must match all requested
        amenities across listing- and building-level data.
      schema:
        type: string
      example: DISHWASHER,WASHER_DRYER
    minDaysOnMarket:
      name: minDaysOnMarket
      in: query
      description: Minimum days on market
      schema:
        type: integer
        minimum: 0
    maxDaysOnMarket:
      name: maxDaysOnMarket
      in: query
      description: Maximum days on market
      schema:
        type: integer
        minimum: 0
    listedAfter:
      name: listedAfter
      in: query
      description: Only listings created after this date (ISO 8601)
      schema:
        type: string
        format: date-time
      example: '2026-01-01T00:00:00Z'
    listedBefore:
      name: listedBefore
      in: query
      description: Only listings created before this date (ISO 8601)
      schema:
        type: string
        format: date-time
    hasOpenHouse:
      name: hasOpenHouse
      in: query
      description: Filter to listings with a scheduled open house
      schema:
        type: boolean
    has3dTour:
      name: has3dTour
      in: query
      description: Filter to listings with a 3D virtual tour
      schema:
        type: boolean
    maxMonthlyCost:
      name: maxMonthlyCost
      in: query
      description: >-
        Maximum total monthly cost (price + maintenance + common charges +
        property tax). Useful for sales.
      schema:
        type: integer
        minimum: 0
    minTransitScore:
      name: minTransitScore
      in: query
      description: Minimum transit score (0-100) based on proximity to subway/bus stops
      schema:
        type: integer
        minimum: 0
        maximum: 100
    fiberOnly:
      name: fiberOnly
      in: query
      description: >-
        Require a building-level FCC broadband provider record with fiber
        technology. Provisional while Borough validates the underlying FCC and
        CostQuest contract path for further rollout.
      schema:
        type: boolean
    minDownloadMbps:
      name: minDownloadMbps
      in: query
      description: >-
        Require a building-level FCC broadband provider record with at least
        this advertised download speed in Mbps. Provisional while Borough
        validates the underlying FCC and CostQuest contract path for further
        rollout.
      schema:
        type: integer
        minimum: 0
    sort:
      name: sort
      in: query
      description: Sort attribute
      schema:
        type: string
        enum:
          - RECOMMENDED
          - PRICE
          - LISTED_AT
        default: PRICE
    sortDir:
      name: sortDir
      in: query
      description: Sort direction
      schema:
        type: string
        enum:
          - ASC
          - DESC
        default: DESC
    page:
      name: page
      in: query
      description: Page number (1-indexed)
      schema:
        type: integer
        minimum: 1
        default: 1
    perPage:
      name: perPage
      in: query
      description: >-
        Results per page. Maximum depends on tier: Free=10, Starter=50,
        Pro/Business=500.
      schema:
        type: integer
        minimum: 1
        maximum: 500
        default: 50
  headers:
    X-Request-Id:
      description: >-
        Unique trace ID for this request (UUID v4). Include in support requests
        for log correlation.
      schema:
        type: string
        format: uuid
    X-RateLimit-Limit:
      description: Maximum requests per minute
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Remaining requests this minute
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix timestamp when rate limit resets
      schema:
        type: integer
    X-Quota-Limit:
      description: Total requests allowed this billing period
      schema:
        type: integer
    X-Quota-Used:
      description: Requests consumed this billing period
      schema:
        type: integer
    X-Quota-Remaining:
      description: Remaining requests this billing period
      schema:
        type: integer
  responses:
    BadRequest:
      description: Invalid parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: INVALID_PARAMS
              message: Parameter 'maxPrice' must be a positive integer.
              status: 400
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: INVALID_API_KEY
              message: The provided API key is invalid or expired.
              status: 401
    TierRestricted:
      description: Endpoint not available on current tier
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: TIER_RESTRICTED
              message: This endpoint requires a Pro subscription or higher.
              status: 403
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: NOT_FOUND
              message: Listing '9999999' not found.
              status: 404
    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until rate limit resets
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: RATE_LIMIT_EXCEEDED
              message: Rate limit exceeded. Retry after 32 seconds.
              status: 429
              retryAfter: 32
    InternalError:
      description: Unexpected server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: INTERNAL_ERROR
              message: >-
                An unexpected error occurred. Please retry or contact support
                with the X-Request-Id.
              status: 500
  schemas:
    Watcher:
      type: object
      properties:
        id:
          type: string
          format: uuid
        watchType:
          type: string
          enum:
            - listing
            - building
            - search
        listingId:
          type:
            - string
            - 'null'
        buildingId:
          type:
            - string
            - 'null'
        searchFilters:
          type:
            - object
            - 'null'
          additionalProperties: true
        pollInterval:
          type: integer
          description: Seconds between polls
        active:
          type: boolean
        lastPollAt:
          type:
            - string
            - 'null'
          format: date-time
        lastChangeAt:
          type:
            - string
            - 'null'
          format: date-time
        createdAt:
          type: string
          format: date-time
    WatcherCreateRequest:
      type: object
      required:
        - watchType
      properties:
        watchType:
          type: string
          enum:
            - listing
            - building
            - search
        listingId:
          type: string
        buildingId:
          type: string
        searchFilters:
          type: object
          additionalProperties: true
        pollInterval:
          type: integer
          description: >-
            Seconds between polls. Min 300 (Business), 60 (Internal). Default
            900.
          default: 900
    WatcherCreateResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        watchType:
          type: string
          enum:
            - listing
            - building
            - search
        targetId:
          type: string
        pollInterval:
          type: integer
        active:
          type: boolean
        createdAt:
          type: string
          format: date-time
    WatcherUpdateRequest:
      type: object
      properties:
        pollInterval:
          type: integer
        active:
          type: boolean
    Meta:
      type: object
      properties:
        dataAge:
          type:
            - string
            - 'null'
          format: date-time
          description: When the underlying data was last refreshed (ISO 8601)
        source:
          type: string
          enum:
            - cached
            - stale
            - live
          description: >
            How this data was served:

            - `cached`: From D1 database, within freshness threshold

            - `stale`: From D1 but older than tier's freshness threshold;
            background refresh queued for paid tiers

            - `live`: Fetched live from source (Business/Internal tiers on
            property and building detail endpoints)
        freshnessThreshold:
          type:
            - integer
            - 'null'
          description: >-
            Freshness threshold in minutes for the current tier. Data older than
            this triggers a background refresh (paid tiers only).
        refreshTriggered:
          type:
            - boolean
            - 'null'
          description: Whether a background refresh was triggered for this request
    PaginatedMeta:
      allOf:
        - $ref: '#/components/schemas/Meta'
        - type: object
          properties:
            total:
              type: integer
            page:
              type: integer
            perPage:
              type: integer
            links:
              $ref: '#/components/schemas/PaginationLinks'
    PaginationLinks:
      type: object
      description: Pagination navigation links
      properties:
        self:
          type: string
          format: uri
        first:
          type: string
          format: uri
        last:
          type: string
          format: uri
        prev:
          type:
            - string
            - 'null'
          format: uri
        next:
          type:
            - string
            - 'null'
          format: uri
    Address:
      type: object
      properties:
        street:
          type: string
          example: 157 West 57th Street
        unit:
          type:
            - string
            - 'null'
          example: '#48B'
        city:
          type: string
          example: NEW YORK
        state:
          type: string
          example: NY
        zipCode:
          type: string
          example: '10019'
    GeoPoint:
      type: object
      properties:
        latitude:
          type: number
          format: double
          example: 40.7654
        longitude:
          type: number
          format: double
          example: -73.9791
    ListingSummary:
      type: object
      properties:
        id:
          type: string
        address:
          $ref: '#/components/schemas/Address'
        price:
          type: integer
        netEffective:
          type:
            - integer
            - 'null'
        monthsFree:
          type:
            - integer
            - 'null'
        leaseTermMonths:
          type:
            - integer
            - 'null'
        bedroomCount:
          type:
            - integer
            - 'null'
        fullBathroomCount:
          type:
            - integer
            - 'null'
        halfBathroomCount:
          type:
            - integer
            - 'null'
        buildingType:
          oneOf:
            - type: string
              enum:
                - CONDO
                - CO_OP
                - RENTAL
                - TOWNHOUSE
                - MULTI_FAMILY
                - HOUSE
                - TWOFAMILY
                - THREEFAMILY
                - MIXED_USE
            - type: 'null'
        areaName:
          type:
            - string
            - 'null'
        areaId:
          type:
            - integer
            - 'null'
        status:
          type: string
          enum:
            - ACTIVE
            - IN_CONTRACT
            - OFF_MARKET
        geoPoint:
          oneOf:
            - $ref: '#/components/schemas/GeoPoint'
            - type: 'null'
        leadPhotoKey:
          type:
            - string
            - 'null'
          description: >-
            32-char hex key. Construct URL:
            `https://photos.zillowstatic.com/fp/{key}-se_large_800_400.webp`
        sourceGroupLabel:
          type:
            - string
            - 'null'
          description: Brokerage name
        urlPath:
          type: string
        listingType:
          type: string
          enum:
            - rental
            - sale
    ListingDetail:
      allOf:
        - $ref: '#/components/schemas/ListingSummary'
        - type: object
          properties:
            sqft:
              type:
                - integer
                - 'null'
            saleType:
              oneOf:
                - type: string
                  enum:
                    - RESALE
                    - SPONSOR_UNIT
                    - FORECLOSURE
                - type: 'null'
            noFee:
              type: boolean
            petsAllowed:
              type:
                - boolean
                - 'null'
            amenities:
              type:
                - array
                - 'null'
              items:
                type: string
            furnished:
              type: boolean
            availableAt:
              type:
                - string
                - 'null'
              format: date
            listedAt:
              type:
                - string
                - 'null'
              format: date-time
            daysOnMarket:
              type:
                - integer
                - 'null'
            floorPlanKey:
              type:
                - string
                - 'null'
            has3dTour:
              type: boolean
            hasVideo:
              type: boolean
            buildingId:
              type:
                - string
                - 'null'
            priceDelta:
              type:
                - integer
                - 'null'
            priceChangedAt:
              type:
                - string
                - 'null'
              format: date-time
            detailScraped:
              type: boolean
            maintenance:
              type:
                - integer
                - 'null'
            commonCharges:
              type:
                - integer
                - 'null'
            propertyTax:
              type:
                - integer
                - 'null'
            virtualTourUrl:
              type:
                - string
                - 'null'
            internetProviders:
              oneOf:
                - $ref: '#/components/schemas/InternetProviders'
                - type: 'null'
    Fee:
      type: object
      properties:
        typeLabel:
          type: string
          example: Broker Fee
        requirement:
          type:
            - string
            - 'null'
        refundability:
          type:
            - string
            - 'null'
        calcType:
          type:
            - string
            - 'null'
        amount:
          type:
            - number
            - 'null'
        timing:
          type:
            - string
            - 'null'
        occurrence:
          type:
            - string
            - 'null'
    Contact:
      type: object
      properties:
        name:
          type: string
        phone:
          type:
            - string
            - 'null'
        sourceGroup:
          type: string
          description: Brokerage name
    PriceEvent:
      type: object
      properties:
        date:
          type:
            - string
            - 'null'
          format: date
        price:
          type: integer
        event:
          type: string
          enum:
            - LISTED
            - PRICE_INCREASE
            - PRICE_DECREASE
            - DELISTED
            - RENTED
        pctChange:
          type:
            - number
            - 'null'
    BuildingDetail:
      type: object
      properties:
        id:
          type: string
        slug:
          type:
            - string
            - 'null'
        name:
          type:
            - string
            - 'null'
        addressStreet:
          type:
            - string
            - 'null'
        yearBuilt:
          type:
            - integer
            - 'null'
        floorCount:
          type:
            - integer
            - 'null'
        unitCount:
          type:
            - integer
            - 'null'
        buildingType:
          type:
            - string
            - 'null'
        areaId:
          type:
            - integer
            - 'null'
        geoPoint:
          oneOf:
            - $ref: '#/components/schemas/GeoPoint'
            - type: 'null'
        amenities:
          type:
            - object
            - 'null'
          additionalProperties: true
        policies:
          type:
            - object
            - 'null'
          additionalProperties: true
        nycData:
          type:
            - object
            - 'null'
          additionalProperties: true
        additionalDetails:
          type:
            - object
            - 'null'
          additionalProperties: true
        taxAbatementStatus:
          type:
            - string
            - 'null'
        taxAbatementYearsRemaining:
          type:
            - integer
            - 'null'
        managementCompany:
          type:
            - string
            - 'null'
        yearRenovated:
          type:
            - integer
            - 'null'
        bbl:
          type:
            - string
            - 'null'
        internetProviders:
          oneOf:
            - $ref: '#/components/schemas/InternetProviders'
            - type: 'null'
    InternetProviderOffering:
      type: object
      properties:
        providerId:
          type: integer
        providerName:
          type: string
        holdingCompanyName:
          type:
            - string
            - 'null'
        technologyCode:
          type: integer
        technologyLabel:
          type: string
        maxDownloadMbps:
          type:
            - integer
            - 'null'
        maxUploadMbps:
          type:
            - integer
            - 'null'
        lowLatency:
          type:
            - boolean
            - 'null'
    InternetProviders:
      type: object
      description: >-
        Provisional FCC-backed building-level availability block. As of March
        2-3, 2026, Borough observed live Broadband Map browser traffic using
        fabric endpoints with CostQuest license language restricting
        unauthorized scripted or programmatic access, so this schema is pending
        contract validation and may change before further rollout.
      properties:
        status:
          type: string
          enum:
            - matched
            - unmatched
            - error
        source:
          type: string
          enum:
            - fcc_bdc
        coverageLevel:
          type: string
          enum:
            - building
        fccLocationId:
          description: >-
            Current Borough response field for the matched FCC location
            identifier. This field is provisional pending contract validation
            and may change shape in a future revision.
          type:
            - integer
            - 'null'
        matchedAddress:
          type:
            - string
            - 'null'
        unitCount:
          type:
            - integer
            - 'null'
        updatedAt:
          type:
            - string
            - 'null'
          format: date-time
        providers:
          type: array
          items:
            $ref: '#/components/schemas/InternetProviderOffering'
        disclaimer:
          type: string
    InventorySummary:
      type: object
      properties:
        activeCount:
          type: integer
        byBedroom:
          type: array
          items:
            type: object
            properties:
              bedrooms:
                type: string
                description: Bedroom count as string (e.g., '1', '2', 'studio')
              availableCount:
                type: integer
              minPrice:
                type:
                  - integer
                  - 'null'
              maxPrice:
                type:
                  - integer
                  - 'null'
              historicalCount:
                type: integer
              historicalMinPrice:
                type:
                  - integer
                  - 'null'
              historicalMaxPrice:
                type:
                  - integer
                  - 'null'
    Area:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        level:
          type: integer
          description: 0=borough, 1=sub-borough, 2=neighborhood
        parentId:
          type:
            - integer
            - 'null'
        slug:
          type:
            - string
            - 'null'
        encodedBoundary:
          type:
            - string
            - 'null'
    OpenHouse:
      type: object
      properties:
        startTime:
          type: string
          format: date-time
        endTime:
          type: string
          format: date-time
        status:
          type:
            - string
            - 'null'
    BuildingScores:
      type: object
      properties:
        buildingId:
          type: string
        healthScore:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: >-
            HPD violation severity score. 100 = no violations, lower =
            more/worse violations.
        noiseScore:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: >-
            311 noise complaint score. 100 = quiet area, lower = more complaints
            within 200m.
        safetyScore:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: >-
            NYPD crime score. 100 = low crime, lower = more incidents within
            400m.
        transitScore:
          type:
            - integer
            - 'null'
          minimum: 0
          maximum: 100
          description: >-
            Transit proximity score. 100 = excellent access, based on subway/bus
            stop distance and density.
        violationsOpen:
          type:
            - integer
            - 'null'
          description: Count of open HPD violations
        updatedAt:
          type:
            - string
            - 'null'
          format: date-time
    HpdViolation:
      type: object
      properties:
        violationId:
          type: string
        status:
          type:
            - string
            - 'null'
          description: Violation status (e.g., Open, Close)
        class:
          type:
            - string
            - 'null'
          description: >-
            Severity class. A=non-hazardous, B=hazardous, C=immediately
            hazardous.
        description:
          type:
            - string
            - 'null'
        date:
          type: string
          format: date
    MarketSnapshot:
      type: object
      properties:
        areaId:
          type: integer
        bedrooms:
          type: integer
          description: 0=studio, 1-4
        listingType:
          type: string
          enum:
            - rental
            - sale
        inventory:
          type: integer
          description: Count of active listings
        medianRent:
          type:
            - number
            - 'null'
          description: Median asking price
        avgDaysOnMarket:
          type:
            - number
            - 'null'
          description: Average days on market for active listings
        concessionRate:
          type:
            - number
            - 'null'
          description: Fraction of listings offering concessions (0.0-1.0)
        computedAt:
          type:
            - string
            - 'null'
          format: date-time
    MarketTrend:
      type: object
      properties:
        date:
          type: string
          format: date
        event:
          type: string
          enum:
            - LISTED
            - PRICE_DROP
            - PRICE_INCREASE
            - REMOVED
            - RENTED
            - SOLD
        avgPrice:
          type: number
          description: Average price for this event type on this date
        count:
          type: integer
          description: Number of events
    MarketComparison:
      type: object
      properties:
        areaId:
          type: integer
        snapshots:
          type: array
          items:
            type: object
            properties:
              bedrooms:
                type: integer
              inventory:
                type: integer
              medianRent:
                type:
                  - number
                  - 'null'
              avgDaysOnMarket:
                type:
                  - number
                  - 'null'
              concessionRate:
                type:
                  - number
                  - 'null'
              computedAt:
                type:
                  - string
                  - 'null'
                format: date-time
    WebhookCreateRequest:
      type: object
      required:
        - url
        - events
      properties:
        url:
          type: string
          format: uri
          description: HTTPS endpoint to receive webhook payloads
        events:
          type: array
          items:
            type: string
            enum:
              - listing.created
              - listing.price_decreased
              - listing.price_increased
              - listing.status_changed
              - listing.expired
    WebhookUpdateRequest:
      type: object
      properties:
        url:
          type: string
          format: uri
          description: HTTPS endpoint to receive webhook payloads
        events:
          type: array
          items:
            type: string
            enum:
              - listing.created
              - listing.price_decreased
              - listing.price_increased
              - listing.status_changed
              - listing.expired
        active:
          type: boolean
    WebhookSubscription:
      type: object
      properties:
        id:
          type: string
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
        active:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type:
            - string
            - 'null'
          format: date-time
    WebhookSubscriptionWithSecret:
      allOf:
        - $ref: '#/components/schemas/WebhookSubscription'
        - type: object
          properties:
            secret:
              type: string
              description: HMAC-SHA256 signing secret for payload verification
    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          required:
            - code
            - message
            - status
          properties:
            code:
              type: string
              enum:
                - INVALID_PARAMS
                - MISSING_API_KEY
                - INVALID_API_KEY
                - EXPIRED_API_KEY
                - TIER_RESTRICTED
                - QUOTA_EXCEEDED
                - NOT_FOUND
                - WEBHOOK_NOT_FOUND
                - RATE_LIMIT_EXCEEDED
                - INTERNAL_ERROR
                - UPSTREAM_ERROR
                - SERVICE_DISABLED
            message:
              type: string
            status:
              type: integer
            retryAfter:
              type:
                - integer
                - 'null'
              description: Seconds to wait before retrying (for 429 responses)
