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

# Stream Watcher Changes

> Opens a Server-Sent Events stream that delivers a watcher's change events as
its scheduled polls detect them. The stream emits exactly these events:
`connected`, `change`, `heartbeat`, `reconnect`, and `stopped`.

Delivery is decoupled from the watcher's `pollInterval`: the watcher polls
upstream on its own schedule, while the stream drains whatever it has found
every 20 seconds, sending `heartbeat` on a tick with nothing new. A single
stream lasts at most 5 minutes and then sends `reconnect`; open a new one to
continue. `stopped` means the watcher was paused or deleted.

Every event carries an SSE `id` — a resume cursor. Reconnect with the standard
`Last-Event-ID` header, or with `since` on a first connect where the client
cannot set headers.

**Tier:** Business+


## SSE Events

The stream emits exactly five event types:

| Event       | Description                                                                                 |
| ----------- | ------------------------------------------------------------------------------------------- |
| `connected` | Stream opened. Payload carries `watcherId`, `watchType`, `targetId`, `pollInterval`.        |
| `change`    | A change was detected. Payload carries `eventType`, `listingId`, `oldValue`, `newValue`.    |
| `heartbeat` | Keep-alive: the stream checked for new changes and found none. Payload carries `timestamp`. |
| `reconnect` | The 5-minute stream lifetime was reached. Reconnect to continue.                            |
| `stopped`   | The watcher is no longer active (paused, deleted, or quota-exhausted).                      |

`heartbeat` is a connection keep-alive on the stream's own short drain cadence. It is
not tied to the watcher's `pollInterval` and does not mean an upstream poll just
completed.

The stream requires Business or Internal tier, and returns `400` if the watcher is
paused.

## Resuming

Every `change`, `heartbeat`, and `reconnect` event carries an SSE `id`: a cursor into
the watcher's change buffer, which retains the last 50 detected changes. Send that
cursor back and the server resumes from it rather than from the current tip.

* **`Last-Event-ID: <id>` header** — what browser `EventSource` and the Borough SDK
  replay automatically on reconnect. Wins if both are supplied.
* **`?since=<id>` query parameter** — for a first connect from a client that cannot set
  request headers.

A connection with no cursor starts at the buffer tip, so it delivers only changes
detected from that point on. The `connected` event carries no `id`.

