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

# Streaming and Live Lookups

> Live listing lookups over SSE, plus watchers that monitor on a schedule

Borough offers two mechanisms for going beyond a plain cached read:

1. **Listing stream** (Pro+): a short-lived SSE stream that delivers cached data immediately, then one fresh live fetch
2. **Persistent watchers** (Business+): Durable Object-backed subscriptions that poll on a schedule and emit change events

## Listing Stream (SSE)

`GET /v1/listing/{id}` opens a Server-Sent Events stream for a single listing. The stream emits:

| Event    | Description                                                                                                         |
| -------- | ------------------------------------------------------------------------------------------------------------------- |
| `cached` | Immediate response from D1 cache                                                                                    |
| `live`   | Fresh data fetched live from the upstream source                                                                    |
| `error`  | Live fetch or parse could not complete; the payload includes a machine-readable error `code` such as `fetch_failed` |
| `done`   | Stream complete                                                                                                     |

```bash theme={null}
curl -N -H "Authorization: Bearer BOROUGH-..." \
  https://borough.qwady.app/v1/listing/4961849
```

```
event: cached
data: {"id":"4961849","price":3200,...}

event: live
data: {"id":"4961849","price":3150,...}

event: done
data: {"complete":true}
```

The cached event arrives immediately. The live event typically takes 2-5 seconds depending on upstream response time. If the live fetch fails, an `error` event is emitted with an error code and message, and the stream closes.

This is a short-lived stream: it completes once cached + live data have been delivered, usually within about 10 seconds. Size your client timeout off the worst case rather than the typical one, because a struggling upstream can take considerably longer: a single live fetch is allowed **30 seconds**, and an upstream rate-limit or unavailability buys one retry, so a stream can stay open for a little over a minute before it closes.

**Tier requirement:** Pro, Business, or Internal.

## Persistent Watchers

Watchers are long-lived subscriptions backed by Cloudflare Durable Objects. They support three watch types:

| Type       | Monitors              | Change Events                        |
| ---------- | --------------------- | ------------------------------------ |
| `listing`  | A single listing      | Price changes, status changes        |
| `building` | A building's listings | New listings added, listings removed |
| `search`   | A search query        | New listings matching your filters   |

### Creating a watcher

```bash theme={null}
curl -X POST https://borough.qwady.app/v1/watchers \
  -H "Authorization: Bearer BOROUGH-..." \
  -H "Content-Type: application/json" \
  -d '{"watchType":"listing","listingId":"4961849","pollInterval":600}'
```

### Poll intervals

Each poll costs one API request against your active quota window.

| Tier     | Minimum      | Default       |
| -------- | ------------ | ------------- |
| Business | 300s (5 min) | 900s (15 min) |
| Internal | 60s (1 min)  | 900s (15 min) |

### Streaming watcher changes

`GET /v1/watchers/{id}/stream` opens an SSE connection that emits change events:

| Event       | Description                                                   |
| ----------- | ------------------------------------------------------------- |
| `connected` | Stream opened, includes watcher metadata                      |
| `change`    | A change was detected (price drop, new listing, etc.)         |
| `heartbeat` | Keep-alive: the stream checked for new changes and found none |
| `reconnect` | Stream duration limit reached: reconnect                      |
| `stopped`   | Watcher is no longer active                                   |

These five are the complete set. No other event type is emitted on this stream.

<Note>
  `heartbeat` is **not** tied to your watcher's `pollInterval`. The stream drains the
  watcher's change buffer on its own short, fixed cadence (currently about every 20
  seconds) and emits a `heartbeat` whenever that drain turns up nothing new. Polling
  upstream happens separately, on the watcher's own schedule. A heartbeat therefore
  means "the connection is alive and there is nothing new to hand you" — it does not
  mean a poll just completed.
</Note>

```bash theme={null}
curl -N -H "Authorization: Bearer BOROUGH-..." \
  https://borough.qwady.app/v1/watchers/abc-123/stream
```

```
event: connected
data: {"watcherId":"abc-123","watchType":"listing","targetId":"4961849","pollInterval":600}

event: heartbeat
data: {"timestamp":"2026-02-19T12:00:00.000Z"}
id: 41

event: change
data: {"eventType":"listing.price_decreased","listingId":"4961849","oldValue":"3200","newValue":"3150"}
id: 42
```

