Skip to main content
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 attempts one 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:
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 instead of live, followed by done, and the stream closes. The cached event you already received still stands. If the live page was fetched but could not be parsed, the error event carries only a message and no code. Live fetches draw on a live-fetch budget shared across Borough. When that budget is held for Borough’s scheduled updates, or live fetching is paused, the live attempt ends with budget_exceeded or service_disabled. Neither is specific to your key, and the stream still counts as one request. 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: a single live fetch is allowed 30 seconds, and an upstream rate-limit or unavailability buys one retry, but the whole stream is capped at 60 seconds. A stream that reaches the cap sends error with code cancelled, then done. Tier requirement: Pro, Business, or Internal.

Persistent Watchers

Watchers are long-lived subscriptions backed by Cloudflare Durable Objects. They support three watch types: For building and search watchers, listing.expired (with newValue: "removed") means the listing left the watched set. It may have gone off-market, or it may simply no longer match. A listing watcher can also receive a change that Borough’s scheduled refresh detected for the same listing, so the same price or status change can arrive twice. De-duplicate on eventType and newValue. Building watchers only see listings that Borough has linked to the building, and that link is made when Borough processes the listing’s detail page. That can be days or weeks after a unit is listed, and until then the unit is invisible to the watcher, so treat building alerts as a lagging signal. A building watcher can also emit a change with eventType: "building.listings_changed" whose listingId holds the building ID. It is a hint that Borough’s scheduled refresh saw a change to some listing in the building; it does not say which, so read the building’s listings to find out. Search watchers accept only these searchFilters keys; any other key returns 400 INVALID_PARAMS: A search watcher tracks at most 500 active listings. Filters that match more than 500 are refused with 400 INVALID_PARAMS, both when you create the watcher and when you resume it; narrow them with areas, minPrice/maxPrice, beds, listingType, or buildingType. Leave some headroom: a running search watcher whose matches later grow past 500 is paused (its stream sends stopped), and resuming it returns 400 while the filters still match more than 500. PATCH cannot change a watcher’s filters, so delete it and create a narrower one.

Creating a watcher

Poll intervals

Each poll costs one API request against your active quota window. A pollInterval below the minimum returns 400 INVALID_PARAMS.

Watcher limits

A Business customer can have up to 50 active watchers, of which at most 5 may be listing watchers. Listing watchers fetch the live listing on every poll, which is why they have a tighter cap and a longer interval; building and search watchers poll Borough’s own data. Creating a watcher past either cap returns 429 QUOTA_EXCEEDED, and resuming a paused one past either cap returns 400 INVALID_PARAMS. Paused watchers do not count toward the caps.

Streaming watcher changes

GET /v1/watchers/{id}/stream opens an SSE connection that emits change events: These five are the complete set. No other event type is emitted on this stream. While the watcher can’t be read, the stream also sends SSE comment lines (starting with :) to keep the connection open; most SSE clients ignore them. If the watcher couldn’t be read when you first connected, heartbeat and reconnect events carry no id until it can.
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.If the watcher is briefly unavailable, the stream stays open and heartbeats pause until it recovers, for at most the rest of the stream’s 5-minute lifetime. Don’t treat a missing heartbeat as a dropped connection or reconnect because of one.
The id on each event is a resume cursor — see 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": 3600} (at or above the minimum for the watch type)
  • Delete: DELETE /v1/watchers/{id}
Resuming is not a plain toggle. It re-checks the watcher limits, and for a search watcher the 500-listing limit, so it can fail with 400 INVALID_PARAMS. A successful resume starts from a fresh baseline and clears the change buffer: changes that happened while the watcher was paused are not delivered. Resuming a listing watcher whose pollInterval is below the current minimum raises it to the minimum.

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:
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: 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.
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 poll is billed when it runs, whether or not it finds a change and even if it fails. A listing-watcher poll skipped because live fetching is held back is not billed (see below). A building or search watcher polling every 15 minutes consumes approximately 2,880 requests per 30 days; a listing watcher at its 1-hour default consumes approximately 720. Listing watchers also make a live fetch on each poll, drawing on the same shared live-fetch budget as the listing stream. When that budget is held for Borough’s scheduled updates, or live fetching is paused, the poll is skipped that cycle and not billed; it runs again at the next interval. Building and search watchers diff Borough’s cached index instead, so they are not affected. Each connection to GET /v1/watchers/{id}/stream also costs 1 request. Because the server closes every stream after 5 minutes, a client that stays connected continuously opens about 288 streams a day, or approximately 8,640 requests per 30 days per connected client, on top of the watcher’s polls. PATCH and DELETE on a watcher are free, so you can always pause or remove one. 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}, a delete, a search watcher whose matches grew past 500, and a subscription that drops below Business all produce the same event. To distinguish them, read GET /v1/watchers/{id} (a deleted watcher returns 404) and check your usage and plan. Reconnecting to a paused watcher returns 400 until you reactivate it. Tier requirement: Business or Internal.

Choosing the Right Approach

Watchers never deliver to a webhook URL. Their changes reach you only on the watcher’s SSE stream, so per-target delivery (one listing, building, or search) is available only there.
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. When the shared live-fetch budget is held for Borough’s scheduled updates, or live fetching is paused, it serves cached data instead ("source": "cached" or "stale"). See Data Freshness for details.