- Listing stream (Pro+): a short-lived SSE stream that delivers cached data immediately, then attempts one live fetch
- 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:
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 returns429 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.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}
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 areconnect event and closes the stream:
Resuming without missing changes
The watcher keeps a bounded buffer of the changes it has detected — the last 50 — and everychange, 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 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.
changeids 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
connectedevent carries noid— 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 toGET /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.