See the [streaming guide](/borough/guides/streaming#resuming-without-missing-changes)
for worked examples and the retention caveats.


## OpenAPI

````yaml GET /watchers/{id}/stream
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, market, and photos) work without authentication but are
    tracked per IP and subject to

    IP-based rate limits of 10 requests per minute. The health check endpoint
    remains fully public.


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


    Tiered API responses include the `X-RateLimit-Limit` and `X-Quota-*`
    headers,

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

    Note that 429 responses additionally carry a `Retry-After` header and an

    `error.retryAfter` value in the body. The health check remains fully public

    and does not carry quota headers.


    ## Data Freshness


    Freshness-aware JSON data responses include 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`).


    Public cache cadence varies by dataset: rental search refreshes every 6h,

    sale search every 8h, listing detail sweeps every 12h, buildings daily at

    03:00 UTC, and market snapshots daily at 05:00 UTC. Broadband enrichment is

    checked monthly, but the underlying FCC source data is published only twice

    a year, so most checks are a no-op; broadband fields currently cover 76.5%

    of eligible buildings (those with an active listing and geocodes).


    Thresholds behave differently on detail routes than on search:


    - **Property and building detail**: a paid-tier request for data older than
      the tier's threshold queues a background refresh, so a subsequent request
      returns fresher data.
    - **Search**: crossing the threshold only changes the label. Results are
      returned as-is with `meta.source: "stale"` and **no refresh is queued**.
      Search freshness comes solely from the scrape cadence above, so a stale
      search generally means a scheduled scrape was missed. The 8h value is flat
      across every paid tier for that reason.
    - **Free tier**: never triggers a refresh on any route.


    | Tier     | Search Threshold (label only) | Listing Threshold | Building
    Threshold |

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

    | Starter  | 8h                            | 30min             |
    6h                 |

    | Pro      | 8h                            | 15min             |
    3h                 |

    | Business | 8h                            | 10min             |
    2h                 |


    ## Photo URLs


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

    Use the Borough proxy for first-party image URLs:

    ```

    https://borough.qwady.app/v1/photos/{key}

    ```

    Supported sizes:

    ```

    large_800_400 (default), medium_500_250

    ```
  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 that poll listings, buildings, and searches on a
      schedule
  - name: Streaming
    description: Server-Sent Events for live data and watcher changes
  - name: Utility
    description: Health checks and internal endpoints
paths:
  /watchers/{id}/stream:
    get:
      tags:
        - Watchers
      summary: Stream watcher changes (SSE)
      description: >
        Opens a Server-Sent Events stream that delivers a watcher's change
        events as

        its scheduled polls detect them. The stream emits exactly these events:

        `connected`, `change`, `heartbeat`, `reconnect`, and `stopped`.


        Delivery is decoupled from the watcher's `pollInterval`: the watcher
        polls

        upstream on its own schedule, while the stream drains whatever it has
        found

        every 20 seconds, sending `heartbeat` on a tick with nothing new. A
        single

        stream lasts at most 5 minutes and then sends `reconnect`; open a new
        one to

        continue. `stopped` means the watcher was paused or deleted.


        Every event carries an SSE `id` — a resume cursor. Reconnect with the
        standard

        `Last-Event-ID` header, or with `since` on a first connect where the
        client

        cannot set headers.


        **Tier:** Business+
      operationId: streamWatcher
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Watcher ID (UUID)
        - name: since
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
          description: >-
            Resume cursor — the SSE `id` of the last event you processed.
            Changes recorded after it are replayed, bounded by how many the
            watcher retains. Ignored when `Last-Event-ID` is present, which
            takes precedence. A connect with neither starts at the current tip
            instead of replaying.
      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'
components:
  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 or quota exceeded
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until rate limit resets
        X-Quota-Tier:
          $ref: '#/components/headers/X-Quota-Tier'
        X-Quota-Upgrade-URL:
          $ref: '#/components/headers/X-Quota-Upgrade-URL'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            rateLimit:
              summary: Per-minute rate limit exceeded
              value:
                error:
                  code: RATE_LIMIT_EXCEEDED
                  message: Rate limit exceeded. Retry after 32 seconds.
                  status: 429
                  retryAfter: 32
            quotaExceeded:
              summary: Monthly quota exhausted
              value:
                error:
                  code: QUOTA_EXCEEDED
                  message: >-
                    Monthly request quota exhausted. Upgrade your plan to
                    continue.
                  status: 429
                  quotaContext:
                    currentTier: starter
                    currentQuota: 5000
                    nextTier: pro
                    nextTierQuota: 25000
                    upgradeUrl: https://polar.sh/qwady-solutions-llc/portal
    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:
    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)
            quotaContext:
              type:
                - object
                - 'null'
              description: >-
                Upgrade context included on QUOTA_EXCEEDED (429) responses; null
                or absent otherwise.
              properties:
                currentTier:
                  type: string
                currentQuota:
                  type: integer
                nextTier:
                  type:
                    - string
                    - 'null'
                nextTierQuota:
                  type:
                    - integer
                    - 'null'
                upgradeUrl:
                  type: string
                  format: uri
  headers:
    X-Quota-Tier:
      description: The caller's current tier. Sent on QUOTA_EXCEEDED (429) responses.
      schema:
        type: string
    X-Quota-Upgrade-URL:
      description: URL to upgrade to a higher tier. Sent on QUOTA_EXCEEDED (429) responses.
      schema:
        type: string
        format: uri
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: BOROUGH-<uuid>
      description: 'Polar.sh license key. Format: `BOROUGH-<uuid>`'

````