The `id` on each event is a resume cursor — see [Resuming without missing
changes](#resuming-without-missing-changes).

### Managing watchers

* **List:** `GET /v1/watchers`
* **Detail:** `GET /v1/watchers/{id}`
* **Pause/resume:** `PATCH /v1/watchers/{id}` with `{"active": false}` or `{"active": true}`
* **Change interval:** `PATCH /v1/watchers/{id}` with `{"pollInterval": 600}`
* **Delete:** `DELETE /v1/watchers/{id}`

### Stream duration limit

Watcher SSE streams have a **5-minute maximum duration**. When the limit is reached, the server sends a `reconnect` event and closes the stream:

```
event: reconnect
data: {"reason":"Stream duration limit reached. Please reconnect."}
```

Clients should automatically reconnect when receiving this event.

### Resuming without missing changes

The watcher keeps a bounded buffer of the changes it has detected — the **last 50** —
and every `change`, `heartbeat`, and `reconnect` event carries an SSE `id` that is a
cursor into that buffer. Replay the cursor on your next connection and the server
resumes from there instead of from the current tip, so changes detected while nothing
was connected are delivered rather than skipped.

There are two ways to send the cursor:

| Mechanism                            | Use when                                                                                   |
| ------------------------------------ | ------------------------------------------------------------------------------------------ |
| `Last-Event-ID: <id>` request header | Reconnecting. This is what browser `EventSource` and the Borough SDK replay automatically. |
| `?since=<id>` query parameter        | First connect, where `EventSource` gives you no way to set a header.                       |

If both are present, `Last-Event-ID` wins — on an automatic reconnect it is the fresher
of the two. A cursor must be a non-negative integer; anything else is treated as absent,
which is the same as a fresh connect.

```bash theme={null}
# Resume after a reconnect event whose id was 42
curl -N -H "Authorization: Bearer BOROUGH-..." \
  -H "Last-Event-ID: 42" \
  https://borough.qwady.app/v1/watchers/abc-123/stream

# Or, on a first connect from a client that cannot set headers
curl -N -H "Authorization: Bearer BOROUGH-..." \
  "https://borough.qwady.app/v1/watchers/abc-123/stream?since=42"
```

A few things to keep in mind:

* **A connection with no cursor starts at the buffer tip.** You get changes detected
  from that point on, not the watcher's history. That is deliberate — otherwise every
  reconnect would replay the retained buffer as fresh alerts.
* **Retention is bounded.** If more than 50 changes accumulated while you were away,
  only what survives in the buffer can be replayed. A client that stays away that long
  gets what is left, not everything it missed.
* **`change` ids are per-change**, so a client that drops mid-batch resumes at the
  change it actually received rather than at the start of the batch.
* The `connected` event carries no `id` — it is stream metadata, not a buffer position.

### Quota impact

Each watcher alarm poll costs **1 API request** toward your active quota window and counts toward metered overage billing. A watcher polling every 15 minutes consumes approximately **2,880 requests per 30 days**. Listing watchers also make a live upstream fetch on each poll. Building and search watchers diff Borough's cached index instead, so they avoid a live fetch on every cycle.

If your quota (including overage headroom) is exhausted, the watcher is automatically
paused: its `active` field flips to `false` and it stops polling. An open SSE stream
sees this on its next drain and emits a `stopped` event, then closes. There is no
dedicated quota event on the SSE stream, and the `stopped` payload does not say why the
watcher stopped — a quota pause, a `PATCH` with `{"active": false}`, and a delete all
produce the same event. To distinguish them, read `GET /v1/watchers/{id}` (a deleted
watcher returns `404`) and check your usage. Reconnecting to a paused watcher returns
`400` until you reactivate it.

**Tier requirement:** Business or Internal.

## Choosing the Right Approach

| Scenario                                      | Recommended                                                     |
| --------------------------------------------- | --------------------------------------------------------------- |
| One-time fresh data for a listing             | Business tier regular endpoint, or listing stream (SSE) for Pro |
| Ongoing monitoring of a specific listing      | Watcher (listing type)                                          |
| Alert when new units appear in a building     | Watcher (building type)                                         |
| Alert when new listings match search criteria | Watcher (search type)                                           |
| Webhook delivery to your server               | Webhook subscription + watcher                                  |

<Note>
  **Business and Internal tiers** get live-first data automatically on `GET /v1/property/{id}` and `GET /v1/building/{id}`: no SSE stream needed for single lookups. The regular endpoint returns `"source": "live"` when data is fetched fresh. See [Data Freshness](/borough/guides/data-freshness#live-first-data-business--internal) for details.
</Note>
