# List Areas Source: https://qwady.wiki/borough/api-reference/areas/list-areas GET /areas Returns the area hierarchy: 349 areas across NYC and Northern New Jersey, of which 316 are NYC neighborhoods and sub-areas and 5 are boroughs. Optionally filter by level or parent area. This route is paginated: `perPage` defaults to 10 (and is capped at 10 on the Free tier), so a single unfiltered call returns 10 of 349 rows. Read `meta.total` and page through, or raise `perPage` on a paid tier. Unlike search, `meta` here carries no `links`. **Tier:** Free+ # Get Building Source: https://qwady.wiki/borough/api-reference/building/get-building GET /building/{id} Comprehensive building data including amenities, policies, developer, architect, NYC regulatory data (community district, police precinct, building class), and tax/management details. On Business and Internal keys, when the stored building is more than 30 minutes old the request first fetches it live, waiting up to about 10 seconds, and returns `meta.source: "live"`. If that fetch times out or fails, or the shared live-fetch budget is held for Borough's scheduled updates, or live fetching is paused, the stored record is returned instead (`meta.source` `cached` or `stale`). **Tier:** Starter+ Use the `internetProviders` field to inspect FCC-sourced likely internet availability, including provider names, technologies, and advertised speeds. > This product uses FCC APIs and/or Data but is not endorsed or certified by the FCC. # Get Building Listings Source: https://qwady.wiki/borough/api-reference/building/get-building-listings GET /building/{id}/listings Listings linked to a building, off-market ones included, unless you pass `status`; use `status=ACTIVE` for current availability. Sorted by price ascending. A listing is linked to its building only once it has been detail-scraped, so units that are not detail-scraped yet are missing: this is not a complete inventory of the building (see `detailScraped`). **Tier:** Starter+ # Get Building Scores Source: https://qwady.wiki/borough/api-reference/building/get-building-scores GET /building/{id}/scores Composite quality scores (0-100) derived from NYC Open Data: - **healthScore**: Based on HPD violation count and severity - **noiseScore**: Based on 311 noise complaints within 200m - **safetyScore**: Based on NYPD crime data within 400m - **transitScore**: Multi-modal: proximity and density of subway (800m), bus (400m), LIRR/Metro-North (1km) and ferry (800m) stops All four scores (health, noise, safety, transit) are recomputed weekly. Not all buildings have all scores; missing scores are returned as `null`. **Tier:** Starter+ # Get Building Violations Source: https://qwady.wiki/borough/api-reference/building/get-building-violations GET /building/{id}/violations HPD (Housing Preservation & Development) violations for a building: the 50 most recent, of any status, ordered by date descending. Includes violation class (A/B/C), status, and description. Violations are matched by the building's BBL (Borough-Block-Lot), or by location within about 50 meters when no BBL is known, which can include adjacent lots. Because the list stops at 50, `violationsOpen` on `/building/{id}/scores` can be higher than the open violations shown here. `meta.dataAge` reflects the building record, not the HPD sync. **Tier:** Starter+ # Compare Markets Source: https://qwady.wiki/borough/api-reference/market/compare GET /market/compare 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. Container areas (boroughs and other areas with children) roll up their subtree as listed in `/areas`, so Marble Hill (226) counts toward Manhattan (100) here although search returns it for the Bronx (200). Without a `bedrooms` filter there is one bucket per exact bedroom count, which can exceed 6. An unknown area ID comes back with an empty `snapshots` array rather than an error. **Tier:** Free+ # Get Market History Source: https://qwady.wiki/borough/api-reference/market/get-history GET /market/rentals/history Daily time series of the market-snapshot aggregates (median rent/price, inventory, average days-on-market, concession rate) for an area. The series accrues forward from when archiving began, so it is sparse until enough days accumulate. Pass `bedrooms` to scope to one unit size, or omit it to get every bedroom bucket per day. **Tier:** Free+ # Get Market Snapshot Source: https://qwady.wiki/borough/api-reference/market/get-snapshot GET /market/rentals/snapshot 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. Container areas (boroughs and other areas with children) roll up their subtree as listed in `/areas`, so Marble Hill (226) counts toward Manhattan (100) here although search returns it for the Bronx (200). Without a `bedrooms` filter there is one bucket per exact bedroom count, which can exceed 6. An unknown area ID returns an empty `data` array rather than an error. **Tier:** Free+ # Get Market Trends Source: https://qwady.wiki/borough/api-reference/market/get-trends GET /market/rentals/trends Price event time series for an area, grouped by date and event type. Returns up to 365 rows of price history within the last 365 days, one per date and event type, most recent first (with three event types that covers roughly the latest four months), with obvious listing-type price outliers removed. Aggregates the same `LISTED` / `PRICE_DROP` / `PRICE_INCREASE` events as `/property/{id}/history`, so it charts price movement and new-listing activity. Delistings are not recorded and do not appear here. **Tier:** Free+ # API Reference Source: https://qwady.wiki/borough/api-reference/overview Complete reference for all Borough API endpoints ## Base URL All API requests use the following base URL: ``` https://borough.qwady.app/v1 ``` ## Authentication Free-tier endpoints need no key. For paid endpoints and higher limits, include your API key in the `Authorization` header: ``` Authorization: Bearer BOROUGH- ``` Free-tier endpoints (search, areas, market, health, and photos) work without authentication but are subject to stricter rate limits. If you have a key, send it on these endpoints too; without it they run at Free-tier limits. See [Authentication](/borough/authentication) for details. ## Response format JSON data endpoints return a consistent envelope: ```json theme={null} { "data": { ... }, "meta": { "dataAge": "2026-02-15T14:28:00Z", "source": "cached" } } ``` A few responses differ: `GET /health` returns a bare `{status, timestamp, checks}` object, `/photos/{key}` returns image bytes, the streaming endpoints (`/listing/{id}` and `/watchers/{id}/stream`) return Server-Sent Events, `POST /webhooks` returns `{ data }` with no `meta`, and batch lookup and comparables carry their own `meta` fields (`requested`/`found`/`notFound` and `reference`). Some freshness-sensitive endpoints also include `freshnessThreshold` and `refreshTriggered` in `meta` when a tier-based refresh decision is part of the response. `refreshTriggered: true` means a refresh was requested, not that it will run: it is skipped while the shared live-fetch budget is held for Borough's scheduled updates, so the next call is not guaranteed to be fresher. Paginated endpoints include additional fields in `meta`. Search and building listings also include `links`; `/areas` does not: ```json theme={null} { "data": [ ... ], "meta": { "total": 342, "page": 1, "perPage": 25, "dataAge": "2026-02-15T14:25:00Z", "source": "cached", "links": { "self": "https://borough.qwady.app/v1/search/rentals?page=1&perPage=25", "first": "https://borough.qwady.app/v1/search/rentals?page=1&perPage=25", "last": "https://borough.qwady.app/v1/search/rentals?page=14&perPage=25", "prev": null, "next": "https://borough.qwady.app/v1/search/rentals?page=2&perPage=25" } } } ``` ## Endpoint groups Search rental and sale listings with 20+ filters Get listing details, price history, fees, and open houses Building data, listings, quality scores, and HPD violations Deliver supported listing and building images through `/v1/photos/{key}` Market snapshots, trends, and area comparisons List all 349 areas: NYC neighborhoods, boroughs, sub-boroughs, and Northern New Jersey SSE streams: cached listing data, then live data when available (Pro+) Persistent scheduled monitoring of listings, buildings, and searches (Business+) Webhook subscriptions for listing change notifications (Business+) ## Error handling All errors follow a consistent format. See [Errors](/borough/guides/error-handling) for the full reference. ```json theme={null} { "error": { "code": "NOT_FOUND", "message": "Listing not found", "status": 404 } } ``` # Batch Lookup Source: https://qwady.wiki/borough/api-reference/property/batch-lookup POST /property/batch Resolve many listing IDs in a single request (up to your tier's `perPage` cap, and at most 500 IDs; large batches are looked up in chunks server-side). Serves cached data only (it never triggers a live fetch) and is billed at one request unit per requested (deduped) ID, including IDs that are not found. `amenities` and `internetProviders` are not fetched in batch and come back as `null` (not "none"); use `GET /property/{id}` when you need those. Results are not returned in request order: match them by `id`, and read `meta.notFound` for the misses. If the batch would exceed your remaining monthly quota (including overage), the whole request is rejected with `429 QUOTA_EXCEEDED`; there is no partial result. After de-duplication the list may hold at most 500 ids, and never more than your tier's maximum page size (Free 10, Starter 50, Pro/Business 500); a longer list returns `400`. **Tier:** Starter+ # Comparables Source: https://qwady.wiki/borough/api-reference/property/comparables GET /property/{id}/comparables Active listings comparable to the reference listing: same listing type and neighborhood, matching bedroom count, within ±25% of its price, ranked by price proximity. Cached data only, billed as one request. **Tier:** Starter+ # Get Fees Source: https://qwady.wiki/borough/api-reference/property/get-fees GET /property/{id}/fees Fee breakdown for a listing including broker fees, application fees, and other move-in costs. **Tier:** Starter+ # Get Open Houses Source: https://qwady.wiki/borough/api-reference/property/get-open-houses GET /property/{id}/open-houses Scheduled open houses for a listing. Only returns future events. **Tier:** Starter+ # Get Price History Source: https://qwady.wiki/borough/api-reference/property/get-price-history GET /property/{id}/history Price-change timeline for a listing: the first observed price (`LISTED`) plus every subsequent decrease (`PRICE_DROP`) and increase (`PRICE_INCREASE`). Returns the 100 most recent events, newest first. An entry whose price is unchanged from the one before it is also reported as `LISTED` (with `pctChange: null`), so a series can hold more than one `LISTED` event. Delisting, rental, and sale events are **not** included; the API does not record them. To tell whether a listing is still on the market, read `status` on `GET /v1/property/{id}`. **Tier:** Starter+ # Get Property Source: https://qwady.wiki/borough/api-reference/property/get-property GET /property/{id} 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. On Business and Internal keys, when the stored detail is more than 5 minutes old the request first fetches the listing live, waiting up to about 8 seconds, and returns `meta.source: "live"`. A live response can report `status: IN_CONTRACT`, which is not stored, so a later cached read shows the stored status again. If the live fetch times out or fails, or the shared live-fetch budget is held for Borough's scheduled updates, or live fetching is paused, the stored record is returned instead (`meta.source` `cached` or `stale`); see `meta.refreshTriggered`. **Tier:** Starter+ Listing detail responses include the building-linked `internetProviders` block when Borough has FCC broadband availability data for the underlying building or Census block. > This product uses FCC APIs and/or Data but is not endorsed or certified by the FCC. # Get Property by URL Source: https://qwady.wiki/borough/api-reference/property/get-property-by-url GET /property/by-url Find a listing using its URL path (e.g., `/building/one57-condominium/48b`). Returns the same detail and freshness metadata as `GET /property/{id}`, including the Business and Internal live fetch and its fallback to the stored record when a live fetch is unavailable. **Tier:** Starter+ This endpoint returns the same FCC-backed `internetProviders` block as `GET /property/{id}` whenever the listing is attached to a matched building or Census block. > This product uses FCC APIs and/or Data but is not endorsed or certified by the FCC. # Search Rentals Source: https://qwady.wiki/borough/api-reference/search/search-rentals GET /search/rentals Search active NYC rental listings with comprehensive filters. Returns summary-level fields per listing. Use `/property/{id}` for full detail. **Tier:** Free+ # Search Sales Source: https://qwady.wiki/borough/api-reference/search/search-sales GET /search/sales Search active NYC sale listings. Supports the rental filters except `noFee` (rental-only; it returns `400` on sales), plus `saleType` (resale, sponsor, foreclosure). `maxMonthlyCost` compares monthly carrying costs (maintenance, common charges and taxes), not the purchase price. **Tier:** Free+ # Stream Listing Updates Source: https://qwady.wiki/borough/api-reference/streaming/stream-listing GET /listing/{id} Server-Sent Events stream that delivers cached data immediately, then attempts a live fetch. Events arrive in this order: `cached`, then `live` or `error`, then `done`. A listing with no stored URL gets neither `live` nor `error`. `error` carries a machine-readable `code` when the fetch itself fails: `fetch_failed`, `upstream_unavailable`, `upstream_rate_limited`, `proxy_auth_failed`, `budget_exceeded`, `service_disabled`, or `cancelled` (the 60-second stream limit was reached). A page that was fetched but could not be read sends `error` with only a `message`. Live fetches draw on a shared live-fetch budget: when it is held for Borough's scheduled updates or live fetching is paused, the live attempt ends with `error` (`budget_exceeded` or `service_disabled`) and the `cached` event stands. The `cached` payload is labelled `source: "cache"`. `live` carries only the fields read from the live page, plus `source: "live"` and `fetchedAt`, so merge it over `cached`. Each connection costs one request against your quota and one live fetch: close the connection on `done` rather than letting an SSE client reconnect automatically. **Tier:** Pro+ ## Overview Opens a Server-Sent Events (SSE) stream for a single listing. The stream delivers cached data immediately, then attempts a live fetch for fresher data when live fetching is available. **Tier requirement:** Pro, Business, or Internal. ## SSE Events | Event | Description | | - | - | | `cached` | Immediate response from D1 cache (arrives within milliseconds). Carries `id`, `dataAge` and `source: "cache"`. | | `live` | Fresh data read from the live listing page (usually a few seconds). Carries only the fields that could be read, plus `source: "live"` and `fetchedAt`; merge it over `cached`. | | `error` | The live step could not complete. The payload carries a `message` and, when the fetch itself failed, a `code` (see below). A page that was fetched but could not be read has no `code`. | | `done` | Stream complete, connection will close | ### Error codes | `code` | Meaning | | - | - | | `budget_exceeded` | Live fetching is being held back to protect Borough's scheduled updates. The `cached` event stands; try again later. | | `service_disabled` | Live fetching is temporarily paused. The `cached` event stands. | | `upstream_unavailable` | The listing page could not be reached (retried once). | | `upstream_rate_limited` | The listing page is rate-limiting requests (retried once). | | `cancelled` | The 60-second stream limit was reached before live data arrived. | | `proxy_auth_failed` | Borough's live-fetch service rejected its credentials. | | `fetch_failed` | Any other fetch failure. | ## Example ```bash theme={null} curl -N -H "Authorization: Bearer BOROUGH-..." \ -H "Accept: text/event-stream" \ https://borough.qwady.app/v1/listing/4961849 ``` ``` event: cached data: {"id":"4961849","price":3200,"status":"ACTIVE",...,"dataAge":"2026-02-12T14:30:00Z","source":"cache"} event: live data: {"price":3150,"status":"ACTIVE",...,"source":"live","fetchedAt":"2026-02-12T16:05:03Z"} event: done data: {"complete":true} ``` When live fetching is held back, the live step ends with an error and the cached event is the answer: ``` event: cached data: {"id":"4961849","price":3200,"status":"ACTIVE",...} event: error data: {"code":"budget_exceeded","message":"Live fetch budget is temporarily exhausted"} event: done data: {"complete":true} ``` ## Behavior * The `cached` event is always sent first (from D1 database) * The `live` event is sent if the live fetch succeeds (usually a few seconds) * If the live fetch fails, times out, or is held back (`budget_exceeded` or `service_disabled`), an `error` event is sent instead of `live` * A listing with no stored page URL skips the live step: `cached` is followed directly by `done` * The stream always ends with a `done` event, and the whole stream is capped at 60 seconds * Each connection costs one request against your quota and one live fetch. Close your client on `done`; an SSE client that reconnects automatically (such as a browser `EventSource`) re-opens the stream and is billed again each time ## When to use This endpoint is designed for **one-time fresh data retrieval**. For ongoing monitoring, use [persistent watchers](/borough/guides/streaming#persistent-watchers) instead. **Note:** Business and Internal tier users get live-first data on the regular `GET /v1/property/{id}` endpoint (returned with `"source": "live"` when the stored detail is more than 5 minutes old and live fetching is available), so the SSE stream is primarily useful for Pro tier users who want live data. # Get Usage Source: https://qwady.wiki/borough/api-reference/usage GET /usage Returns the caller's current billing-period quota usage. Authenticated and rate-limited, but does NOT count against your monthly quota. Without an API key it returns anonymous free-tier (IP-bucketed) usage. With a key it returns the usage of the subscription the key belongs to: all keys (and OAuth tokens) on one subscription share a single monthly quota, while the per-minute rate limit applies per key. What counts: only successful (2xx) responses consume quota; errors, including 429s and authentication failures, do not. `POST /property/batch` costs one unit per deduplicated ID, found or not. Each photo, each SSE connection (including every watcher-stream reconnect) and each watcher poll costs one unit. `PATCH` and `DELETE` on `/webhooks/{id}` and `/watchers/{id}` are free, so you can always pause or remove an automation. The Free quota resets on the 1st of each month (UTC); paid quotas reset at the end of the billing period. **Tier:** Free+ # Create Watcher Source: https://qwady.wiki/borough/api-reference/watchers/create-watcher POST /watchers Create a persistent watcher that monitors a listing, building, or search for changes. The watcher polls at the configured interval. Each poll counts as one API request against your active quota window, whether or not it finds a change. How each type detects changes: - **listing**: fetches the live listing on every poll and reports status changes and price moves of more than $25. Live fetches draw on a shared live-fetch budget; while it is held for Borough's scheduled updates, or live fetching is paused, polls are skipped and not counted. - **building** and **search**: compare against Borough's own index, which refreshes about every 6h for rentals and 8h for sales, so polling faster than that mostly spends quota. A building watcher sees only the units linked to the building (see `GET /building/{id}/listings`). Limits (Business): - At most 50 active watchers, of which at most 5 may be listing watchers. Creating one past either cap returns `429 QUOTA_EXCEEDED`; paused watchers do not count. - `pollInterval`: listing watchers minimum and default 3600 seconds; building and search watchers minimum 300, default 900. A shorter interval returns `400`. - A search watcher whose filters match more than 500 active listings is refused with `400 INVALID_PARAMS`; narrow it with `areas`, `minPrice`/`maxPrice`, `beds`, `listingType` or `buildingType`. A running search watcher whose matches later grow past 500 is paused. If the account drops below Business, all of its watchers and webhooks are paused; after re-subscribing, resume each with `PATCH` `{"active": true}`. **Tier:** Business+ # Delete Watcher Source: https://qwady.wiki/borough/api-reference/watchers/delete-watcher DELETE /watchers/{id} Permanently deletes a watcher and stops its scheduled polls. Never counts against your quota. **Tier:** any authenticated key # Get Watcher Source: https://qwady.wiki/borough/api-reference/watchers/get-watcher GET /watchers/{id} Returns full detail for a single watcher subscription. **Tier:** any authenticated key # List Watchers Source: https://qwady.wiki/borough/api-reference/watchers/list-watchers GET /watchers List all watcher subscriptions (active and paused) for the authenticated customer. **Tier:** any authenticated key (creating, resuming and retuning a watcher need Business) # Stream Watcher Changes Source: https://qwady.wiki/borough/api-reference/watchers/stream-watcher GET /watchers/{id}/stream 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. If the watcher is briefly unavailable, the stream stays open and heartbeats pause until it recovers, so a missing heartbeat does not mean the connection has dropped. A single stream lasts at most 5 minutes and then sends `reconnect`; open a new one to continue. `stopped` means the watcher is no longer active: paused, deleted, stopped because the monthly quota ran out, or paused because the account dropped below Business. If the stream closes without an event, reconnect with your last `id`. Each stream connection, including every reconnect after the 5-minute limit, counts as one request against your quota: about 12 an hour, or about 8,640 per 30 days, for a stream kept open continuously, on top of the polls. Every `change`, `heartbeat`, `reconnect` and `stopped` event carries an SSE `id`, a resume cursor; `connected` does not. 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, stopped because the monthly quota ran out, or paused because the account dropped below Business. After re-subscribing, resume it with `PATCH /watchers/{id}` and `{"active": true}`. | `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. 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. The connection is still alive, so don't treat a missing heartbeat as a dropped stream or reconnect because of one. The stream ends with `reconnect` or `stopped`, or when your client disconnects. The stream requires Business or Internal tier, and returns `400` if the watcher is paused. ## Change events The `eventType` on a `change` event, and what `listingId`, `oldValue` and `newValue` hold, depend on the watch type: | `eventType` | Watch type | `listingId` | `oldValue` → `newValue` | | - | - | - | - | | `listing.price_decreased`, `listing.price_increased` | listing | The listing | Old price → new price. Only moves of more than \$25 are reported. | | `listing.status_changed` | listing | The listing | Old status → new status | | `listing.expired` | listing | The listing | Old status → `OFF_MARKET` | | `listing.created` | building, search | The listing that appeared | `null` → the listing ID | | `listing.expired` | building, search | The listing that left | The listing ID → `"removed"`. The listing left the watcher's set of active matches: it went off market, or no longer matches (for example, its price moved out of your range). It has not necessarily expired. | | `building.listings_changed` | building | The **building** ID, not a listing | `null` → `"cron_detected"`: Borough's scheduled refresh saw a change among this building's listings. | 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`. While live fetching is held back for Borough's scheduled updates, or paused, a listing watcher skips its own polls, and skipped polls are not counted. Changes Borough's scheduled refresh detects for the listing can still arrive; otherwise the stream sends only `heartbeat` events. No separate signal is sent. ## Resuming Every `change`, `heartbeat`, `reconnect`, and `stopped` 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: ` header** — what browser `EventSource` and the Borough SDK replay automatically on reconnect. Wins if both are supplied. * **`?since=` 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. # Update Watcher Source: https://qwady.wiki/borough/api-reference/watchers/update-watcher PATCH /watchers/{id} Update the poll interval or active status of a watcher. Setting `active: false` pauses the watcher without deleting it. Resuming (`active: true`) starts a fresh baseline: changes that happened while the watcher was paused are not reported, and unread buffered changes are cleared. Resuming returns `400` when it would exceed an active-watcher cap (on Business, 50 per account and 5 listing watchers), or for a search watcher whose filters now match more than 500 active listings (filters cannot be changed here, so delete that watcher and create a narrower one). Resuming a listing watcher whose `pollInterval` is below the current minimum raises it to the minimum. A `pollInterval` below the minimum for the watcher's type (Business: listing 3600, building and search 300) returns `400`. A shorter interval applies at once; a longer one takes effect after the poll already scheduled. Updates never count against your quota, so an exhausted account can still pause its watchers. **Tier:** Business+ (a pause-only request, `{"active": false}`, works on any authenticated key) # Create Webhook Source: https://qwady.wiki/borough/api-reference/webhooks/create-webhook POST /webhooks Register a URL to receive listing change events — created, price moved, status changed, or expired. Returns a signing secret for payload verification. The signing secret appears only in this response (list and update never return it), so store it now; to rotate it, create a new subscription and delete the old one. `url` must be HTTPS on a public host (loopback, private and link-local addresses are rejected with `400`), and `events` takes 1-10 entries. Borough publishes detected changes every 5 minutes in capped batches, so a large burst can take several cycles to drain. New-listing, status and expiry events follow the search refresh cadence (about every 6h for rentals, 8h for sales). Price events for detail-scraped listings are detected only when that listing's detail is refreshed, which can lag the real change by days to weeks; use a listing watcher when price latency matters. Subscriptions are matched on event type only. An active subscription receives every change of a subscribed type across the whole Borough dataset — subscribing to `listing.created` means every new listing Borough indexes, citywide. Narrow it either by filtering on `data.listingId` in your handler, or by using Watchers (`POST /v1/watchers`), which scope monitoring to one listing, one building, or one saved search. If the account drops below Business, every webhook subscription and watcher on it is paused. Re-subscribing does not resume them: send `PATCH` with `{"active": true}` for each one. Events that occur while a subscription is paused are not delivered later. **Tier:** Business # Delete Webhook Source: https://qwady.wiki/borough/api-reference/webhooks/delete-webhook DELETE /webhooks/{id} Remove a webhook subscription. The subscription is re-checked at delivery time, so the deletion takes effect on the next delivery attempt rather than on a fixed schedule. During a database incident that check cannot be made, and a delivery already queued for this subscription may still be attempted — deliveries are failed open rather than dropped. Deleting works on any authenticated key and never counts against your quota, so you can always remove a subscription. # List Webhooks Source: https://qwady.wiki/borough/api-reference/webhooks/list-webhooks GET /webhooks List all webhook subscriptions (active and paused) for the authenticated account, newest first; check each item's `active` flag. Subscriptions paused because the account dropped below Business are listed too. **Tier:** any authenticated key (creating and resuming a subscription need Business) # Update Webhook Source: https://qwady.wiki/borough/api-reference/webhooks/update-webhook PATCH /webhooks/{id} Update the URL, subscribed events, or active state of an existing webhook subscription. There are no per-listing or per-area filters to update — see `POST /v1/webhooks` for the delivery scope. Pausing (`{"active": false}` alone) works on any authenticated key; any other change, including resuming, needs Business. Updates never count against your quota, so an exhausted account can still pause its subscriptions. # Authentication Source: https://qwady.wiki/borough/authentication API key and OAuth 2.1 authentication methods Borough supports two authentication methods: **API keys** (simplest) and **OAuth 2.1** (for MCP clients and third-party integrations). ## No key (Free tier) Search, areas, market, and photo endpoints answer requests with no `Authorization` header at all. These keyless requests are metered per IP at the [Free tier limits](/borough/tiers-and-pricing). Send a key only when you need paid endpoints or higher limits. A placeholder or expired key is rejected with `401`; it does not fall back to the free tier. ## API Key (Bearer Token) Include your API key in the `Authorization` header: ``` Authorization: Bearer BOROUGH- ``` Keys use the format `BOROUGH-` and are provisioned automatically when you [subscribe to a plan](/borough/tiers-and-pricing). ``` BOROUGH-a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` The key itself is checked live against Polar.sh on every request, so revocation takes effect immediately: the next request made with a revoked or disabled key returns `401`. The one delayed revocation path is OAuth 2.1 access tokens (below), which are issued after validation and stay usable until they expire, at most one hour later. Two things can lag behind a subscription change. A plan change can take up to **5 minutes** to apply, so right after an upgrade a key may still get the old tier's limits, including `403 TIER_RESTRICTED`. A key that was refused, for example just before its subscription was reactivated, may keep being refused for up to **60 seconds**. ## OAuth 2.1 (PKCE) Borough implements OAuth 2.1 with PKCE for secure delegated access. This is the recommended method for MCP clients and third-party apps. ### Endpoints | Endpoint | Method | Description | | - | - | - | | `/oauth/register` | POST | Dynamic client registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) | | `/oauth/authorize` | GET | Consent page (redirects user) | | `/oauth/authorize` | POST | Submit API key, receive auth code | | `/oauth/token` | POST | Exchange code for tokens | | `/.well-known/oauth-authorization-server` | GET | Server metadata ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)) | | `/.well-known/oauth-protected-resource` | GET | Resource metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)) | | `/.well-known/oauth-protected-resource/mcp` | GET | Resource metadata for the `/mcp` endpoint (RFC 9728 path-inserted form) | ### Flow 1. **Register your client** (optional for localhost apps): ```bash theme={null} curl -X POST https://borough.qwady.app/oauth/register \ -H "Content-Type: application/json" \ -d '{"client_name":"My App","redirect_uris":["https://myapp.com/callback"]}' ``` Unregistered clients may only use `localhost` redirect URIs. 2. **Redirect user to authorize:** ``` GET /oauth/authorize?response_type=code &client_id= &redirect_uri= &code_challenge= &code_challenge_method=S256 &state= &resource=https://borough.qwady.app/mcp ``` The user enters their Borough API key (or continues with Free tier) and is redirected back with an authorization code. `resource` is optional. If you send it, it must be `https://borough.qwady.app` or `https://borough.qwady.app/mcp` (a trailing slash is fine); anything else returns `400 invalid_target`. 3. **Exchange code for tokens:** ```bash theme={null} curl -X POST https://borough.qwady.app/oauth/token \ -d "grant_type=authorization_code&code=&client_id=&code_verifier=&redirect_uri=" ``` 4. **Use the access token:** ``` Authorization: Bearer ``` ### Token lifetimes | Token | Lifetime | | - | - | | Access token | 1 hour | | Refresh token | 30 days | | Auth code | 5 minutes | | Client registration | 30 days | Use the refresh token to get new access tokens without re-authorization: ```bash theme={null} curl -X POST https://borough.qwady.app/oauth/token \ -d "grant_type=refresh_token&refresh_token=&client_id=" ``` Refresh tokens rotate. Each successful refresh returns a new `refresh_token` that must replace the one you stored, and the token you presented is consumed. Presenting a consumed token again within 60 seconds returns the same response, so a retried request is safe. After 60 seconds it counts as reuse: every refresh token in that grant is revoked (access tokens already issued last until they expire) and the call returns `invalid_grant`, so the user must authorize again. The refresh flow re-validates your API key, so tier changes and revocations take effect on the next refresh. If Borough cannot reach its billing provider to re-validate, the refresh returns `503` with `server_error` and does not consume your refresh token; retry with the same token. ## Free-tier access Search endpoints (`/v1/search/rentals`, `/v1/search/sales`), areas (`/v1/areas`), market endpoints, and photo delivery (`/v1/photos/{key}`) work without authentication and use Borough's Free tier limits. The public `/v1/health` endpoint is also available without authentication, but it is outside Borough's metered quota and rate-limit middleware. Unauthenticated metered requests are subject to: * 10 requests/minute rate limit (IP-based) * 100 requests/month quota * Maximum 10 results per page Public Borough pages live on `borough.qwady.app`, while service integrations continue to use `/v1/*`, `/mcp`, `/.well-known/*`, `/oauth/*`, and `/v1/webhooks/*`. Public docs live separately at `qwady.wiki/borough`. ## Error responses | Code | Status | Description | | - | - | - | | `MISSING_API_KEY` | 401 | No `Authorization` header on `/mcp` or on a watcher or webhook list, get, update, or delete call | | `INVALID_API_KEY` | 401 | Key is unknown, revoked, or disabled, or has no active Borough subscription (including a cancelled plan) | | `EXPIRED_API_KEY` | 401 | The license key has passed its expiry date | | `TIER_RESTRICTED` | 403 | Endpoint requires a higher tier | | `UPSTREAM_ERROR` | 503 | Borough could not verify the key with its billing provider. The key is not invalid; retry after `error.retryAfter` seconds | A request with no key is treated as Free tier, so a keyless call to a paid `/v1` endpoint (property, building, listing streams, or creating a watcher or webhook) returns `403 TIER_RESTRICTED`, not `401`. On 401 responses from authentication (a missing key on `/mcp`, or an invalid or expired key), the `WWW-Authenticate` header includes a link to the protected-resource metadata for automatic OAuth discovery. ## Security best practices * Store keys in environment variables, never in client-side code * Rotate keys by creating a new subscription if compromised * Use the minimum tier needed for your use case * For OAuth, always use PKCE with S256 code challenges * Register your client to restrict allowed redirect URIs # Building Scores Source: https://qwady.wiki/borough/enrichment Quality scores derived from NYC and New York State open data ## Overview Borough enriches building data with quality scores computed from NYC and New York State open data. Each score ranges from **0 to 100**, where higher is better. Scores are available via `GET /v1/building/{id}/scores` (Starter and above). On the Free tier, the same four scores and the open violation count appear as `buildingScores` on each search result. ## Score types ### Health Score **Source:** [HPD Violations](https://data.cityofnewyork.us/Housing-Development/Housing-Maintenance-Code-Violations/wvxf-dwi5) (NYC Department of Housing Preservation & Development) Measures building maintenance quality based on open HPD violations. * **Class C** (immediately hazardous): -3 points each * **Class B** (hazardous): -2 points each * **Class A** (non-hazardous): -1 point each * **Formula:** `max(0, 100 - weighted_sum)` Individual violations are available via `GET /v1/building/{id}/violations` (Starter and above). The list holds the **50 most recent** violations of any status, open and closed together, so filter on `status: "Open"` and use `violationsOpen` from `/scores` for the open count rather than counting list rows. For a building matched by location rather than BBL (see [Availability](#availability)), the list can include violations from adjacent lots. ### Noise Score **Source:** [311 Service Requests](https://data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9) (noise complaints) Reflects reported noise activity near the building, based on 311 noise-complaint density within **200 meters** over the trailing **6 months**. Scored 0–100 (higher = quieter) with a logarithmic curve calibrated to the citywide distribution of nearby complaint counts, so a typical NYC building lands near the middle and the noisiest blocks approach 0. ### Safety Score **Sources:** [NYPD Complaint Data Historic](https://data.cityofnewyork.us/d/qgea-i56i) for the part of the window before January 1 of the current year, and [NYPD Complaint Data Current (Year To Date)](https://data.cityofnewyork.us/Public-Safety/NYPD-Complaint-Data-Current-Year-To-Date-/5uac-w243) for the rest Reflects reported crime activity near the building, based on NYPD complaint density within **400 meters** over the trailing **12 months**. Weighted by severity: * **Felony**: 5 points * **Misdemeanor**: 2 points * **Violation**: 1 point Scored 0–100 (higher = safer) with a logarithmic curve calibrated to the citywide distribution of weighted complaint counts, so a typical NYC building lands near the middle and the highest-incident areas approach 0. ### Transit Score **Sources:** MTA subway stops ([39hk-dx4f](https://data.ny.gov/Transportation/MTA-GTFS-Stops/39hk-dx4f)), MTA bus stops ([2ucp-7wg5](https://data.ny.gov/d/2ucp-7wg5)), LIRR and Metro-North stations ([wxmd-5cpm](https://data.ny.gov/d/wxmd-5cpm)), the NYC Ferry GTFS feed, and the Staten Island Ferry GTFS feed ([b57i-ri22](https://data.cityofnewyork.us/d/b57i-ri22)) Measures public transit accessibility based on proximity and density of subway, bus, commuter-rail (LIRR/Metro-North), and ferry stops near the building. Six components by mode, summed and capped at 100 (the raw maximum is 110, so a building does not need every mode to score 100): | Component | Max points | Measure | | - | - | - | | Subway proximity | 40 | Linear decay with distance to the nearest subway stop, reaching 0 at 800 m | | Subway density | 25 | Logarithmic scale of subway stops within 800 m, full points at about 15 stops | | Commuter rail | 15 | Linear decay with distance to the nearest LIRR or Metro-North station, reaching 0 at 1,000 m | | Ferry | 10 | Linear decay with distance to the nearest ferry landing, reaching 0 at 800 m | | Bus proximity | 12 | Linear decay with distance to the nearest bus stop, reaching 0 at 400 m | | Bus density | 8 | Logarithmic scale of bus stops within 400 m, full points at about 7 stops | ## Update frequency | Score | Schedule | Data Source | | - | - | - | | Health | Weekly | HPD violations | | Noise | Weekly | 311 complaints | | Safety | Weekly | NYPD data | | Transit | Weekly | GTFS stops | | Internet providers | Checked monthly (FCC publishes twice a year) | FCC Broadband Map | Enrichment jobs run on a periodic schedule. Scores reflect the most recent computation cycle. ## Internet provider availability Borough also enriches buildings with likely internet provider availability from official FCC Broadband Data Collection public data. * **Coverage level:** exact-building when matched, or Census-block level from FCC public bulk imports * **Source:** FCC Broadband Map public availability data * **Cadence:** the FCC publishes this dataset twice a year. Borough checks monthly and re-imports when a new filing period is available, so most monthly checks find nothing new. Manual backfills are supported when needed * **Fill rate:** about 76.5% of eligible buildings, meaning those with an active listing and a geocoded location. Buildings with no active listing are not enriched by design * **Disclaimer:** provider availability may vary by apartment or unit > This product uses FCC APIs and/or Data but is not endorsed or certified by the FCC. ## Availability * Noise, safety, and transit scores require building geo-coordinates (latitude/longitude) * The health score uses the building's BBL (Borough-Block-Lot) identifier when Borough has one. Otherwise it counts HPD violations within about 50 m of the building's coordinates, so for those buildings it can include adjacent lots * Not all buildings have all scores; missing scores return `null` # Enums Source: https://qwady.wiki/borough/enums All enumerated values used in the API ## BuildingType | Value | Description | | - | - | | `CONDO` | Condominium | | `CO_OP` | Co-operative | | `CONDOP` | Condop: a co-op that operates under condo-like rules | | `RENTAL` | Rental building | | `TOWNHOUSE` | Townhouse | | `HOUSE` | Single-family house | | `SINGLEFAMILY` | Single-family dwelling | | `TWOFAMILY` | Two-family house | | `THREEFAMILY` | Three-family house | | `FOURFAMILY` | Four-family dwelling | | `MULTI_FAMILY` | Multi-family house | | `MIXED_USE` | Mixed-use building | | `HYBRID` | Mixed ownership/structure type reported by the source | | `COMMERCIAL` | Commercial building | | `UNKNOWN` | The source reported an unknown type, or a type Borough does not recognize | `buildingType` is `null` when the source did not report a type. The `buildingType` and `excludeBuildingType` filters match case- and punctuation-insensitively and accept common alias spellings, for example `MULTIFAMILY` for `MULTI_FAMILY`, `COOP` for `CO_OP`, `CONDOMINIUM` for `CONDO`, and `2FAMILY` for `TWOFAMILY`. Responses always return the canonical spelling. ## ListingStatus | Value | Description | | - | - | | `ACTIVE` | Currently available | | `IN_CONTRACT` | Under contract / offer accepted (*live data only; see note*) | | `OFF_MARKET` | No longer available | Stored listings and search results carry **`ACTIVE`** or **`OFF_MARKET`** only, so listings transition directly from `ACTIVE` to `OFF_MARKET` when they leave the market. `IN_CONTRACT` can appear only in live data: Business live-first responses from `GET /v1/property/{id}` and `/v1/property/by-url`, `live` events on the listing stream, and listing-watcher change events (`newValue`), when the live listing reports it. Match on it if present, but do not rely on it for in-contract detection. ## SaleType | Value | Description | | - | - | | `RESALE` | Standard resale | | `SPONSOR_UNIT` | New development / sponsor sale | | `FORECLOSURE` | Foreclosure | | `RESTRICTED` | Income- or age-restricted sale (HDFC-style) | | `AUCTION` | Sold at auction | | `SHORT` | Short sale | | `UNKNOWN` | The source reported an unknown sale type, or one Borough does not recognize | `saleType` is `null` when the source did not report one. The `saleType` filter also accepts alias spellings such as `SPONSOR` and `SHORTSALE`. ## Sort | Value | Description | | - | - | | `RECOMMENDED` | Default. Currently orders by listing date, the same as `LISTED_AT` | | `PRICE` | Sort by price | | `LISTED_AT` | Sort by listing date | ## SortDirection | Value | Description | | - | - | | `ASC` | Ascending (low to high, oldest first) | | `DESC` | Descending (high to low, newest first) | The default direction is `DESC` for every sort, so `sort=PRICE` without `sortDir` returns the highest price first. Ties are broken by listing ID, ascending. ## PriceEvent The `event` field returned by `/property/{id}/history` and `/market/rentals/trends`. | Value | Description | | - | - | | `LISTED` | Initial listing, or a later entry with no price change (for example a relist at the same price) | | `PRICE_DROP` | Price reduced | | `PRICE_INCREASE` | Price raised | ## ErrorCode | Value | HTTP Status | Description | | - | - | - | | `INVALID_PARAMS` | 400, 422 | Invalid request parameters, including the watcher rules below (`422` for an unknown area ID in `areas`) | | `MISSING_API_KEY` | 401 | No API key provided | | `INVALID_API_KEY` | 401 | Unknown, revoked, or disabled key, or a key with no active Borough subscription (including a cancelled plan) | | `EXPIRED_API_KEY` | 401 | The license key has passed its expiry date | | `TIER_RESTRICTED` | 403 | Higher tier required | | `QUOTA_EXCEEDED` | 429 | Monthly hard limit reached (included quota plus overage), a batch larger than the remaining allowance, or an active-watcher cap reached when creating a watcher | | `NOT_FOUND` | 404 | Resource not found, including a missing webhook subscription or watcher | | `WEBHOOK_NOT_FOUND` | 404 | A webhook subscription disappeared while an update to it was being applied (for example, it was deleted concurrently) | | `RATE_LIMIT_EXCEEDED` | 429 | Rate limit exceeded | | `INTERNAL_ERROR` | 500 | Unexpected error | | `UPSTREAM_ERROR` | 502, 503 | `502`: a data source failed (photo delivery). `503`: Borough could not verify your subscription with its billing provider; your key is not invalid, so retry after `retryAfter` seconds | | `SERVICE_DISABLED` | 503 | API temporarily disabled | Watcher rules (Business): creating a watcher past the active-watcher cap (50 in total, of which at most 5 may be listing watchers) returns `429 QUOTA_EXCEEDED`, and resuming one past a cap with `PATCH` `active: true` returns `400 INVALID_PARAMS`. A `pollInterval` below the minimum (3600 seconds for listing watchers, 300 for building and search watchers) returns `400 INVALID_PARAMS`, as does a search watcher whose filters match more than 500 active listings, checked when it is created and when it is resumed. A running search watcher whose matches later grow past 500 is paused. ## WebhookEvent | Value | Description | | - | - | | `listing.created` | New listing appeared | | `listing.price_decreased` | Price dropped >\$25 | | `listing.price_increased` | Price raised >\$25 | | `listing.status_changed` | Status changed to a value other than `OFF_MARKET`, typically a listing returning to `ACTIVE` | | `listing.expired` | Listing went off-market. A move to `OFF_MARKET` sends this event only, never `listing.status_changed` | # Data Freshness Source: https://qwady.wiki/borough/guides/data-freshness How Borough keeps data fresh and what staleness means for your tier ## Overview Borough caches data in a D1 database and refreshes it on a schedule. Every API response includes baseline freshness metadata so you can see how old the data is and how it was served. Freshness-sensitive endpoints may also include refresh-decision metadata when tier-based refresh logic applies. ## Response metadata Freshness-aware JSON responses include these baseline fields in `meta`: | Field | Type | Description | | - | - | - | | `dataAge` | string | When the data was last refreshed (ISO 8601 timestamp) | | `source` | string | How the data was served: `cached`, `stale`, or `live` | | Some freshness-sensitive endpoints additionally include: | | | | Field | Type | Description | | - | - | - | | `freshnessThreshold` | integer | Max allowed age in minutes before refresh triggers | | `refreshTriggered` | boolean | Whether this request requested a background refresh (not a guarantee that the refresh succeeds) | `GET /v1/property/{id}/history`, `/fees`, and `/open-houses` additionally include: | Field | Type | Description | | - | - | - | | `detailScraped` | boolean | Whether a detail scrape has ever run for this listing. `false` means the `data: []` you got back means "never checked," not "checked, none found." See [Detail-only fields and coverage](#detail-only-fields-and-coverage) below. When `false`, `dataAge` is also `null` for the same reason. | ### Response headers Two freshness headers are also included on metered JSON `/v1` responses: ``` X-Data-Age: 2026-02-16T14:30:00Z X-Data-Source: cached ``` ## Source values | Value | Meaning | | - | - | | `cached` | Data is within the freshness threshold for your tier | | `stale` | Data exceeds the freshness threshold for your tier. On property and building detail this also queues a background refresh; on search it is a staleness signal only (see below) | | `live` | Fetched live from the source just now (Business/Internal tiers on property and building detail, when the [live-fetch budget](#live-first-data-business--internal) allows) | When `source` is `stale`, the API still returns the data immediately. On property and building detail a refresh is requested asynchronously, so a subsequent request usually gets fresher data. It is not guaranteed; see [How async refresh works](#how-async-refresh-works). ## Freshness thresholds by tier Data is considered stale past these age thresholds. On property and building detail, paid tiers additionally queue a background refresh (search does not; see below): ### Search results | Tier | Threshold | | - | - | | Starter | 8 hours | | Pro | 8 hours | | Business | 8 hours | ### Listing details | Tier | Threshold | | - | - | | Starter | 30 minutes | | Pro | 15 minutes | | Business | 10 minutes | ### Building details | Tier | Threshold | | - | - | | Starter | 6 hours | | Pro | 3 hours | | Business | 2 hours | ## Free tier behavior Free-tier requests always serve cached data and never trigger background refreshes. Free keys cannot call property or building detail (they return `403 TIER_RESTRICTED`), and Free search responses always report `"source": "cached"`, whatever the data's age. Cached data is refreshed on a fixed schedule regardless of API traffic: | Data type | Schedule | | - | - | | Rental search index | Every 6 hours | | Sale search index | Every 8 hours | | Listing detail sweeps | Every 12 hours | | Building details | Daily (03:00 UTC) | | Area boundaries | Rarely change; updated on demand | | Broadband enrichment | Checked monthly; the underlying FCC dataset is published twice a year, so most checks find nothing new | | Building scores (HPD, noise, transit, crime) | Weekly, Sundays between 01:10 and 04:10 UTC | | Market snapshots | Daily at 05:00 UTC | ## How async refresh works When a paid-tier request for **property or building detail** hits stale data: 1. The API returns the stale data immediately with `"source": "stale"` 2. A background job is triggered to fetch fresh data from the source 3. The fresh data is written to the database 4. If the refresh succeeded, your next request returns the updated data with `"source": "cached"` This means you never wait for a refresh: you always get a response immediately. The tradeoff is that one response may contain slightly older data. `refreshTriggered: true` means a refresh was requested, not that it will succeed. Concurrent stale reads of the same listing or building share one refresh (about one per listing per minute and per building per five minutes), though each of those responses still reports `refreshTriggered: true`. A refresh is skipped when the shared live-fetch budget is held for Borough's scheduled updates or live fetching is paused, and it can also fail upstream, so your next request may still be `stale`. ### Search is different Search results are kept fresh by the scheduled crawl, not by per-request refreshes. A single search spans many listings and areas, so there is no one upstream fetch to trigger. A `"source": "stale"` on search therefore means the data is past the 8-hour threshold (typically because a scheduled crawl was missed), not that a refresh has been queued. It resolves on the next crawl. ## Live-first data (Business / Internal) Business and Internal tier requests for **property detail** and **building detail** endpoints get live data automatically: * If cached data is older than **5 minutes** (listings) or **30 minutes** (buildings), the API fetches fresh data from the source synchronously * The live data is returned immediately with `"source": "live"` * The fresh data is also written back to the cache, keeping it warm for all tiers * If the live fetch fails or times out (8-10 seconds), the API falls back to cached data with the standard async refresh behavior * 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 API skips the live fetch and serves cached data (`"source": "cached"` or `"stale"`) instead of `live`. Check `meta.source` rather than assuming a Business response is live This applies to: * `GET /v1/property/{id}` * `GET /v1/property/by-url` * `GET /v1/building/{id}` Search, market, and area endpoints always serve from cache regardless of tier. ## Listing availability Search endpoints (`/v1/search/rentals`, `/v1/search/sales`) and `GET /v1/building/{id}/listings?status=ACTIVE` return only listings that are currently on the market. When a listing comes off the market, Borough sets its `status` to `OFF_MARKET` and removes it from active search results. Webhook subscribers receive a [`listing.expired`](/borough/guides/webhooks) event; that event is delivered to webhooks only and is not part of `GET /v1/property/{id}/history`, which returns price events (`LISTED`, `PRICE_DROP`, `PRICE_INCREASE`) exclusively. Without a webhook subscription, read off-market status from `data.status` on `GET /v1/property/{id}`. Availability is reconciled against the source on each search refresh (rentals every 6 hours, sales every 8 hours), so a listing that leaves the market is reflected within roughly that window. A property detail lookup (`GET /v1/property/{id}`) still resolves for an off-market listing, so check `data.status` to confirm availability rather than assuming a successful response means the unit is active. ### Price freshness Availability and price are not refreshed on the same schedule. Once a listing has been fetched in detail (`detailScraped: true`), the scheduled search refresh no longer updates its stored price. That price changes only when Borough re-checks the listing's detail page, which can be days to weeks later, or when a paid-tier detail request refreshes it. Search `meta.dataAge` is the newest refresh time among the matching listings, and every scheduled search refresh advances it, so a listing fetched in detail can appear in fresh-looking results (`"source": "cached"`) with a price that is weeks old. For a current price on a single listing, use `GET /v1/property/{id}` on Business (live-first) or the [listing stream](/borough/guides/streaming) on Pro, and check that `source` is `live`. ## Listed date accuracy Every listing carries `listedAt` (on-market date) and a `daysOnMarket` value derived from it. Until a listing has been detail-scraped, `listedAt` is the date Borough **first observed** the listing in the feed (an estimate accurate to within one search cycle) rather than the authoritative on-market date. The `listedAtIsApproximate` boolean tells you which you have: * `listedAtIsApproximate: true`: `listedAt` is the feed-first-seen estimate (detail not yet scraped). * `listedAtIsApproximate: false`: `listedAt` is the authoritative date from the listing detail page. `daysOnMarket` inherits the same accuracy. Check the flag before relying on `listedAt` or `daysOnMarket` for date filtering (`listedAfter` / `listedBefore`) or time-on-market analysis. A listing's flag flips to `false` once its detail page is scraped, but scraping is a continuous, prioritized queue, not a fixed-latency job. The listings scraper *runs* every 12 hours; that's how often the cron fires, not a bound on how long any individual listing waits to be detail-scraped. See [Detail-only fields and coverage](#detail-only-fields-and-coverage) below for why many listings wait far longer than 12 hours, some indefinitely. ## Detail-only fields and coverage Some listing fields are only ever populated by a **detail scrape** (a fetch of the individual listing's page), as distinct from the search-feed sweep that keeps most of Borough's data warm. Until a listing has been detail-scraped, these fields read as `null`, not `false`, `0`, or `[]`: * Listing booleans: `noFee`, `furnished`, `has3dTour`, `hasVideo` * Other detail-only listing fields: `sqft`, `petsAllowed`, `amenities` (listing-level), `maintenance`, `commonCharges`, `propertyTax`, `floorPlanKey`, `virtualTourUrl` * The sub-resources `GET /v1/property/{id}/history`, `/fees`, and `/open-houses`: an empty `data: []` from these on an undetailed listing means "never checked," not "checked, none found." Confirm via `meta.detailScraped` (see the table above) `detailScraped` tells you whether a detail scrape has run for this listing. It's on every listing object: search results (`/v1/search/rentals`, `/v1/search/sales`), `GET /v1/building/{id}/listings`, `GET /v1/property/{id}/comparables`, and `GET /v1/property/{id}`, plus `meta.detailScraped` on the three sub-resources above. But it's a weaker signal than it looks. A detail scrape that ran but failed to parse one particular field (for example, an upstream key rename) still sets `detailScraped: true` while that one field stays `null`. Read `detailScraped: true` as "Borough attempted a detail scrape," not "every detail-only field on this listing was observed." **Coverage today:** currently around 55% of ACTIVE listings have never been detail-scraped. Treat that figure as approximate and time-varying — it moves as the backlog drains and as new listings arrive — but the shape holds: this is the majority case, not an edge case, so expect most of the fields above to be `null` on most listings, most of the time. Detail scraping is an always-running pipeline, not a one-time backfill, but new listings enter the feed at roughly the rate the backlog drains, so the gap does not reliably close with time. If your workflow needs confirmed values, gate on the field being non-`null` (or on `detailScraped`) rather than assuming that waiting resolves it. This coverage gap is also why `noFee=false`, `has3dTour=false`, and `hasOpenHouse=false` are rejected outright (`400 INVALID_PARAMS`) rather than silently matched. See the [filtering guide](/borough/guides/filtering). ## Best practices * Check `meta.source` to know if you're reading stale data * For time-sensitive detail lookups, use Pro or Business tier for tighter listing/building freshness thresholds * Business tier gets live data on property and building detail: no need for SSE streams for single lookups. When the shared live-fetch budget is held or live fetching is paused, these endpoints serve cached data, so confirm `meta.source` is `live` * If you need the freshest available Borough path on Pro, use the [listing stream](/borough/guides/streaming) endpoint for live SSE delivery. Like any live fetch, it still depends on upstream availability, response time, and the shared live-fetch budget; when the live attempt cannot run, the stream sends an `error` event and the `cached` event stands. # Errors Source: https://qwady.wiki/borough/guides/error-handling Error codes and response format ## Error response format All errors follow this structure: ```json theme={null} { "error": { "code": "INVALID_PARAMS", "message": "Parameter 'maxPrice' must be a positive integer.", "status": 400 } } ``` Some errors add optional fields to the `error` object: | Field | Present on | Description | | - | - | - | | `retryAfter` | Per-key `RATE_LIMIT_EXCEEDED` (429) and `UPSTREAM_ERROR` (503) | Seconds to wait before retrying | | `quotaContext` | `QUOTA_EXCEEDED` (429) for monthly quota or a batch lookup, not the watcher cap | Your tier and quota, the next tier up, and an upgrade link. See [Rate Limits](/borough/guides/rate-limits#quota-exceeded) | | `upgrade` | `TIER_RESTRICTED`, per-key `RATE_LIMIT_EXCEEDED`, and quota `QUOTA_EXCEEDED`, when a higher tier would lift the error | The tier required and where to upgrade | `401` responses from API key authentication also carry a `WWW-Authenticate` header. ## Error codes | Code | HTTP Status | Description | | - | - | - | | `INVALID_PARAMS` | 400, 422 | Invalid or missing parameters (`400`). Unknown area IDs in `areas` return `422` | | `MISSING_API_KEY` | 401 | No `Authorization` header where a key is required. On REST endpoints a keyless request runs as anonymous Free, so a keyless call to a paid-only endpoint (such as property or building detail) returns `403 TIER_RESTRICTED` instead | | `INVALID_API_KEY` | 401 | Unknown, revoked, or disabled key, or a key with no active Borough subscription (including a cancelled plan) | | `EXPIRED_API_KEY` | 401 | The license key has passed its expiry date | | `TIER_RESTRICTED` | 403 | Endpoint requires a higher tier | | `NOT_FOUND` | 404 | Resource not found, including a missing webhook subscription or watcher | | `WEBHOOK_NOT_FOUND` | 404 | A webhook subscription disappeared while an update to it was being applied (for example, it was deleted concurrently) | | `QUOTA_EXCEEDED` | 429 | Monthly request quota exceeded (including overage cap), or a batch lookup needs more units than remain. Also returned when creating a watcher would pass the active-watcher cap (50, or 5 listing watchers) | | `RATE_LIMIT_EXCEEDED` | 429 | Per-minute rate limit exceeded | | `INTERNAL_ERROR` | 500 | Unexpected server error | | `UPSTREAM_ERROR` | 503, 502 | `503`: your subscription could not be verified momentarily; your key is fine. `502`: the photo proxy could not fetch an image | | `SERVICE_DISABLED` | 503 | API temporarily disabled via kill switch | ### Watcher request errors | Condition | Response | | - | - | | `pollInterval` below the minimum for the watch type (Business: 3600 seconds for listing watchers, 300 for building and search) | `400 INVALID_PARAMS` | | Search watcher filters match more than 500 active listings, at create or when resumed with `{"active": true}` | `400 INVALID_PARAMS`. At create, narrow the filters. `PATCH` cannot change filters, so on resume delete the watcher and create a narrower one; retrying unchanged will not help | | Creating a watcher past the 50-active or 5-active-listing-watcher cap | `429 QUOTA_EXCEEDED` | | Resuming a paused watcher past either cap | `400 INVALID_PARAMS` | See [Streaming](/borough/guides/streaming#watcher-limits) for the limits. ## Retry guidance | Status | Action | | - | - | | 400 | Fix the request parameters and retry | | 401 | Check your API key | | 403 | Upgrade your tier | | 404 | Verify the resource ID exists | | 422 | Fix the area IDs (resolve them with `GET /v1/areas`) | | 429 `RATE_LIMIT_EXCEEDED` | Wait for `Retry-After` seconds, then retry | | 429 `QUOTA_EXCEEDED` | Do not retry. There is no `Retry-After`; wait for your quota period to reset or upgrade (read `quotaContext`). For the watcher cap, pause or delete a watcher first | | 500 | Retry with exponential backoff | | 502 | Retry after a brief delay | | 503 `UPSTREAM_ERROR` | Retry after a few seconds (`retryAfter`) with exponential backoff; do not rotate your key | | 503 `SERVICE_DISABLED` | Service is temporarily down; retry later | ## Request tracing Every response includes an `X-Request-Id` header (UUID v4). Include this ID in support requests for log correlation. # Search Filters Source: https://qwady.wiki/borough/guides/filtering All available search parameters for rental and sale listings ## Common filters These filters work on both `/v1/search/rentals` and `/v1/search/sales`: | Parameter | Type | Description | Example | | - | - | - | - | | `areas` | string | Comma-separated area IDs. A borough ID (`100`, `200`, `300`, `400`, `500`) matches its ID range (for example `100` covers 100-199). Any other area with child areas (for example `140` Upper East Side) covers its whole subtree. Exception: Marble Hill (`226`) is listed under Manhattan in `/areas` but is returned for the Bronx (`200`), not Manhattan (`100`). At most 100 distinct IDs; more, or a non-numeric or zero ID, returns `400`, and an unknown area ID returns `422 INVALID_PARAMS` | `120,115` | | `excludeAreas` | string | Comma-separated area IDs to exclude (exact match: child areas are not excluded, including for boroughs). Unknown IDs are ignored rather than rejected | `115` | | `minPrice` | integer | Minimum price | `2000` | | `maxPrice` | integer | Maximum price | `5000` | | `minBeds` | integer | Minimum bedrooms | `1` | | `maxBeds` | integer | Maximum bedrooms | `3` | | `minBaths` | integer | Minimum full bathrooms | `1` | | `maxBaths` | integer | Maximum full bathrooms | `2` | | `minSqft` † | integer | Minimum square footage | `500` | | `maxSqft` † | integer | Maximum square footage | `1500` | | `amenities` † | string | Comma-separated amenity enums | `DOORMAN,GYM` | | `petsAllowed` † | boolean | Pet-friendly listings | `true` | | `buildingType` | string | Comma-separated building types | `CONDO,CO_OP` | | `excludeBuildingType` | string | Comma-separated building types to exclude | `HOUSE,TOWNHOUSE` | ## Rental-only filters | Parameter | Type | Description | Example | | - | - | - | - | | `noFee` †‡ | boolean | Confirmed no-broker-fee listings only | `true` | A broker fee is something you pay to rent, so sale listings carry no broker-fee flag. Sending `noFee` to `/v1/search/sales` returns `400 INVALID_PARAMS` rather than matching nothing. ## Advanced filters | Parameter | Type | Description | Example | | - | - | - | - | | `minDaysOnMarket` † | integer | Minimum days listed | `7` | | `maxDaysOnMarket` † | integer | Maximum days listed | `30` | | `listedAfter` † | date | Listed on or after this date (`YYYY-MM-DD`; datetimes are accepted and normalized to the UTC date) | `2026-01-01` | | `listedBefore` † | date | Listed on or before this date | `2026-02-01` | | `updatedSince` | date | Only listings last seen in the active feed on or after this date (`YYYY-MM-DD`; datetimes are accepted and normalized to the UTC date). Not a change feed: see [Incremental sync](#incremental-sync) | `2026-06-01` | | `hasOpenHouse` †‡ | boolean | Confirmed scheduled-open-house listings only | `true` | | `has3dTour` †‡ | boolean | Confirmed 3D-virtual-tour listings only | `true` | | `maxMonthlyCost` | integer | Max monthly cost. Rentals: the monthly rent. Sales: monthly carrying costs (maintenance + common charges + taxes), not the purchase price; sales whose carrying costs are not recorded yet are left out and `meta.warnings` says so | `6000` | | `minTransitScore` † | integer | Minimum transit score (0-100) | `70` | | `fiberOnly` † | boolean | Require a fiber provider match for the building | `true` | | `minDownloadMbps` † | integer | Require a provider with at least this advertised download speed | `1000` | ## Detail-only filter coverage † and ‡ above mark filters that touch **detail-only fields**: data that only a listing-detail scrape populates, as opposed to the search-feed sweep that keeps most of Borough's data warm. **Currently around 55% of ACTIVE listings have never been detail-scraped** — an approximate figure that shifts over time as the backlog drains and new listings arrive, but reliably the majority case, not an edge case; see [Detail-only fields and coverage](/borough/guides/data-freshness#detail-only-fields-and-coverage) for the full picture. Two consequences follow from that: **† Under-return, not exclusion.** `minSqft`/`maxSqft`, `amenities` (the listing-level match), `hasOpenHouse=true`, `has3dTour=true`, `noFee=true`, and the `listedAt`-derived filters (`minDaysOnMarket`, `maxDaysOnMarket`, `listedAfter`, `listedBefore`, and `sort=LISTED_AT` or the default `sort=RECOMMENDED`) all only match against data that requires a detail scrape (or, for `listedAt`, is a feed-first-seen estimate until one runs). A listing that hasn't been detail-scraped is dropped from `minSqft`/`maxSqft`/`amenities` results and cannot satisfy `hasOpenHouse=true`/`has3dTour=true`/`noFee=true` even if the true value would have matched. It's excluded for lack of data, not because it fails the filter. `amenities` matching also depends on listing-level amenities being regex-derived from the listing description, so an amenity the description never mentions reads as absent, not merely unconfirmed. `sort=LISTED_AT` can rank an undetailed listing as newer than it really is, since its `listedAt` is only an estimate. Whenever any of these filters (or `sort=LISTED_AT`) is used, the response's `meta.warnings` array carries a caveat string naming the specific gap. Check it rather than assuming `meta.total` is a population count. The default `sort=RECOMMENDED` currently orders by `listedAt` too, so it has the same ranking caveat, but it does not add a warning. **† Building-derived filters.** `minTransitScore`, `fiberOnly`, `minDownloadMbps`, the building-level half of `amenities`, and the building pet-policy fallback for `petsAllowed` all read the listing's building. A listing is linked to its building only once it is fetched in detail, so an undetailed listing cannot match any of them. `petsAllowed` has the same gap from both sides: the listing's own pet flag is detail-only, and the building fallback needs the link. The warnings some of these add (transit, broadband, amenities) do not mention the building-link gap, and `petsAllowed` adds no warning. **‡ `=false` is rejected, not silently ignored.** `noFee`, `has3dTour`, and `hasOpenHouse` are detail-only booleans stored as `null` until a detail scrape runs. Borough cannot yet distinguish "confirmed false" from "not yet checked." Passing `noFee=false`, `has3dTour=false`, or `hasOpenHouse=false` returns `400 INVALID_PARAMS` rather than the previous behavior of silently matching every listing (including every undetailed one). Use `=true` for confirmed matches, or omit the parameter to search all listings regardless of that field's value. ## Incremental sync `updatedSince` matches listings whose `last_seen_at`, the time the listing was last seen in the active feed, is on or after the value you pass. Matching is by calendar day (UTC); any time component in the value is ignored. ```bash theme={null} curl "https://borough.qwady.app/v1/search/rentals?areas=120&updatedSince=2026-06-01" ``` It is **not a change feed**. Every scheduled search refresh (rentals every 6 hours, sales every 8 hours) re-stamps `last_seen_at` on every listing it sees, whether or not anything about the listing changed. Any date before the latest refresh therefore matches nearly the whole active set, and search returns only `ACTIVE` listings, so listings that went off-market never appear. To find what changed or was removed, use [webhooks](/borough/guides/webhooks) or [watchers](/borough/guides/streaming#persistent-watchers), or compare full result sets between runs. Each listing also carries `sqft` and a derived `pricePerSqft` (rounded to the nearest dollar) when square footage is known; both are `null` for listings whose detail page has not yet been scraped. Broadband filters are based on Borough's FCC-backed provider enrichment. Coverage may be exact-building when a listing is attached to a matched FCC location, or Census-block level when sourced from FCC BDC public bulk files. Listings in unmatched buildings or blocks will not satisfy these filters. > This product uses FCC APIs and/or Data but is not endorsed or certified by the FCC. ## Not currently supported These are common requests, but they are not current Borough search filters: * `prewar` * `transitLines` * view-style amenity enums such as `CITY_VIEW` or `WATER_VIEW` Unrecognized query parameters, including these and misspelled filter names, are ignored rather than rejected. A typo returns unfiltered results with a `200`, so check your parameter names against this page. ## Sale-only filters | Parameter | Type | Description | Example | | - | - | - | - | | `saleType` | enum | Sale type filter | `RESALE` | Valid values: `RESALE`, `SPONSOR_UNIT`, `FORECLOSURE`, `RESTRICTED`, `AUCTION`, `SHORT`, `UNKNOWN` `saleType` sent to `/v1/search/rentals` is ignored (the results are not filtered), unlike `noFee` on sales, which returns `400`. ## Sorting | Parameter | Type | Default | Options | | - | - | - | - | | `sort` | enum | `RECOMMENDED` † | `RECOMMENDED` †, `PRICE`, `LISTED_AT` † | | `sortDir` | enum | `DESC` | `ASC`, `DESC` | † `RECOMMENDED` currently orders by `listedAt` (recency), the same as `LISTED_AT`, so both carry the `listedAt` estimate caveat: see [Detail-only filter coverage](#detail-only-filter-coverage) above. Only `sort=LISTED_AT` adds the `meta.warnings` entry. ## Example: complex search ```bash theme={null} curl "https://borough.qwady.app/v1/search/rentals?\ areas=120,115&\ minPrice=2000&\ maxPrice=4500&\ minBeds=1&\ maxBeds=2&\ noFee=true&\ petsAllowed=true&\ minTransitScore=70&\ maxDaysOnMarket=14&\ sort=PRICE&\ sortDir=ASC&\ fiberOnly=true&\ minDownloadMbps=1000" ``` # Pagination Source: https://qwady.wiki/borough/guides/pagination Page-based pagination with navigation links ## Overview Borough uses page-based pagination. Pages are **1-indexed** (first page is `page=1`). ## Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | 1 | Page number (1-indexed), from 1 to 1,000,000. Anything else returns `400` | | `perPage` | integer | 10 | Results per page (max depends on tier) | Pass an explicit `perPage` (up to your tier max) for larger pages. A `perPage` above your tier max is not rejected: it is lowered to the max, and `meta.perPage` and the `links` reflect the value actually used. Read `meta.perPage` rather than assuming your requested size was honored. ## Response metadata ```json theme={null} { "data": [...], "meta": { "total": 342, "page": 2, "perPage": 50, "links": { "self": "https://borough.qwady.app/v1/search/rentals?page=2&perPage=50", "first": "https://borough.qwady.app/v1/search/rentals?page=1&perPage=50", "last": "https://borough.qwady.app/v1/search/rentals?page=7&perPage=50", "prev": "https://borough.qwady.app/v1/search/rentals?page=1&perPage=50", "next": "https://borough.qwady.app/v1/search/rentals?page=3&perPage=50" } } } ``` * **`total`**: Total number of matching results across all pages * **`page`**: Current page number * **`perPage`**: Results per page * **`links`**: Navigation URLs for self, first, last, prev (null on first page), and next (null on last page). Present on search and `GET /v1/building/{id}/listings`; `GET /v1/areas` is paginated but its `meta` has no `links`, so page it with `total`, `page`, and `perPage` ## Max perPage by tier | Tier | Max perPage | | - | - | | Free | 10 | | Starter | 50 | | Pro | 500 | | Business | 500 | ## SDK auto-pagination The TypeScript SDK supports automatic pagination via async iterators: ```typescript theme={null} const results = await borough.rentals.search({ areas: '120', perPage: 50 }); // Iterate across pages automatically (up to 100 pages by default) for await (const listing of results) { console.log(listing.address.street); } // Or collect into an array with an optional limit const all = await results.toArray({ limit: 200 }); ``` Every page the SDK fetches is a separate request and costs one unit of quota, so prefer a larger `perPage` (up to your tier max) over many small pages. Auto-iteration stops at **100 pages** by default. If more pages remain at that point, it throws `PaginationLimitError` (exported by the SDK) rather than silently returning a partial set; at the default `perPage` of 10, that is any result set over 1,000 items. `toArray({ limit })` does not throw when the limit is reached within the cap. Change the cap with the second argument to `search`: ```typescript theme={null} // Raise the cap, or pass Infinity to remove it const results = await borough.rentals.search({ areas: '120' }, { maxAutoPages: 500 }); // Or stop quietly at the cap and check whether the scan was cut short const bounded = await borough.rentals.search({ areas: '120' }, { onMaxAutoPages: 'stop' }); for await (const listing of bounded) { /* ... */ } if (bounded.truncated) { /* more pages were left unread */ } ``` # Rate Limits Source: https://qwady.wiki/borough/guides/rate-limits Per-tier rate limits and quota enforcement ## Limits by tier | 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 | The per-minute figure is a **burst ceiling for your key**, not a reserved throughput guarantee. Each tier's limit is fully reachable on its own, but authenticated traffic across all keys shares a platform-wide budget, so sustained parallel bursts at the top of the range can return `503` before you reach your own limit. If you need sustained high throughput rather than headroom for spikes, get in touch before you build against it — see [Handling 503](#handling-503) below. ## Response headers Metered `/v1` responses include rate-limit and quota headers. The public `/v1/health` endpoint is outside this middleware chain. ``` X-RateLimit-Limit: 60 X-Quota-Limit: 25000 X-Quota-Used: 142 X-Quota-Remaining: 24858 X-Quota-Overage-Limit: 50000 X-Request-Id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` | Header | Description | | - | - | | `X-RateLimit-Limit` | Max requests per minute for your tier | | `X-Quota-Limit` | Included quota for the active quota window | | `X-Quota-Used` | Requests used in the active quota window | | `X-Quota-Remaining` | Requests remaining in base quota (can be 0 during overage) | | `X-Quota-Overage-Limit` | Hard limit including overage headroom (paid tiers only) | | `X-Request-Id` | Unique request ID for log correlation | `X-Quota-Overage-Limit` is only present for paid tiers. When `X-Quota-Used` exceeds `X-Quota-Limit`, you are in the overage zone: requests continue but incur per-request charges. Requests are blocked at the hard limit. See [Pricing](/borough/tiers-and-pricing#overage-billing) for overage rates. If the response also carries `X-Quota-Degraded: true`, Borough's primary usage counter was momentarily unavailable and the `X-Quota-*` numbers come from a backup copy. Treat them as a floor, not an exact count. Free anonymous quotas reset on the 1st of each calendar month. Paid customer quotas and overage counters follow the active Polar subscription billing period, so usage aligns with the billing invoice rather than a calendar-month boundary. ## What counts against quota Quota is counted in **units**. Most requests cost one unit, with these exceptions: | Request | Units | | - | - | | Any successful (`2xx`) metered request | 1 | | Any request that returns a non-`2xx` status | 0 (failed requests are not billed) | | `POST /v1/property/batch` | 1 per requested ID, after duplicates are removed | | `GET /v1/photos/{key}` | 1 per image, including repeat downloads of the same photo, so cache images you have already fetched | | Each watcher poll | 1, billed when the poll runs, even if it finds nothing or fails | | Each `GET /v1/watchers/{id}/stream` connection | 1. Streams reconnect about every 5 minutes, so one kept open continuously costs about 8,640 units per 30 days | | `PATCH` or `DELETE` on a watcher or webhook subscription | 0, so you can always pause or remove automations, even at the hard limit | | `GET /v1/usage` | 0 | One quota is shared by all API keys that belong to the same customer; issuing more keys does not add quota. See [Streaming](/borough/guides/streaming#quota-impact) for worked watcher costs. ## Handling 429 responses When you exceed the rate limit, the API returns a `429` status with a `Retry-After` header: ```json theme={null} { "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Rate limit of 60 requests per minute exceeded. Retry after 60 seconds.", "status": 429, "retryAfter": 60, "upgrade": { "currentTier": "PRO", "requiredTier": "BUSINESS", "upgradeUrl": "https://polar.sh/qwady-solutions-llc/portal", "pricingUrl": "https://qwady.wiki/borough/tiers-and-pricing?utm_source=api" } } } ``` The wait time (in seconds) is exposed both in the `Retry-After` response header and as `error.retryAfter` in the JSON body. The SDK reads it automatically. Below Business, the body also carries an `upgrade` block naming the next tier, whose per-minute limit is higher. A separate per-IP ceiling of **300 requests per minute** applies before your key is checked, shared by every key calling from the same IP address. It sits above every tier's per-key limit, so you only reach it when many keys or clients share one address. Its `429` is also `RATE_LIMIT_EXCEEDED` (message `"Too many requests from this IP. Retry after 60 seconds."`) with a `Retry-After: 60` header, but it has no `error.retryAfter` in the body and no `X-RateLimit-Limit` header, so read the header. The SDK falls back to its own short backoff for this case. **Best practices:** 1. Read the `Retry-After` header and wait before retrying 2. Implement exponential backoff for repeated 429s 3. Use the SDK, which handles retries automatically ## Handling 503 A `503` with code `UPSTREAM_ERROR` means Borough could not verify your key against its billing provider — usually because the shared verification budget is momentarily saturated, occasionally because the provider is degraded. It is explicitly **retryable** and says nothing about your key being invalid, which is why it is a `503` and not a `401`. ```json theme={null} { "error": { "code": "UPSTREAM_ERROR", "message": "Subscription verification is temporarily unavailable. Please retry.", "status": 503, "retryAfter": 5 } } ``` Retry with exponential backoff and jitter. Do not re-issue or rotate your key in response to a `503` — the key is fine. The SDK retries this automatically. If you see it steadily rather than in bursts, tell us: that is a capacity signal on our side, not a bug in yours. ## Quota exceeded Paid tiers have overage headroom: requests continue past the base quota and are billed at your tier's overage rate, up to the overage cap. Once the hard limit is reached: ```json theme={null} { "error": { "code": "QUOTA_EXCEEDED", "message": "Monthly quota of 5000 requests plus overage cap exceeded. Resets at the end of the current billing period (2026-06-02T12:00:00.000Z).", "status": 429, "quotaContext": { "currentTier": "STARTER", "currentQuota": 5000, "nextTier": "PRO", "nextTierQuota": 25000, "upgradeUrl": "https://polar.sh/qwady-solutions-llc/portal" }, "upgrade": { "currentTier": "STARTER", "requiredTier": "PRO", "upgradeUrl": "https://polar.sh/qwady-solutions-llc/portal", "pricingUrl": "https://qwady.wiki/borough/tiers-and-pricing?utm_source=api" } } } ``` A `QUOTA_EXCEEDED` response has no `Retry-After` and retrying cannot succeed until the quota period resets; the SDK does not retry it. Every `QUOTA_EXCEEDED` for quota, on any tier, carries a `quotaContext` block and an `X-Quota-Tier` header; below the top tier it also carries an `upgrade` block and an `X-Quota-Upgrade-URL` header. For a paying subscriber, `upgradeUrl` points to the customer portal, where you change plan; it is a checkout link only for callers without a subscription. Free-tier users are blocked at 100 requests with no overage. The `quotaContext` lets you prompt an upgrade at the moment you hit the limit: ```json theme={null} { "error": { "code": "QUOTA_EXCEEDED", "message": "Monthly quota of 100 requests exceeded. Resets on the 1st of next month.", "status": 429, "quotaContext": { "currentTier": "FREE", "currentQuota": 100, "nextTier": "STARTER", "nextTierQuota": 5000, "upgradeUrl": "https://buy.polar.sh/..." }, "upgrade": { "currentTier": "FREE", "requiredTier": "STARTER", "upgradeUrl": "https://buy.polar.sh/...", "pricingUrl": "https://qwady.wiki/borough/tiers-and-pricing?utm_source=api" } } } ``` `nextTier` and `upgradeUrl` are `null` once you are on the top tier (Business). ## Checking your usage Read the `X-Quota-*` response headers on any API response, or call `GET /v1/usage` for a full breakdown without spending a request: ```json theme={null} { "data": { "tier": "STARTER", "quota": 5000, "used": 1234, "remaining": 3766, "overageLimit": 15000, "percentUsed": 25, "periodStart": "2026-07-01T00:00:00.000Z", "periodEnd": "2026-08-01T00:00:00.000Z", "usedSource": "counter" }, "meta": { "dataAge": null, "source": "live" } } ``` `periodStart` and `periodEnd` are `null` for Free, whose quota resets on the 1st of each month. `usedSource` says where `used` came from: | `usedSource` | Meaning | | - | - | | `counter` | The authoritative usage counter. `meta.source` is `live` | | `mirror` | A backup copy, used while the counter is unavailable; it can lag. `meta.source` is `stale` and `X-Quota-Degraded: true` is set | | `unknown` | Neither was available. `used: 0` here does **not** mean zero usage, and `remaining` and `percentUsed` are unreliable. `meta.source` is `stale` and `X-Quota-Degraded: true` is set | `GET /v1/usage` is authenticated and rate-limited but does **not** count against your monthly quota. The full machine-readable API spec is served at [`GET /v1/openapi.json`](https://borough.qwady.app/v1/openapi.json) for import into Postman, Bruno, or code generators. # Streaming and Live Lookups Source: https://qwady.wiki/borough/guides/streaming 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 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: | 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 `message` and, for fetch failures, a machine-readable `code` (see below) | | `done` | Stream complete; always sent last, including after an `error` | ```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 instead of `live`, followed by `done`, and the stream closes. The `cached` event you already received still stands. | `error` code | Meaning | | - | - | | `fetch_failed` | The live fetch failed for an unclassified reason | | `upstream_unavailable` | The upstream source was unavailable or timed out | | `upstream_rate_limited` | The upstream source rate-limited the request | | `proxy_auth_failed` | Borough's live-fetch service could not authenticate | | `budget_exceeded` | The shared live-fetch budget is held for Borough's scheduled updates | | `service_disabled` | Live fetching is paused | | `cancelled` | The 60-second stream limit was reached before live data arrived | 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: | Type | Monitors | Change Events | | - | - | - | | `listing` | A single listing | Price changes, status changes | | `building` | Active listings linked to a building | `listing.created` when a listing joins, `listing.expired` when it leaves | | `search` | Active listings matching `searchFilters` | `listing.created` when a listing starts matching, `listing.expired` when it stops | 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`: | Key | Meaning | | - | - | | `areas` | Area IDs (a number, a comma-separated string, or an array; at most 100 distinct). A container area covers its subtree, as in search. Unlike search, unknown IDs are not rejected; they match no listings, so check IDs with `GET /v1/areas` first | | `minPrice`, `maxPrice` | Price bounds | | `beds` | An **exact** bedroom count, not a minimum | | `listingType` | Lowercase `rental` or `sale` | | `buildingType` | Comma-separated building types | 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 ```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":3600}' ``` ### Poll intervals Each poll costs one API request against your active quota window. | Tier | Watch type | Minimum | Default | | - | - | - | - | | Business | `building`, `search` | 300s (5 min) | 900s (15 min) | | Business | `listing` | 3600s (1 hour) | 3600s (1 hour) | | Internal | All | 60s (1 min) | 900s (15 min) | 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: | 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. 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. ```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":3600} 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": 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](#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: ``` 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: ` request header | Reconnecting. This is what browser `EventSource` and the Borough SDK replay automatically. | | `?since=` 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 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](#listing-stream-sse). 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 | 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; up to 5 active, hourly polls) | | Alert when new units appear in a building | Watcher (building type) | | Alert when new listings match search criteria | Watcher (search type; filters matching 500 or fewer active listings) | | Webhook delivery to your server | [Webhook subscription](/borough/guides/webhooks) (citywide by event type; filter in your handler) | 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](/borough/guides/data-freshness#live-first-data-business--internal) for details. # Webhooks Source: https://qwady.wiki/borough/guides/webhooks Push notifications for listing changes, delivered within minutes of detection Webhooks require a **Business** tier subscription. ## Overview Borough webhooks notify you when a listing changes, delivered within minutes of Borough detecting the change on its regular refresh cycle (not the instant it changes upstream). Webhooks use the [Standard Webhooks](https://www.standardwebhooks.com/) specification for payload signing and verification. Detection sets the pace, and two things can slow it: * **Price events on detailed listings lag.** For a listing Borough has already fetched in detail (`detailScraped: true`), the regular refresh does not re-read its price. A price change is detected only when Borough re-checks that listing's detail page, which can be days to weeks later, so `listing.price_decreased` and `listing.price_increased` for those listings can arrive well after the change. See [Price freshness](/borough/guides/data-freshness#price-freshness). * **Delivery is throughput-limited.** Borough publishes up to 100 detected changes every 5 minutes (about 1,200 an hour). After a refresh that detects a large batch of changes, the backlog is delivered over hours rather than minutes. ## Event types | Event | Description | | - | - | | `listing.created` | A new listing appeared in the search index | | `listing.price_decreased` | Listing price dropped by more than \$25 | | `listing.price_increased` | Listing price increased by more than \$25 | | `listing.status_changed` | Listing status changed to something other than off-market, for example `OFF_MARKET` to `ACTIVE` when a listing is relisted. Off-market transitions are delivered as `listing.expired` | | `listing.expired` | Listing went off-market | ## Delivery scope Subscriptions are matched on **event type only**. There is no per-subscription area, price, or listing filter: an active subscription receives every change of a subscribed type across the whole Borough dataset, so subscribing to `listing.created` means every new listing Borough indexes, citywide. Two ways to narrow that down: * **Filter in your handler.** Every payload carries `data.listingId` (see below); ignore the ones you do not care about. * **Use a [watcher](/borough/guides/streaming#persistent-watchers).** `POST /v1/watchers` scopes monitoring to a single listing, a single building, or one saved search, and emits changes for that target only. Watchers deliver on their own SSE stream, never to your webhook URL, and they are not a general-purpose filter: a Business customer can have at most 5 active listing watchers (polling at most hourly), and a saved-search watcher only accepts filters that match 500 or fewer active listings. For anything broader, filter webhook events in your handler. ## Payload format ```json theme={null} { "type": "listing.price_decreased", "data": { "listingId": "4961849", "oldValue": "5222", "newValue": "4900" }, "timestamp": "2026-02-15 14:30:00" } ``` `timestamp` is when Borough detected the change, in UTC, formatted `YYYY-MM-DD HH:MM:SS` with no `T` and no zone designator. Some parsers (including JavaScript's `Date`) read that format as local time, so parse it explicitly as UTC, for example `new Date(timestamp.replace(' ', 'T') + 'Z')`. The time the delivery was signed is in the `webhook-timestamp` header. ## Signature verification Every webhook request includes three headers: | Header | Description | | - | - | | `webhook-id` | Delivery ID for this event and subscription; the same on every retry or duplicate, so deduplicate on it | | `webhook-timestamp` | Unix timestamp of signing | | `webhook-signature` | `v1,` | The signature is computed as: ``` HMAC-SHA256(key, "${webhook-id}.${webhook-timestamp}.${body}") ``` where `key` is your subscription secret with the `whsec_` prefix removed and the remainder base64-decoded, per the Standard Webhooks specification. Using the literal secret string as the key produces a signature that never matches. ### Verify with the SDK The SDK's webhook helpers rely on the `standardwebhooks` package, an optional peer dependency. Install it alongside the SDK: ```bash theme={null} npm install @borough/sdk standardwebhooks ``` Without it, verification throws, the helper answers `400`, and Borough keeps retrying the delivery until it is dead-lettered. ```typescript Express theme={null} import { webhookMiddleware } from '@borough/sdk/webhooks/express'; app.post('/webhooks/borough', express.raw({ type: 'application/json' }), webhookMiddleware(process.env.BOROUGH_WEBHOOK_SECRET, async (event) => { console.log('Received:', event.type, event.data); }) ); ``` ```typescript Next.js theme={null} // app/api/webhooks/borough/route.ts import { webhookHandler } from '@borough/sdk/webhooks/nextjs'; export const POST = webhookHandler( process.env.BOROUGH_WEBHOOK_SECRET!, async (event) => { console.log('Received:', event.type, event.data); } ); ``` ## Retry schedule Failed deliveries (non-2xx responses, timeouts, and connection errors) are retried up to **7 times** with exponentially increasing delays (10s, 30s, 90s, 4.5m, 13.5m, 40.5m, then 1h), so a delivery is attempted over roughly two hours before it is given up on. After all retries are exhausted, the delivery is moved to a dead letter queue. During a brief maintenance window, deliveries are parked and re-attempted rather than dropped. Parked attempts still count against the 7-retry budget, so an extended pause can send a delivery to the dead letter queue. Treat the retry budget as the ceiling in both cases. ## Delivery semantics * **Respond within 10 seconds.** Each delivery attempt times out after 10 seconds, and a timeout is retried like any other failure. Return a `2xx` quickly and do slow work afterwards. * **Expect duplicates.** Delivery is at-least-once: after a maintenance pause or a recovery re-send, the same event can arrive more than once. Deduplicate on the `webhook-id` header, which stays the same for every copy of one event sent to one subscription. * **Pausing or deleting stops queued deliveries.** Deliveries still in the queue for a subscription you pause or delete are cancelled rather than sent. * **Webhooks need an active Business subscription.** If your subscription drops below Business, Borough deactivates your webhook subscriptions automatically. ## Managing subscriptions Use the [Webhook CRUD endpoints](/borough/api-reference/webhooks/create-webhook) to create, list, update, and delete subscriptions. # Borough Source: https://qwady.wiki/borough/index Structured NYC real estate data, REST endpoints, streaming, watchers, and MCP access. Borough provides structured NYC real estate data through REST APIs, streaming endpoints, webhooks, watchers, and an MCP server for AI-native access. ## Start Here Get the product overview, pricing, and data surface in one place. Make your first Borough API request in under 2 minutes, no API key needed. Browse the core endpoints for listings, buildings, markets, and areas. Connect Borough to Claude, Cursor, or other MCP-capable clients. ## What Borough Covers * Listing search for rentals and sales. * Property, building, market, and neighborhood reference data. * Streaming responses, persistent watchers, and webhooks on supported plans. * SDK and MCP access patterns for developer and agent workflows. ## Key Docs * [Authentication](/borough/authentication) for keyless Free-tier access, API keys, and OAuth. * [Tiers & Pricing](/borough/tiers-and-pricing) for plan limits and access differences. * [SDK](/borough/sdk/index) for typed client usage. * [MCP Quickstart](/borough/mcp/quickstart) for AI assistant integration. # Introduction Source: https://qwady.wiki/borough/introduction Structured NYC real estate data via REST API Borough is a REST API that provides structured access to NYC real estate data including rental and sale listings, building details, neighborhood boundaries, market statistics, building quality scores, and FCC-backed internet provider availability. ## What Borough provides * **Dozens of structured listing fields** across summary and detail endpoints, including pricing, concessions, amenities, photos, and freshness metadata * **Building-level data** with amenities, policies, NYC regulatory data, and quality scores * **First-party photo delivery** through `/v1/photos/{key}` for supported Borough image sizes * **Likely internet providers** with FCC-sourced provider names, technologies, and advertised speeds * **316 NYC neighborhoods and sub-areas** with encoded map boundaries (349 areas in total, including the five boroughs and Northern New Jersey) * **Market snapshots** with inventory, median prices, days on market, and concession rates * **Building quality scores** derived from NYC and New York State open data (HPD violations, 311 noise, NYPD crime, and multi-modal transit access: subway, bus, commuter rail, ferry) * **Live SSE streaming** that delivers cached data immediately, then a best-effort live update (Pro+) * **Live-first property and building detail** that fetches fresh data when cached listing detail is more than 5 minutes old, or building detail more than 30 minutes old, while the live-fetch budget allows (Business+) * **Persistent watchers** that monitor listings, buildings, or searches for changes (Business+; at most 5 active listing watchers, each polling at most hourly) * **Webhooks** for automated notifications on price drops, new listings, and status changes (Business+) * **MCP server** for connecting AI agents directly to NYC real estate data ## Pricing | Tier | Price | Requests/month | Rate Limit | | - | - | - | - | | Free | \$0 | 100 | 10/min | | Starter | \$19/mo | 5,000 | 30/min | | Pro | \$49/mo | 25,000 | 60/min | | Business | \$99/mo | 100,000 | 120/min | Free tier covers search, area reference, market snapshots, and photo delivery. The public `/v1/health` endpoint is also available without authentication, but it is outside Borough's metered `/v1` data surface. Starter and above add property details and building data. Pro adds live SSE streaming. Business adds live-first detail, webhooks, persistent watchers, and change notifications. Live data is best-effort. Live fetches draw on a shared budget, and while that budget is held for Borough's scheduled updates, or live fetching is paused, detail requests return cached data and listing streams send an `error` event in place of the live one. See [Tiers & Pricing](/borough/tiers-and-pricing). ## Product surface Borough keeps its public product pages and service runtime on the same hostname without changing the integration URLs developers depend on. * Public site and legal pages live at `borough.qwady.app/` * REST endpoints stay under `borough.qwady.app/v1/*` * MCP remains at `borough.qwady.app/mcp` * OAuth and discovery stay under `borough.qwady.app/oauth/*` and `borough.qwady.app/.well-known/*` * Webhook subscriptions stay under `borough.qwady.app/v1/webhooks/*` ## Internet provider availability Building and property detail responses can include an `internetProviders` block sourced from official FCC Broadband Data Collection public data. * Coverage is **exact-building** when Borough has a matched FCC location and **Census-block level** when sourced from FCC public bulk files * Coverage is not unit-specific qualification * Data reflects likely providers, advertised technologies, and max download/upload speeds * The FCC publishes this dataset **twice a year**. Borough checks monthly and re-imports when a new FCC filing period is available, so in most months there is nothing new to pull. Manual backfills are supported for operational re-syncs * Broadband data currently covers about **76.5%** of eligible buildings, meaning those with an active listing and a geocoded location. Buildings with no active listing are out of scope by design > This product uses FCC APIs and/or Data but is not endorsed or certified by the FCC. Make your first request in under 2 minutes, no API key needed Explore all 20+ endpoints with examples and response schemas Connect Claude, Cursor, or VS Code to query NYC data via AI Install the official SDK for typed access with auto-pagination # Add Borough to Claude Source: https://qwady.wiki/borough/mcp/add-to-claude Exact steps for adding Borough as a custom connector in Claude and Claude Desktop ## What You Need * Optional: a Borough API key for Starter, Pro, or Business access. Without one you can continue on the free tier * Claude web or Claude Desktop Custom connector availability in Claude can vary by Anthropic plan and rollout. Check Anthropic's current connector docs in your account if the add-connector flow is missing. ## Connector URL Use this remote MCP server URL: ```text theme={null} https://borough.qwady.app/mcp ``` Claude discovers Borough's OAuth metadata automatically through the standard `/.well-known` documents: ```text theme={null} https://borough.qwady.app/.well-known/oauth-protected-resource https://borough.qwady.app/.well-known/oauth-protected-resource/mcp https://borough.qwady.app/.well-known/oauth-authorization-server ``` The `401` from `/mcp` points clients to the first document; the second is the path-specific version defined by RFC 9728. ## Add Borough In Claude ### Pro, Max, or Free individual plans 1. Open **Settings → Connectors** 2. In the **Connectors** section, click **Add custom connector** 3. Paste: ```text theme={null} https://borough.qwady.app/mcp ``` 4. Click **Add** 5. Claude will open Borough's OAuth flow 6. On the Borough consent screen: * enter a Borough API key to activate your paid tier, or * continue without a key to use the free MCP tool set 7. Finish authorization 8. In chat, open **Search and tools** and enable Borough ### Team and Enterprise plans Owners add the connector first: 1. Open **Organization settings → Connectors** 2. Click **Add custom connector** 3. Paste: ```text theme={null} https://borough.qwady.app/mcp ``` 4. Save the connector Then individual users: 1. Open **Settings → Connectors** 2. Find **Borough** 3. Click **Connect** 4. Complete the Borough OAuth flow 5. Enable Borough from **Search and tools** in chat ## What Auth Does In Borough Borough's MCP server is tier-aware. * If you authorize with no Borough API key, Claude gets the free tool set: * `search_rentals` * `search_sales` * `list_areas` * market tools * If you authorize with a paid Borough API key, Borough maps your tier and exposes the matching tools * Property and building detail tools are only exposed for non-free Borough tiers This means Claude only sees the tools your Borough configuration is allowed to use. Without a key, Borough counts the free limits (10 requests a minute, 100 a month) per connecting IP address. Custom connector requests typically reach Borough from Anthropic's servers rather than your own network, so free users can share one allowance and run it out. Use a paid key for dependable access. ## Troubleshooting ### The icon does not update Claude may cache connector icons. If Borough's icon looks stale: 1. Remove the connector from **Settings → Connectors** 2. Add it again with: ```text theme={null} https://borough.qwady.app/mcp ``` 3. Reconnect auth Anthropic's current directory FAQ also notes that connector icons may come from the connector's favicon or a provided icon URL, so updates can lag if the client or icon source is cached. ### Borough shows too many results in chat Borough's MCP tools now use smaller MCP-facing defaults than the REST API. If a conversation still gets too large: * ask for a smaller page * use narrower area filters * prefer exact area IDs instead of borough-level IDs when possible Example: ```text theme={null} Search rentals in area IDs 410 and 488, max 5 results, under $4,500 ``` ### Claude says Borough has no tools Check: 1. The connector URL is exactly `https://borough.qwady.app/mcp` 2. The auth flow completed successfully 3. Borough is enabled from **Search and tools** 4. Your current Borough tier actually includes the tool you are asking for ## Example Prompts * "Search rentals in Williamsburg and Greenpoint under \$4,500" * "List neighborhoods in Queens" * "Compare market snapshot data for Astoria and Long Island City" * "Get details for property 4961849" ## Related Links * [MCP Overview](/borough/mcp/overview) * [MCP Quick Start](/borough/mcp/quickstart) * [MCP Tools Reference](/borough/mcp/tools-reference) * [MCP Troubleshooting](/borough/mcp/troubleshooting) # MCP Server Source: https://qwady.wiki/borough/mcp/overview Connect AI agents to NYC real estate data via the Model Context Protocol ## What is Borough MCP? Borough provides a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that lets AI agents query NYC real estate data through natural language tool calls. Instead of writing code to call the REST API, you can ask your AI assistant questions like: * "Find me 2-bedroom apartments in Williamsburg under \$4,000/month" * "Compare rents between Park Slope, Astoria, and the East Village" * "Get details on property 4961849" The MCP server exposes 10 tools that map to Borough's REST API endpoints, with the same tier-based access, rate limits, and quota enforcement. ## How It Works ``` AI Agent (Claude, Cursor, VS Code) ↓ MCP tool call Borough MCP Server (https://borough.qwady.app/mcp) ↓ internal REST call with your API key Borough REST API (/v1/search/rentals, /v1/property/:id, etc.) ↓ Response formatted for AI consumption ``` Authenticated MCP sessions are mapped to Borough tiers, so the same tool restrictions, rate limits, and monthly quotas apply as the REST API. ## Authentication Methods Borough MCP supports two connection patterns: | Method | Best For | Setup | | - | - | - | | **OAuth-based connector flow** | Claude Connectors and other OAuth-capable MCP clients | Auto-discovered via `/.well-known/`: paste the MCP URL and complete Borough auth | | **Bearer Token** | Direct MCP config files and CLI clients | `Authorization: Bearer BOROUGH-` header | OAuth is the recommended method for MCP clients that support it. Free-tier MCP access is available through the Borough auth flow, and paid Borough API keys enable additional MCP tools. Free-tier sessions have no API key, so their limits (10 requests a minute, 100 a month) are counted per connecting IP address. With a hosted client such as Claude on the web, requests reach Borough from the client platform's servers, so many free users can share, and use up, one allowance. Use a paid key for dependable access. ## Compact and location-aware by default Borough MCP is optimized for chat clients: * search and building-listing tools return small default result sets * tool responses are compact projections instead of full raw API payloads * exact neighborhood names such as `Chelsea`, `Astoria`, or `Park Slope` can be resolved directly without manually looking up IDs first (each name resolved costs one extra API request; pass numeric area IDs to avoid it) * `list_areas` is the safest path when you want to lock a precise area ID before searching * if a location is ambiguous, Borough returns a short clarification list instead of a large fallback payload * search and building-listing results include render-oriented fields such as `price`, `displayPrice`, `geoPoint`, `leadPhotoUrl`, and `renderHints` * photo rendering is still client-dependent in this version because Borough returns photo URLs, not inline MCP image blocks ## Available Tools | Tool | Tier | Description | | - | - | - | | `search_rentals` | Free | Search rental listings by exact name or area ID with compact, render-friendly results | | `search_sales` | Free | Search sales listings by exact name or area ID with compact, render-friendly results | | `list_areas` | Free | Resolve boroughs and neighborhoods by name or lock precise area IDs before searching | | `get_market_snapshot` | Free (authenticated) | Current market stats for a neighborhood by ID or name | | `get_market_trends` | Free (authenticated) | Recent price trends (up to 365 rows, one per date and event type, typically the last few months) by ID or name | | `compare_neighborhoods` | Free (authenticated) | Side-by-side comparison of 1-5 areas by ID or name | | `get_property` | Starter | Listing detail including amenities, pet policy, fee and carrying-cost summary, and building scores | | `get_property_by_url` | Starter | Look up listing by URL path | | `get_building` | Starter | Building detail (amenities, policies, management, tax abatement) | | `get_building_listings` | Starter | Compact, card/map-friendly listing summaries for a building | ## Next Steps Get connected in 2 minutes Detailed tool documentation # Quick Start Source: https://qwady.wiki/borough/mcp/quickstart Connect your AI agent to Borough in 2 minutes ## Prerequisites 1. An MCP-compatible AI client 2. Optional: a Borough API key from a paid plan if you want paid-tier tools. Clients that support MCP OAuth (such as Claude) can use the free tools (search, areas, and market data) without one: choose **Continue without key** on the consent screen. Keyless free usage is counted per connecting IP address, so with a hosted client it may be shared with other users; see [MCP Server](/borough/mcp/overview#authentication-methods) If you are connecting from Claude or Claude Desktop, use the dedicated guide: * [Add Borough to Claude](/borough/mcp/add-to-claude) ## Remote Connection (Recommended) Most MCP clients support connecting to remote servers directly. Use the Borough MCP endpoint: ``` https://borough.qwady.app/mcp ``` For clients without OAuth support, pass your Borough API key via the `Authorization` header. This needs a key from a paid plan; the keyless free tier is only available through the OAuth consent flow. ``` Authorization: Bearer BOROUGH- ``` ## Client Setup Borough supports an OAuth-based connector flow for Claude custom connectors. 1. In Claude, go to **Settings → Connectors** 2. Click **Add custom connector** 3. Enter the URL: `https://borough.qwady.app/mcp` 4. Claude auto-discovers the OAuth flow and opens the consent page 5. Enter your Borough API key to enable paid tools, or continue without a key for the free Borough MCP tool set 6. Click **Authorize** Claude can now query NYC real estate data directly. See [Add Borough to Claude](/borough/mcp/add-to-claude) for the exact current flow, including free-plan and team/org setup. Claude Desktop supports the same remote custom connector flow as Claude web. 1. Open **Settings → Connectors** 2. Click **Add custom connector** 3. Enter `https://borough.qwady.app/mcp` 4. Complete the Borough OAuth flow If you need the full step-by-step path, use: * [Add Borough to Claude](/borough/mcp/add-to-claude) Add to `.cursor/mcp.json` in your project. This config sends a paid-plan API key: ```json theme={null} { "mcpServers": { "borough": { "url": "https://borough.qwady.app/mcp", "headers": { "Authorization": "Bearer BOROUGH-" } } } } ``` Add to `.vscode/mcp.json` in your project. This config sends a paid-plan API key: ```json theme={null} { "servers": { "borough": { "type": "http", "url": "https://borough.qwady.app/mcp", "headers": { "Authorization": "Bearer BOROUGH-" } } } } ``` Run locally via the [`@borough/mcp`](https://www.npmjs.com/package/@borough/mcp) stdio bridge: ```bash theme={null} BOROUGH_API_KEY=BOROUGH- npx @borough/mcp ``` This spawns a local stdio-to-HTTP bridge that connects to the remote MCP server. The bridge requires an API key from a paid plan; for the free tier, connect to the remote URL from an OAuth-capable client instead. ## ChatGPT (GPT Actions) ChatGPT does not support MCP natively. Instead, you can create a custom GPT with an Action that calls the Borough REST API directly using the [Borough OpenAPI spec](https://qwady.wiki/borough/openapi.yaml): 1. Go to [ChatGPT GPT Builder](https://chatgpt.com/gpts/editor) 2. Under **Actions**, click **Create new action** 3. Import the OpenAPI spec from the URL above (or paste the YAML) 4. Under **Authentication**, select **API Key** with header `Authorization` and prefix `Bearer` 5. Enter your Borough API key (from a paid plan) as the value ## Test It Once connected, try asking your AI agent: * "Search for no-fee 1-bedroom apartments in Manhattan under \$3,500" * "List all Brooklyn neighborhoods" * "What's the median rent in the East Village?" The agent will automatically select the right Borough tools and return formatted results. ## Troubleshooting If tools aren't showing up: 1. If you connected with an API key, verify your subscription is active in the [customer portal](https://polar.sh/qwady-solutions-llc/portal) 2. Check that the MCP endpoint is reachable: `curl https://borough.qwady.app/mcp` (a `401` response is expected and confirms it's up; the endpoint requires authentication) 3. Restart your AI client after configuration changes 4. See the [troubleshooting guide](/borough/mcp/troubleshooting) for more help # Tools Reference Source: https://qwady.wiki/borough/mcp/tools-reference Complete reference for all Borough MCP tools ## Search Tools ### search\_rentals Search active rental listings across all five NYC boroughs. **Tier:** Free | Parameter | Type | Description | | - | - | - | | `areas` | string or array | Area IDs as either a comma-separated string or an array (at most 10 per call) | | `location` | string | Single borough or neighborhood name such as `Chelsea` or `Astoria` (costs one extra request to resolve) | | `locations` | string or array | Multiple borough or neighborhood names (each name costs one extra request to resolve) | | `minPrice` | number | Minimum monthly rent in USD | | `maxPrice` | number | Maximum monthly rent in USD | | `minBeds` | number | Minimum bedrooms (integer 0–50; 0 = studio) | | `maxBeds` | number | Maximum bedrooms (integer 0–50) | | `minBaths` | number | Minimum full bathrooms (integer 1–4) | | `maxBaths` | number | Maximum full bathrooms (integer 1–4) | | `minSqft` | number | Minimum interior square footage | | `maxSqft` | number | Maximum interior square footage | | `petsAllowed` | boolean | Filter for pet-friendly listings | | `amenities` | string or array | Amenity enums such as `DISHWASHER`, `WASHER_DRYER`, `ELEVATOR`, `DOORMAN`, `LAUNDRY`, `GYM`, `POOL`, `PARKING` | | `buildingType` | string | Comma-separated: RENTAL, CONDO, CO\_OP, CONDOP, HOUSE, SINGLEFAMILY, TOWNHOUSE, TWOFAMILY, THREEFAMILY, FOURFAMILY, MULTI\_FAMILY, MIXED\_USE, HYBRID, COMMERCIAL, UNKNOWN | | `noFee` | boolean (`true` only) | Filter for no-fee listings only | | `sort` | string | PRICE, LISTED\_AT, or RECOMMENDED (default: RECOMMENDED, which currently sorts by listing date, newest first) | | `sortDir` | string | ASC or DESC (default: DESC, for any `sort`) | | `hasOpenHouse` | boolean (`true` only) | Filter for listings with open houses | | `has3dTour` | boolean (`true` only) | Filter for listings with 3D tours | | `maxMonthlyCost` | number | Max monthly cost: the rent for rentals; monthly carrying costs (maintenance, common charges, taxes; not the purchase price) for sales | | `minTransitScore` | number | Minimum transit score (0-100) | | `fiberOnly` | boolean | Require an FCC public-data provider match with fiber technology | | `minDownloadMbps` | number | Require an FCC public-data provider with at least this advertised download speed | | `page` | number | Page number (default 1) | | `perPage` | number | Results per page (default 3, max 5) | Notes: * Use either `areas`, `location`, or `locations`, not more than one of them in the same call. * Exact borough and neighborhood names can be passed directly with `location`, but `list_areas` is the safest path when you want to lock a precise area ID first. * If a location is ambiguous, Borough returns a short clarification list instead of silently auto-picking. * `noFee`, `hasOpenHouse`, and `has3dTour` accept `true` or nothing at all. Borough leaves those fields `null` until a listing has been detail-scraped, so it cannot tell "confirmed absent" from "not yet checked", and `false` is rejected. Omit the parameter to search every listing regardless of that field. * Broadband filters use Borough's FCC-backed provider enrichment. Coverage may be exact-building or Census-block level depending on the imported area. * `minTransitScore`, `fiberOnly`, `minDownloadMbps`, and building-level amenities match only listings Borough has linked to their building, which happens when it loads a listing's full detail. Listings without that link never match these filters, even when their building qualifies, so results undercount while detail coverage is incomplete. Response highlights: * `price` and `displayPrice` for numeric and human-friendly price display * `geoPoint` for map plotting * `leadPhotoUrl` for proxied listing images * `renderHints` for card-friendly address, price, map label, and image URL values **Borough IDs:** Manhattan=100, Bronx=200, Brooklyn=300, Queens=400, Staten Island=500 *** ### search\_sales Search active for-sale listings. Every `search_rentals` parameter applies here except `noFee`, which is a rentals-only filter. Sale search adds: **Tier:** Free | Parameter | Type | Description | | - | - | - | | `saleType` | string | RESALE, SPONSOR\_UNIT, FORECLOSURE, RESTRICTED, AUCTION, SHORT, or UNKNOWN | Notes: * `noFee` is not accepted here. A broker fee is something you pay to rent, so sale listings carry no broker-fee flag, and passing `noFee` to sale search is refused with that reason rather than silently ignored. Use `search_rentals` when you want no-fee listings. * `hasOpenHouse` and `has3dTour` take `true` or nothing here as well. Response highlights match `search_rentals`, including `price`, `displayPrice`, `geoPoint`, `leadPhotoUrl`, and `renderHints`. *** ## Property Tools ### get\_property Get listing detail for a specific ID (price, address, beds/baths, sqft, listing amenities, pet policy, and building scores), returned as a compact projection for chat clients. **Tier:** Starter (\$19/mo) | Parameter | Type | Required | Description | | - | - | - | - | | `id` | string | Yes | Listing ID | Includes: * listing-level amenities when available * building scores if the listing is linked to a scored building * `costSummary`: `noFee` plus monthly maintenance, common charges, and property tax Fee schedules and open houses are not available through MCP; use the REST API or SDK for them. **Freshness.** On Business, `get_property`, `get_property_by_url`, and `get_building` fetch stale data live before responding, and paid tiers queue a background refresh for stale data. Live fetches and refreshes draw on a shared live-fetch budget. When that budget is held for Borough's scheduled updates, or live fetching is paused, these tools return cached data instead: check `meta.source` (`cached` or `stale` rather than `live`) and `meta.dataAge`. *** ### get\_property\_by\_url Look up a listing by its URL path. **Tier:** Starter (\$19/mo) | Parameter | Type | Required | Description | | - | - | - | - | | `url` | string | Yes | URL path such as `/sale/`, `/building//`, or `/building//rental/` | Returns the same compact projection as `get_property`, with the same freshness behavior. *** ## Building Tools ### get\_building Get detailed building information. **Tier:** Starter (\$19/mo) | Parameter | Type | Required | Description | | - | - | - | - | | `id` | string | Yes | Building ID | Returns the building's name, address, type, year built, floor and unit counts, area ID, up to 8 amenities plus `amenityCount`, policies, management company, and tax abatement status. Building scores are not included; `get_property` returns them for a listing in the building. Freshness behaves as described under `get_property` above. *** ### get\_building\_listings Get the listings Borough has linked to a specific building, cheapest first. Listings are linked when Borough loads their full detail, so units seen only in search results may be missing. **Tier:** Starter (\$19/mo) | Parameter | Type | Required | Description | | - | - | - | - | | `id` | string | Yes | Building ID | | `status` | string | No | ACTIVE, IN\_CONTRACT, or OFF\_MARKET | | `listingType` | string | No | rental or sale | | `page` | number | No | Page number | | `perPage` | number | No | Results per page (default 3, max 5) | Returns the same compact, render-friendly listing summary fields as the search tools, including `price`, `displayPrice`, `geoPoint`, `leadPhotoUrl`, and `renderHints`. *** ## Area Tools ### list\_areas List Borough's areas: 316 NYC neighborhoods and sub-areas, the five boroughs, and Northern New Jersey coverage (349 rows in total). Results are paginated. **Tier:** Free | Parameter | Type | Description | | - | - | - | | `level` | number | 0=top container (NYC and NJ), 1=borough or region, 2-5=groupings, neighborhoods, and sub-neighborhoods (see the [Areas & Neighborhoods](/borough/reference/areas-and-neighborhoods) reference) | | `parentId` | number | Parent area ID (e.g. 200 for Bronx neighborhoods) | | `q` | string | Name lookup for boroughs or neighborhoods such as `Chelsea` | | `page` | number | Page number | | `perPage` | number | Results per page (default 10, max 25) | Use this when you want the safest, most explicit area-ID resolution before calling a search or market tool. *** ## Market Tools ### get\_market\_snapshot Current market statistics for a neighborhood. **Tier:** Free (authenticated) | Parameter | Type | Required | Description | | - | - | - | - | | `areaId` | number | No | Area ID | | `location` | string | No | Borough or neighborhood name | | `bedrooms` | number | No | Filter by bedroom count (0-6) | | `listingType` | string | No | rental or sale (default: rental) | *** ### get\_market\_trends Recent price trends for a neighborhood: up to 365 rows, one per date and event type, most recent first. Because each day can carry several event types, the rows typically cover the last few months rather than a full year. **Tier:** Free (authenticated) | Parameter | Type | Required | Description | | - | - | - | - | | `areaId` | number | No | Area ID | | `location` | string | No | Borough or neighborhood name | | `listingType` | string | No | rental or sale (default: rental) | *** ### compare\_neighborhoods Side-by-side comparison of 1-5 neighborhoods. **Tier:** Free (authenticated) | Parameter | Type | Required | Description | | - | - | - | - | | `areas` | string or array | No | Area IDs as a comma-separated string or an array (1-5 total) | | `locations` | string or array | No | Borough or neighborhood names (1-5 total) | | `bedrooms` | number | No | Filter by bedroom count (0-6) | | `listingType` | string | No | rental or sale (default: rental) | Use either `areas` or `locations`, not both. # Troubleshooting Source: https://qwady.wiki/borough/mcp/troubleshooting Common issues and solutions for Borough MCP ## Connection Issues ### Tools not appearing in my AI client 1. **Restart your client** after making configuration changes 2. **Check the endpoint** is reachable: ```bash theme={null} curl -X POST https://borough.qwady.app/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' ``` A `401 Unauthorized` here is expected and **confirms the endpoint is up**: `/mcp` requires authentication. Your MCP client supplies credentials automatically; this manual curl omits them. 3. **Verify your config** file syntax (JSON must be valid) 4. **Check mcp-remote** is installed: `npx mcp-remote --version` ### "Authentication failed" error * If you are using Claude or another OAuth-capable client, reconnect Borough and complete the Borough auth flow again * If you are using direct bearer auth, verify your API key starts with `BOROUGH-` * Check the key is active in the [customer portal](https://polar.sh/qwady-solutions-llc/portal) * Ensure the `Authorization` header format is exactly `Bearer BOROUGH-` Free-tier MCP sessions can still authenticate through OAuth even if you do not attach a paid Borough API key. ### "Rate limit exceeded" error Your plan's per-minute rate limit has been reached. Each MCP tool call consumes at least one API request. Searching by neighborhood or borough *name* also spends one extra request per name resolved (the server looks each name up via the areas endpoint). To avoid the extra lookups, pass numeric area IDs directly, or resolve them once with `list_areas`. | Plan | Rate Limit | | - | - | | Free | 10/min | | Starter | 30/min | | Pro | 60/min | | Business | 120/min | Read the text after "Rate limit exceeded." first. If it mentions a monthly quota, see the next section: waiting a minute will not help. Otherwise, wait 60 seconds before retrying. ### "Monthly quota ... exceeded" error The MCP server reports this as "Rate limit exceeded. Monthly quota of ... exceeded", which means your monthly allowance is used up, not your per-minute limit. | Plan | Included requests | Hard limit | Resets | | - | - | - | - | | Free | 100 | 100 | 1st of each month | | Starter | 5,000 | 15,000 | End of your billing period | | Pro | 25,000 | 50,000 | End of your billing period | | Business | 100,000 | 200,000 | End of your billing period | On paid plans, requests past the included amount keep working and are billed as overage; they are refused only at the hard limit. Free-tier MCP sessions without an API key are counted per connecting IP address, so with a hosted client several free users can share one allowance. [Upgrade your plan](https://qwady.wiki/borough/tiers-and-pricing) for more requests. ## Tool-Specific Issues ### Property or building tools are missing `get_property`, `get_property_by_url`, `get_building`, and `get_building_listings` are only exposed to Starter (\$19/mo) and higher sessions, so a free-tier session does not list them at all. Reconnect Borough with a paid API key to get them. Free-tier users can use `search_rentals`, `search_sales`, `list_areas`, `get_market_snapshot`, `get_market_trends`, and `compare_neighborhoods`. ### Empty search results * Try broader filters (remove price or bedroom constraints) * Try an exact borough or neighborhood name directly, or lock the area ID first with `list_areas` * Borough IDs: Manhattan=100, Bronx=200, Brooklyn=300, Queens=400, Staten Island=500 ### Location name resolves to multiple areas If Borough returns a short clarification list, choose one of the suggested IDs or provide a more specific location name. This usually happens with names that exist at multiple levels or in multiple contexts. Borough does not silently auto-pick in these cases because the MCP tools are optimized for certainty first. ### Photos do not render in my client * Borough search results return `leadPhotoUrl` and `renderHints.imageUrl`, but this version does not send inline MCP image blocks * Some clients render the proxied image URLs automatically; others may only use the text and map fields * The most render-friendly fields to look for are `price`, `displayPrice`, `geoPoint`, `leadPhotoUrl`, and `renderHints` ### Amenity search returns few results Amenities such as `DISHWASHER` and `WASHER_DRYER` are supported, but matches depend on listing-detail coverage. Listing amenities are read from the listing description and exist only for listings whose full detail Borough has loaded, and building amenities match only listings Borough has linked to their building. Listings without that detail cannot match an amenity filter, so results undercount. If your search is very narrow, try: * widening the budget * expanding to nearby neighborhoods * reducing the required amenity set to one must-have amenity first ## Getting Help * [Full Borough docs](https://qwady.wiki/borough/introduction) * [Get an API key](https://qwady.wiki/borough/tiers-and-pricing) * [Customer portal](https://polar.sh/qwady-solutions-llc/portal) # Quickstart Source: https://qwady.wiki/borough/quickstart Make your first API request in under 2 minutes, no API key needed ## 1. Make your first request Search, areas, market, and photo endpoints work without an API key, so you can try Borough right now. Keyless requests are limited per IP to **100 requests/month** and **10 requests/minute**, with up to 10 results per page. This finds 1+ bedroom rentals under \$4,000 on the Upper East Side: ```bash cURL theme={null} curl "https://borough.qwady.app/v1/search/rentals?areas=140&maxPrice=4000&minBeds=1" ``` ```typescript SDK theme={null} import { BoroughClient } from "@borough/sdk"; // No key needed for the free tier. const borough = new BoroughClient(); const results = await borough.rentals.search({ areas: "140", maxPrice: 4000, minBeds: 1, }); for (const listing of results.data) { console.log(`${listing.address.street} (${listing.areaName}) - $${listing.price}/mo`); } ``` ```python Python theme={null} import requests params = {"areas": "140", "maxPrice": 4000, "minBeds": 1} response = requests.get( "https://borough.qwady.app/v1/search/rentals", params=params, ) data = response.json() for listing in data["data"]: print(f"{listing['address']['street']} ({listing['areaName']}) - ${listing['price']}/mo") ``` `140` is the Upper East Side. An area that contains other areas covers all of them, so this search also returns Yorkville, Lenox Hill, and Carnegie Hill listings. Find the ID for any neighborhood with [List Areas](/borough/api-reference/areas/list-areas), for example `/v1/areas?q=williamsburg`. ## 2. Understand the response Every response follows this envelope (values below are illustrative): ```json theme={null} { "data": [ { "id": "1234567", "address": { "street": "123 East 86th Street", "unit": "5B" }, "price": 3450, "bedroomCount": 1, "fullBathroomCount": 1, "areaName": "Yorkville", "areaId": 142, "listedAt": "2026-09-20", "daysOnMarket": 3, "buildingScores": { "healthScore": 82, "noiseScore": 64, "safetyScore": 71, "transitScore": 88, "violationsOpen": 2, "updatedAt": "2026-09-20T03:10:00.000Z" } } ], "meta": { "total": 87, "page": 1, "perPage": 10, "dataAge": "2026-09-23T12:40:39.711Z", "source": "cached", "links": { "self": "https://borough.qwady.app/v1/search/rentals?areas=140&maxPrice=4000&minBeds=1&page=1&perPage=10", "first": "https://borough.qwady.app/v1/search/rentals?areas=140&maxPrice=4000&minBeds=1&page=1&perPage=10", "last": "https://borough.qwady.app/v1/search/rentals?areas=140&maxPrice=4000&minBeds=1&page=9&perPage=10", "prev": null, "next": "https://borough.qwady.app/v1/search/rentals?areas=140&maxPrice=4000&minBeds=1&page=2&perPage=10" } } } ``` Key fields: * **`data`**: The response payload (array for lists, object for single resources) * **`data[].buildingScores`**: Health, noise, safety, and transit scores plus the open violation count for the listing's building, or `null` when the building has not been scored yet. See [Enrichment](/borough/enrichment) * **`meta.dataAge`**: When the data was last refreshed (ISO 8601) * **`meta.source`**: How data was served: `cached`, `stale`, or `live`. Search is always `cached` or `stale`. `live` appears only on Business live-first detail and is not guaranteed: while live fetching is held back or paused, detail responses return `cached` or `stale` instead * **`meta.links`**: Pagination navigation URLs * **`meta.warnings`**: Present when a filter relies on partially populated data (for example `noFee`, `minSqft`, `amenities`, or `fiberOnly`) and may shrink results. Check it when a search returns fewer results than expected Responses also carry `X-Quota-Limit`, `X-Quota-Used`, and `X-Quota-Remaining` headers so you can track your monthly allowance. `X-Quota-Used` is the count before the current request. If a listing includes a `leadPhotoKey`, you can fetch a first-party image URL through Borough at `/v1/photos/{key}`. Each photo request counts as one request against your quota, so cache images you have already fetched. See [Photo URLs](/borough/reference/photo-urls) for supported sizes. ## 3. Get an API key when you need more Paid plans add property and building detail (price history, fees, open houses, comparables, and HPD violations), higher monthly limits, and larger pages. Pro adds live listing streams (the live update is best-effort); Business adds live-first detail, webhooks, and watchers. See [Tiers & Pricing](/borough/tiers-and-pricing). Keys are provisioned automatically when you subscribe and use the format `BOROUGH-`. Send yours as a Bearer token: ```bash cURL theme={null} curl -H "Authorization: Bearer $BOROUGH_API_KEY" \ "https://borough.qwady.app/v1/search/rentals?areas=140&maxPrice=4000&minBeds=1" ``` ```typescript SDK theme={null} // With no argument the client reads BOROUGH_API_KEY from the environment. const borough = new BoroughClient(process.env.BOROUGH_API_KEY); ``` ```python Python theme={null} import os headers = {"Authorization": f"Bearer {os.environ['BOROUGH_API_KEY']}"} ``` ## Next steps * [Search Filters](/borough/guides/filtering): All available filter parameters * [SDK Guide](/borough/sdk): Install and use the TypeScript SDK * [MCP Server](/borough/mcp/overview): Connect Claude or another AI agent (the free tier works there too) * [Authentication](/borough/authentication): API key formats and OAuth # Amenities Source: https://qwady.wiki/borough/reference/amenities All amenity filter values and building-level amenity data ## Search filter amenities Pass amenity values to the `amenities` parameter as a comma-separated string. Listings are filtered to include only those matching **all** specified amenities. ```bash theme={null} curl "https://borough.qwady.app/v1/search/rentals?amenities=DOORMAN,ELEVATOR,GYM&areas=100" ``` ```typescript theme={null} const results = await borough.rentals.search({ amenities: "DOORMAN,ELEVATOR,GYM", areas: "100", }); ``` Amenity matches depend on listing-detail coverage, so results undercount. A listing matches an amenity when the listing has it or its building does: * Listing-level amenities are read from the listing's description text, and only for listings whose full detail Borough has loaded. An amenity the description never mentions reads as absent. * Building-level amenities match only listings Borough has linked to their building, which also happens when it loads the listing's full detail. Listings without full detail cannot match any amenity filter. When you filter by amenities, the response's `meta.warnings` carries a coverage note. ## Available amenity values ### In-unit features | Value | Description | | - | - | | `WASHER_DRYER` | In-unit washer/dryer | | `DISHWASHER` | In-unit dishwasher | | `CENTRAL_AC` | Central air conditioning | | `FURNISHED` | Fully furnished unit | | `FIREPLACE` | Working or decorative fireplace | | `LOFT` | Loft-style layout with high ceilings | | `PRIVATE_OUTDOOR_SPACE` | Private balcony, terrace, or patio | ### Building amenities | Value | Description | | - | - | | `ELEVATOR` | Building has elevator service | | `DOORMAN` | Doorman or concierge service | | `LAUNDRY` | Shared laundry room in building | | `GYM` | Fitness center in building | | `POOL` | Swimming pool | | `PARKING` | Parking available (garage or lot) | | `SHARED_OUTDOOR_SPACE` | Shared roof deck, courtyard, or garden | | `CHILDRENS_PLAYROOM` | Children's playroom in building | | `STORAGE_SPACE` | Storage units available | ### Policies | Value | Description | | - | - | | `PIED_A_TERRE_ALLOWED` | Building allows pied-a-terre (non-primary residence) | | `SMOKE_FREE` | No-smoking building | | `GUARANTORS_ACCEPTED` | Building accepts guarantors for lease applications | ## Not currently supported as search filters The following categories are intentionally out of scope for the current API filter surface and should not be treated as supported search values: * view-style enums such as `CITY_VIEW`, `WATER_VIEW`, or `PARK_VIEW` * transit-line matching * prewar-only filtering ## Building-level amenity details The building detail endpoint (`GET /v1/building/{id}`) returns richer amenity data in the `amenities` object: ```json theme={null} { "amenities": { "list": ["ELEVATOR", "DOORMAN", "GYM", "LAUNDRY", "PARKING"], "doormanTypes": ["FULL_TIME"], "parkingTypes": ["GARAGE"], "storageSpaceTypes": ["BASEMENT"], "sharedOutdoorSpaceTypes": ["ROOF_DECK", "COURTYARD"] } } ``` | Field | Type | Description | | - | - | - | | `list` | string\[] | Array of amenity enum values present in the building | | `doormanTypes` | string\[] | Doorman service types (e.g., `FULL_TIME`, `PART_TIME`, `VIRTUAL`) | | `parkingTypes` | string\[] | Parking types (e.g., `GARAGE`, `LOT`, `VALET`) | | `storageSpaceTypes` | string\[] | Storage options (e.g., `BASEMENT`, `UNIT`, `BIKE_ROOM`) | | `sharedOutdoorSpaceTypes` | string\[] | Outdoor space types (e.g., `ROOF_DECK`, `COURTYARD`, `GARDEN`) | # Areas & Neighborhoods Source: https://qwady.wiki/borough/reference/areas-and-neighborhoods Area IDs, hierarchy levels, and borough mapping across NYC and Northern New Jersey ## Dataset composition `GET /v1/areas` returns **349 areas** in total. They are not all NYC neighborhoods: | Group | Rows | | - | - | | NYC neighborhoods and sub-areas | 316 | | NYC boroughs | 5 | | Northern New Jersey (region, municipalities, and sub-areas) | 26 | | Administrative (`NYC and NJ` (1), `Unassigned` (9999999)) | 2 | | **Total** | **349** | Match the count to the scope you mean: **349 areas across NYC and Northern New Jersey** for the whole dataset, or **316 NYC neighborhoods and sub-areas** for NYC places only. ## Hierarchy Borough organizes its geography into a nested tree. Every area in `GET /v1/areas` has an `id`, `name`, `level`, `parentId`, and `slug`. Levels run from 0 to 5, and the depth varies by borough. Manhattan nests an extra sub-borough grouping that the outer boroughs don't. | Level | Description | Example | | - | - | - | | 0 | Top region container | NYC and NJ (1) | | 1 | Borough / region | Manhattan (100) | | 2 | Sub-borough grouping (Manhattan) or neighborhood (outer boroughs) | All Downtown (102), Williamsburg (302) | | 3 | Neighborhood (Manhattan) | Upper East Side (140) | | 4–5 | Sub-neighborhood | Hell's Kitchen (152) | In Manhattan, neighborhoods sit at **level 3** under a level-2 grouping (e.g. `Upper East Side (140)` → `All Upper East Side (139)` → `Manhattan (100)`). In Brooklyn, Queens, the Bronx, and Staten Island, neighborhoods sit directly at **level 2** under the borough (e.g. `Williamsburg (302)` → `Brooklyn (300)`). ## Borough IDs | Borough | Area ID | | - | - | | Manhattan | 100 | | Bronx | 200 | | Brooklyn | 300 | | Queens | 400 | | Staten Island | 500 | There is no single "all NYC" area ID. To search the whole city, pass the five borough IDs together (`areas=100,200,300,400,500`). The level-0 container `NYC and NJ (1)` also includes New Jersey. Area ID **`1000000` is New Jersey**, not all of NYC. ## Using area filters Pass area IDs to the `areas` parameter on the search endpoints: ```bash theme={null} # Search Upper East Side curl ".../v1/search/rentals?areas=140" # Search multiple neighborhoods curl ".../v1/search/rentals?areas=140,137,120" # Search an entire borough curl ".../v1/search/rentals?areas=100" ``` Rules for `areas` and `excludeAreas`: * A container ID (a borough, or a grouping such as `All Downtown (102)`) in `areas` matches every area beneath it. * `excludeAreas` matches exact area IDs only; excluding a container does not exclude the areas beneath it. * Each parameter accepts at most 100 distinct IDs. * An unknown ID in `areas` returns `422 INVALID_PARAMS`. Search [watchers](/borough/api-reference/watchers/create-watcher) are limited to filters that match at most 500 active listings, so a whole borough or the five-borough list is usually refused with `400 INVALID_PARAMS`. For a watcher, use specific neighborhoods and narrow with price, `beds`, `listingType`, or `buildingType`. ## Listing areas `GET /v1/areas` accepts `level`, `parentId`, `q`, `include`, `page`, and `perPage` (no `borough` parameter): ```bash theme={null} # First page of areas — returns 10 rows by default, not all 349 curl ".../v1/areas" # Page through the full set (perPage is capped by your tier) curl ".../v1/areas?perPage=500&page=1" # Only level-2 areas curl ".../v1/areas?level=2" # Direct children of a borough (e.g. Brooklyn's neighborhoods) curl ".../v1/areas?parentId=300" # Search areas by name curl ".../v1/areas?q=williamsburg" ``` ## Popular Manhattan neighborhoods | Name | ID | Parent | | - | - | - | | Upper East Side | 140 | All Upper East Side (139) | | Upper West Side | 137 | All Upper West Side (135) | | Midtown | 120 | All Midtown (119) | | Chelsea | 115 | All Downtown (102) | | Greenwich Village | 116 | All Downtown (102) | | East Village | 117 | All Downtown (102) | | SoHo | 107 | All Downtown (102) | | Tribeca | 105 | All Downtown (102) | | Financial District | 104 | All Downtown (102) | ## Popular Brooklyn neighborhoods | Name | ID | Parent | | - | - | - | | Williamsburg | 302 | Brooklyn (300) | | Park Slope | 319 | Brooklyn (300) | | DUMBO | 307 | Brooklyn (300) | | Brooklyn Heights | 305 | Brooklyn (300) | | Bushwick | 313 | Brooklyn (300) | | Greenpoint | 301 | Brooklyn (300) | | Bedford-Stuyvesant | 310 | Brooklyn (300) | | Crown Heights | 325 | Brooklyn (300) | Use `GET /v1/areas` to retrieve all 349 areas with their IDs, levels, and parent relationships. The endpoint is paginated: the default `perPage` is 10, so page through the results (or raise `perPage` within your tier's limit) to collect the full set. See [Dataset composition](#dataset-composition) for what the 349 rows contain. # Building & Listing Types Source: https://qwady.wiki/borough/reference/building-types Enums for building types, listing statuses, and sale types ## BuildingType Used in search filters (`buildingType` parameter) and returned in listing/building responses. | Value | Description | | - | - | | `CONDO` | Condominium: individually owned units in a shared building | | `CO_OP` | Co-operative: residents own shares in a corporation that owns the building | | `CONDOP` | Condop: a co-op that operates under condo-like rules (a real NYC ownership hybrid) | | `RENTAL` | Rental building: units available for lease only | | `TOWNHOUSE` | Townhouse: multi-story attached or semi-attached home | | `HOUSE` | Single-family house | | `SINGLEFAMILY` | Single-family dwelling | | `TWOFAMILY` | Two-family house | | `THREEFAMILY` | Three-family house | | `FOURFAMILY` | Four-family dwelling | | `MULTI_FAMILY` | Multi-family residential building | | `MIXED_USE` | Mixed-use building (residential + commercial) | | `HYBRID` | Mixed ownership/structure type reported by the source | | `COMMERCIAL` | Commercial building | | `UNKNOWN` | The source reported an unknown type, or a type Borough does not recognize | `buildingType` is `null` when the source did not report one. `MULTIFAMILY` (no underscore) is accepted as an input alias for `MULTI_FAMILY` on the `buildingType` and `excludeBuildingType` filters. Responses always return the canonical spelling `MULTI_FAMILY`. ### Usage in search Pass one or more values as a comma-separated string: ```bash theme={null} # Search for condos and co-ops curl "https://borough.qwady.app/v1/search/rentals?buildingType=CONDO,CO_OP" # Search only townhouses curl "https://borough.qwady.app/v1/search/sales?buildingType=TOWNHOUSE" ``` ```typescript theme={null} const results = await borough.rentals.search({ buildingType: "CONDO,CO_OP", }); ``` ## ListingStatus Returned in the `status` field of listing responses. | Value | Description | | - | - | | `ACTIVE` | Currently available on the market | | `IN_CONTRACT` | Under contract / offer accepted (*live data only; see note*) | | `OFF_MARKET` | No longer available | Stored listings and search results carry **`ACTIVE`** or **`OFF_MARKET`** only, so listings transition directly from `ACTIVE` to `OFF_MARKET` when they leave the market. `IN_CONTRACT` can appear only in live data: Business live-first responses from `GET /v1/property/{id}` and `/v1/property/by-url`, `live` events on the listing stream, and listing-watcher change events (`newValue`), when the live listing reports it. Match on it if present, but do not rely on it for in-contract detection. Search endpoints return only `ACTIVE` listings; there is no status filter on search. Cached property detail (`/v1/property/{id}`) returns `ACTIVE` or `OFF_MARKET` listings. Building listings accept a `status` filter (for example `/v1/building/{id}/listings?status=OFF_MARKET`); `status=IN_CONTRACT` returns no listings, because that status is never stored. ## SaleType Used as a filter on `/v1/search/sales` only (not applicable to rentals). | Value | Description | | - | - | | `RESALE` | Standard resale: existing owner selling | | `SPONSOR_UNIT` | New development or sponsor sale: first sale from developer | | `FORECLOSURE` | Foreclosure: bank-owned or distressed sale | | `RESTRICTED` | Income- or age-restricted sale (HDFC-style) | | `AUCTION` | Sold at auction | | `SHORT` | Short sale: lender-approved sale below the outstanding mortgage | | `UNKNOWN` | The source reported an unknown sale type, or one Borough does not recognize | `saleType` is `null` when the source did not report one. ### Usage in search ```bash theme={null} # Search for new development condos in Manhattan curl "https://borough.qwady.app/v1/search/sales?saleType=SPONSOR_UNIT&buildingType=CONDO&areas=100" ``` ```typescript theme={null} const results = await borough.sales.search({ saleType: "SPONSOR_UNIT", buildingType: "CONDO", areas: "100", }); ``` # Photo URLs Source: https://qwady.wiki/borough/reference/photo-urls Constructing image URLs from photo keys ## Overview Listing responses include **photo keys**: 32-character hex hashes that identify images. Borough also exposes a public photo proxy at `/v1/photos/{key}` so clients can fetch supported image sizes through the Borough domain when they want a stable first-party URL. ## Borough photo proxy ``` GET https://borough.qwady.app/v1/photos/{key} GET https://borough.qwady.app/v1/photos/{key}?size=medium_500_250 ``` Supported sizes: * `large_800_400` (default) * `medium_500_250` The photo proxy returns the image bytes with long-lived cache headers. Invalid keys and unsupported `size` values return `400`; missing images return `404`; other error responses from the image host return `502 UPSTREAM_ERROR`. Each photo request counts as one request against your monthly quota, so cache images on your side. ## Available sizes | Size | Dimensions | Use case | | - | - | - | | `medium_500_250` | 500x250 | Cards | | `large_800_400` | 800x400 | Detail pages | ## Example Given a photo key `ed2987594843debc8e828fef155dbb6d`: ``` https://borough.qwady.app/v1/photos/ed2987594843debc8e828fef155dbb6d ``` ## Where keys appear * **`ListingSummary.leadPhotoKey`**: Primary listing photo (search results and building listings) * **`ListingDetail.leadPhotoKey`**: Primary listing photo * **`ListingDetail.photos`**: Up to 6 listing photo keys * **`ListingDetail.floorPlanKey`**: Floor plan image (if available) Building detail (`GET /v1/building/{id}`) does not include photo keys. # Transit Lines Source: https://qwady.wiki/borough/reference/transit-lines Transit line filtering is not currently available `transitLines` is not a current Borough search parameter. This page is kept so older references resolve cleanly, but line-by-line transit filtering is not part of the live contract today. ## What to use instead * `minTransitScore` for building-level transit accessibility filtering. It only matches listings Borough has linked to their building, which happens when it loads a listing's full detail, so listings without that link are left out even when their building qualifies. * `areas` for neighborhood and borough scoping * building score detail endpoints when you need the current transit score itself ## Current status These requests are common, but they are not current Borough filters: * `transitLines` * `prewar` * view-style amenity enums such as `CITY_VIEW` or `WATER_VIEW` Search does not reject `transitLines` or `prewar`: it ignores them and returns results as if they were not passed. The SDK drops them before sending the request. ## Related: transit score Instead of line filtering, you can filter by transit accessibility score: ```bash theme={null} # Listings with excellent transit access curl "https://borough.qwady.app/v1/search/rentals?minTransitScore=80" ``` Transit scores (0-100) are available on building detail responses via `GET /v1/building/{id}/scores`. The score is computed from public GTFS stop data (MTA subway, bus, LIRR, and Metro-North, plus NYC Ferry and the Staten Island Ferry). Points are added per mode, and the total is capped at 100: | Component | Max points | Radius | | - | - | - | | Subway proximity (distance to the nearest stop) | 40 | 800 m | | Subway density (stops within the radius; full points at 15 or more) | 25 | 800 m | | LIRR / Metro-North proximity | 15 | 1,000 m | | Ferry proximity | 10 | 800 m | | Bus proximity | 12 | 400 m | | Bus density (stops within the radius; full points at 7 or more) | 8 | 400 m | The components add up to 110 before the cap, so a building does not need every mode to reach 100. See [Building Scores](/borough/enrichment) for details on how scores are computed. # SDK Quickstart Source: https://qwady.wiki/borough/sdk/index Install and use the Borough TypeScript/JavaScript SDK ## Installation ```bash npm theme={null} npm install @borough/sdk ``` ```bash pnpm theme={null} pnpm add @borough/sdk ``` ```bash yarn theme={null} yarn add @borough/sdk ``` The SDK requires Node.js 22 or later. ## Quick start ```typescript theme={null} import { BoroughClient } from "@borough/sdk"; // No key needed for free-tier search, areas, and market data. // With no argument the client also picks up BOROUGH_API_KEY if it is set. const borough = new BoroughClient(); // Search rentals in Midtown const results = await borough.rentals.search({ areas: "120", minBeds: 1, maxPrice: 4000, noFee: true, }); console.log(`Found ${results.meta.total} listings`); for (const listing of results.data) { console.log( `${listing.address.street}${listing.address.unit ? ` ${listing.address.unit}` : ""} — $${listing.price}/mo`, ); } ``` ## Configuration The client accepts nothing (free tier, or `BOROUGH_API_KEY` from the environment), a string (API key), or a config object: ```typescript theme={null} // Free tier: no key const borough = new BoroughClient(); // Simple — just the API key const borough = new BoroughClient("BOROUGH-..."); // Full config const borough = new BoroughClient({ apiKey: "BOROUGH-...", baseURL: "https://borough.qwady.app/v1", // default timeout: 30_000, // 30s default maxRetries: 2, // retries rate limits, 5xx responses and network failures }); // Environment variable fallback // Set BOROUGH_API_KEY in your environment, then: const borough = new BoroughClient(); ``` ## Resources The client exposes resource modules following a consistent pattern: ```typescript theme={null} // Search const rentals = await borough.rentals.search({ areas: "120", minBeds: 2 }); const sales = await borough.sales.search({ areas: "200", maxPrice: 1_000_000 }); // Property details const listing = await borough.listings.get("4961849"); const byUrl = await borough.listings.getByUrl("/building//"); const history = await borough.listings.history("4961849"); const fees = await borough.listings.fees("4961849"); const openHouses = await borough.listings.openHouses("4961849"); // Building data const building = await borough.buildings.get("12345"); const scores = await borough.buildings.scores("12345"); const violations = await borough.buildings.violations("12345"); const buildingListings = await borough.buildings.listings("12345"); // Areas const allAreas = await borough.areas.list(); const neighborhoods = await borough.areas.list({ level: 2, parentId: 100 }); // Market data const snapshot = await borough.market.snapshot({ areaId: 120, bedrooms: 1 }); const trends = await borough.market.trends({ areaId: 120 }); const comparison = await borough.market.compare({ areas: "120,200,300" }); const marketHistory = await borough.market.history({ areaId: 120, days: 90 }); ``` The client also exposes `listings.stream()`, `watchers`, and `webhooks`. See [Streams and watchers](#streams-and-watchers) below and [Webhook verification](/borough/sdk/webhooks). Detail methods (`listings.get`, `listings.getByUrl`, `buildings.get`) report how the data was served in `meta.source`: `cached`, `stale`, or `live`. On Business, stale detail is fetched live before responding, and paid tiers queue a background refresh for stale data. Live fetches and refreshes draw on a shared live-fetch budget. When that budget is held for Borough's scheduled updates, or live fetching is paused, these methods return cached data (`source: "cached"` or `"stale"`) instead of `live`. ## Streams and watchers ### Listing stream (Pro+) `listings.stream(id)` opens a server-sent event stream: a `cached` event with the stored record, then a `live` event with freshly fetched data, then `done`. The live fetch uses the same shared live-fetch budget. When that budget is held for scheduled updates or live fetching is paused, the stream sends an `error` event with code `budget_exceeded` or `service_disabled` in place of `live`, and the `cached` event stands. ```typescript theme={null} for await (const event of await borough.listings.stream("4961849")) { if (event.event === "cached") console.log("cached price", event.data.price); if (event.event === "live") console.log("live data", event.data); if (event.event === "error") console.log("no live data:", event.data.code); } ``` ### Watchers (Business) ```typescript theme={null} const { data: watcher } = await borough.watchers.create({ watchType: "search", searchFilters: { areas: "302", maxPrice: 4000, beds: 1 }, }); await borough.watchers.update(watcher.id, { active: false }); // pause const all = await borough.watchers.list(); ``` Watcher limits: | Limit | Building and search watchers | Listing watchers | | - | - | - | | Active watchers | Up to 50 active watchers in total, across all types | At most 5 of the 50 | | `pollInterval` | Minimum 300 seconds, default 900 | Minimum and default 3600 seconds | * Creating a watcher past either cap throws `QuotaExceededError` (`429 QUOTA_EXCEEDED`); the message names the cap you hit. Resuming one with `update(id, { active: true })` past a cap throws `ValidationError` (400). * A `pollInterval` below the minimum throws `ValidationError`. * A search watcher whose filters match more than 500 active listings throws `ValidationError`, both on `create` and when you resume it. On `create`, narrow it with `areas`, `minPrice`/`maxPrice`, `beds`, `listingType`, or `buildingType`; `update` cannot change filters, so to fix a resume, delete the watcher and create a narrower one. A running search watcher whose matches grow past 500 is paused. * Resuming a listing watcher whose `pollInterval` is below 3600 raises it to 3600. * Each poll counts as one request against your quota. ### Watcher streams `watchers.stream(id)` yields `connected`, `change`, `heartbeat`, `reconnect`, and `stopped` events. The server ends each stream about every 5 minutes, and the SDK reopens it by default with no limit on reconnects, resuming from the last event received. From SDK 0.8.0, a stream that closes without `reconnect` or `stopped` counts as a failed connection: it is retried with backoff, and after `maxConsecutiveFailures` (default 10) failed connections in a row the loop throws a `ConnectionError`. Every connection counts as one request against your quota, so a stream kept open around the clock costs about 12 requests an hour (about 8,640 per 30 days) on top of the polls. Pass `maxReconnects` to bound it, or abort `signal` to stop. ```typescript theme={null} const controller = new AbortController(); const stream = await borough.watchers.stream(watcher.id, { maxReconnects: 12, // about one hour signal: controller.signal, }); for await (const event of stream) { if (event.event === "change") console.log(event.data.eventType, event.data.listingId); } ``` ## Error handling The SDK throws typed errors that you can catch and handle: ```typescript theme={null} import { BoroughClient, NotFoundError, RateLimitError, QuotaExceededError, } from "@borough/sdk"; const borough = new BoroughClient("BOROUGH-..."); try { const listing = await borough.listings.get("nonexistent"); } catch (error) { if (error instanceof NotFoundError) { console.log("Listing not found"); } else if (error instanceof RateLimitError) { console.log(`Rate limited. Retry after ${error.retryAfter}s`); } else if (error instanceof QuotaExceededError) { console.log("Monthly quota exceeded"); } else { throw error; } } ``` Every error class extends `BoroughError`. The API error classes extend it through `APIError`, which carries `code` and `status`; `ConnectionError` and `PaginationLimitError` extend `BoroughError` directly: | Error Class | Error Code | When | | - | - | - | | `AuthenticationError` | `MISSING_API_KEY`, `INVALID_API_KEY`, `EXPIRED_API_KEY` | Bad or missing API key | | `RateLimitError` | `RATE_LIMIT_EXCEEDED` | Too many requests per minute | | `QuotaExceededError` | `QUOTA_EXCEEDED` | Monthly request quota exceeded, or an active-watcher cap reached | | `NotFoundError` | `NOT_FOUND` | Resource doesn't exist | | `ValidationError` | `INVALID_PARAMS` | Bad request parameters, including unknown area IDs | | `TierRestrictedError` | `TIER_RESTRICTED` | Endpoint requires higher tier | | `UpstreamError` | `UPSTREAM_ERROR` | Borough couldn't verify your subscription (API `503`; retry shortly) or a data source failed (API `502`) | | `ServiceDisabledError` | `SERVICE_DISABLED` | The API is temporarily disabled (503) | | `ConnectionError` | (none) | Network failure, or an HTTP error without a Borough error body (then `status` is set) | | `PaginationLimitError` | (none) | Auto-pagination reached `maxAutoPages`; see [Auto-pagination](/borough/sdk/pagination) | ## Retries The SDK automatically retries rate-limit errors (`RateLimitError`), `5xx` responses, and network failures with exponential backoff. It does not retry `QuotaExceededError`, because waiting does not restore quota. Default: 2 retries, starting at 500ms. For a rate-limit error, the wait is at least the `retryAfter` value in the error body. ```typescript theme={null} // Disable retries const borough = new BoroughClient({ apiKey: "...", maxRetries: 0 }); ``` ## TypeScript The SDK is fully typed. All request parameters and response types are exported: ```typescript theme={null} import type { ListingSummary, ListingDetail, BuildingDetail, BuildingScores, SearchParams, SaleSearchParams, } from "@borough/sdk"; ``` ## Next steps * [Auto-pagination](/borough/sdk/pagination): Iterate through all pages automatically * [Webhook verification](/borough/sdk/webhooks): Verify webhook signatures in Express or Next.js # Auto-Pagination Source: https://qwady.wiki/borough/sdk/pagination Iterate through all result pages automatically ## Overview Search endpoints return paginated results. The SDK provides `PaginatedResult` which implements `AsyncIterable`, letting you iterate through all items across all pages without manual page management. ## Basic usage ```typescript theme={null} const results = await borough.rentals.search({ areas: "120", minBeds: 1, maxPrice: 4000, }); // Iterate through every page (up to maxAutoPages, default 100) for await (const listing of results) { console.log(`${listing.address.street} — $${listing.price}`); } ``` The iterator automatically fetches the next page when the current page is exhausted. Each page it fetches is one API request and counts against your monthly quota, so keyless Free-tier usage (100 requests a month, counted per IP) can spend its whole allowance on one large iteration. ## Collecting results Use `toArray()` to collect all items into an array: ```typescript theme={null} // Get all results const allListings = await results.toArray(); // Get first 100 results (stops fetching after enough items) const first100 = await results.toArray({ limit: 100 }); ``` ## Page cap Auto-pagination stops after `maxAutoPages` pages (default 100). If pages remain at that point, iteration and `toArray()` throw `PaginationLimitError` rather than return a partial result. At the API's default `perPage` of 10, that cap is 1,000 items. Set the cap with the second argument to `search()`: ```typescript theme={null} // No page cap const all = await borough.rentals.search({ areas: "120" }, { maxAutoPages: Infinity }); // Stop quietly at the cap and check whether anything was left unread const bounded = await borough.rentals.search( { areas: "120" }, { maxAutoPages: 20, onMaxAutoPages: "stop" }, ); const items = await bounded.toArray(); if (bounded.truncated) console.log("stopped at 20 pages"); ``` `PaginationLimitError` carries `pagesFetched`, `maxAutoPages`, `itemsFetched`, and `total`. A `toArray({ limit })` that is reached within the cap does not throw. ## Accessing page metadata The initial response includes pagination metadata: ```typescript theme={null} const results = await borough.rentals.search({ areas: "120" }); console.log(results.meta.total); // Total matching listings console.log(results.meta.page); // Current page (1-indexed) console.log(results.meta.perPage); // Items per page console.log(results.data.length); // Items on this page ``` ## Controlling page size ```typescript theme={null} // Smaller pages (fewer items per request) const results = await borough.rentals.search({ areas: "120", perPage: 10, }); // Larger pages (Starter: max 50, Pro/Business: max 500) const results = await borough.rentals.search({ areas: "120", perPage: 500, }); ``` A `perPage` above your tier's maximum is lowered to that maximum, not rejected. Read `results.meta.perPage` to see the size you actually got. ## Example: export all Brooklyn rentals ```typescript theme={null} import { BoroughClient } from "@borough/sdk"; const borough = new BoroughClient("BOROUGH-..."); // perPage 500 needs Pro or Business; Starter is lowered to 50 per page const results = await borough.rentals.search( { areas: "300", // Brooklyn perPage: 500, }, { maxAutoPages: Infinity }, // one request per page, all counted against quota ); const listings = await results.toArray(); console.log(`Exported ${listings.length} Brooklyn rentals`); // Write to CSV, database, etc. for (const listing of listings) { // process each listing... } ``` ## Rate limit awareness Auto-pagination respects your tier's rate limits. If you hit a rate limit during pagination, the SDK automatically retries with backoff. Each page is one request against your monthly quota, and running out of quota throws `QuotaExceededError`, which is not retried. For large exports, use a larger `perPage` value to reduce the number of requests. | Tier | Max perPage | Rate Limit | | - | - | - | | Free | 10 | 10/min | | Starter | 50 | 30/min | | Pro | 500 | 60/min | | Business | 500 | 120/min | # Webhook Verification Source: https://qwady.wiki/borough/sdk/webhooks Verify Borough webhook signatures in Express and Next.js Webhooks require a **Business** tier subscription. ## Overview Borough uses the [Standard Webhooks](https://www.standardwebhooks.com/) specification for signing webhook payloads. The SDK provides middleware helpers for Express and Next.js to verify signatures and parse events. ## Installation The webhook helpers require the `standardwebhooks` peer dependency: ```bash theme={null} npm install @borough/sdk standardwebhooks ``` ## Express The SDK types `event.type` as `string` and `event.data` as `unknown`, so the examples cast `data` to the [payload shape](#event-payload). ```typescript theme={null} import express from "express"; import { webhookMiddleware } from "@borough/sdk/webhooks/express"; type BoroughEventData = { listingId: string; oldValue: string | null; newValue: string; }; const app = express(); app.post( "/webhooks/borough", // IMPORTANT: Use raw body parser — signature is verified against the raw body express.raw({ type: "application/json" }), webhookMiddleware(process.env.BOROUGH_WEBHOOK_SECRET!, async (event) => { const data = event.data as BoroughEventData; switch (event.type) { case "listing.price_decreased": console.log(`Listing ${data.listingId} price dropped to ${data.newValue}`); break; case "listing.created": console.log(`New listing: ${data.listingId}`); break; case "listing.status_changed": // Currently a relisting: OFF_MARKET → ACTIVE console.log(`Listing ${data.listingId}: ${data.oldValue} → ${data.newValue}`); break; } }) ); app.listen(3000); ``` ## Next.js (App Router) ```typescript theme={null} // app/api/webhooks/borough/route.ts import { webhookHandler } from "@borough/sdk/webhooks/nextjs"; export const POST = webhookHandler( process.env.BOROUGH_WEBHOOK_SECRET!, async (event) => { const data = event.data as { listingId: string; oldValue: string | null; newValue: string }; switch (event.type) { case "listing.price_decreased": // Send alert, update database, etc. await notifyUser(data.listingId, data.newValue); break; case "listing.expired": await removeFromWatchlist(data.listingId); break; } } ); ``` In both helpers, an error thrown by your handler produces a `400` response, which Borough treats as a failed delivery and retries. Make handlers idempotent. ## Event types | Event | Triggered when | | - | - | | `listing.created` | A new listing appeared in the search index | | `listing.price_decreased` | Listing price dropped by more than \$25 | | `listing.price_increased` | Listing price increased by more than \$25 | | `listing.status_changed` | Listing status changed to anything other than off-market; today that means a relisting (OFF\_MARKET back to ACTIVE) | | `listing.expired` | Listing went off-market | A listing going off-market always sends `listing.expired`, never `listing.status_changed`. ## Event payload All events share the same shape: ```typescript theme={null} interface BoroughWebhookEvent { type: string; // one of the event types above data: { listingId: string; oldValue: string | null; newValue: string; }; timestamp: string; // UTC, "YYYY-MM-DD HH:MM:SS" with no zone suffix } ``` | Event | `oldValue` | `newValue` | | - | - | - | | `listing.created` | `null` | Price in dollars, as a string | | `listing.price_decreased`, `listing.price_increased` | Previous price, as a string | New price, as a string | | `listing.status_changed`, `listing.expired` | Previous status | New status | `timestamp` is when Borough detected the change, in UTC, but it has no `Z` or offset. Parse it as UTC: `new Date(timestamp.replace(" ", "T") + "Z")`. Passing it to `new Date()` unchanged reads it as local time. ## Delivery timing Events arrive after Borough detects the change, not when it happened: * Borough checks for new changes to deliver every 5 minutes and handles up to 100 changes per run across all subscribers, so a large batch (for example, many listings expiring at once) can take hours to drain. * New, expired, and relisted listings are detected as Borough's regular listing updates run: rentals about every 6 hours, sales about every 8 hours. * Price changes on listings whose full detail Borough already holds are detected only when that detail is refreshed: in Borough's periodic re-check, or when a paid customer's detail request triggers a refresh. Those events can arrive days or weeks after the change. On-demand refreshes draw on a shared live-fetch budget, so while that budget is held for Borough's scheduled updates, or live fetching is paused, these price events wait for the periodic re-check. ## Manual verification If you're not using Express or Next.js, verify the signature manually: ```typescript theme={null} import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.BOROUGH_WEBHOOK_SECRET!); // headers: webhook-id, webhook-timestamp, webhook-signature // body: raw request body as string try { const event = wh.verify(body, { "webhook-id": headers["webhook-id"], "webhook-timestamp": headers["webhook-timestamp"], "webhook-signature": headers["webhook-signature"], }); // event is verified and safe to process } catch (err) { // Signature verification failed — reject the request return new Response("Invalid signature", { status: 401 }); } ``` ## Webhook secret Your webhook secret is returned only when you [create a subscription](/borough/api-reference/webhooks/create-webhook). `GET /v1/webhooks` (`borough.webhooks.list()`) does not return it, so store it at creation time. It starts with `whsec_` and should be stored securely (e.g., environment variable). Each subscription has its own unique secret. ## Retry behavior Failed deliveries (non-2xx responses) are retried up to **7 times** with increasing delays. Ensure your handler returns a 2xx status within 10 seconds to acknowledge receipt. After all retries are exhausted, the delivery is moved to a dead letter queue and cannot be replayed through the API. Delivery is at-least-once, so the same event can arrive more than once. Deduplicate on the `webhook-id` header, which stays the same for every attempt at delivering one event to one subscription. If your plan drops below Business, Borough deactivates your subscriptions. Upgrading again does not turn them back on: re-enable each one with a `PATCH` setting `active: true`. # Pricing Source: https://qwady.wiki/borough/tiers-and-pricing Choose a plan and get your API key Borough offers a free tier for exploration and three paid tiers for production use. All paid plans include a license key delivered after checkout. ## Free tier Search endpoints (`/v1/search/rentals`, `/v1/search/sales`), areas (`/v1/areas`), market routes, and the photo proxy (`/v1/photos/{key}`) work without an API key and use the Free tier limits of **10 requests/minute** and **100 requests/month** per IP. The public `/v1/health` endpoint is also available without authentication, but it is outside Borough's metered quota and rate-limit middleware. ## Paid plans | | Starter | Pro | Business | | - | - | - | - | | **Price** | \$19/mo | \$49/mo | \$99/mo | | **Requests/month** | 5,000 | 25,000 | 100,000 | | **Rate limit** | 30/min | 60/min | 120/min | | **Max perPage** | 50 | 500 | 500 | | **Search endpoints** | Yes | Yes | Yes | | **Property details** | Yes | Yes | Yes | | **Building data** | Yes | Yes | Yes | | **Market analytics** | Yes | Yes | Yes | | **Live SSE streaming** | No | Yes | Yes | | **Live-first property/building** | No | No | Yes | | **Persistent watchers** | No | No | Yes ([limits](#watcher-limits)) | | **Webhook subscriptions** | No | No | Yes | | **Overage rate** | \$0.005/req | \$0.003/req | \$0.001/req | | **Overage cap** | \$50/mo | \$75/mo | \$100/mo | | **Hard limit** | 15,000 | 50,000 | 200,000 | All paid tiers include automatic data freshness on **property and building detail**: when cached data is past your tier's threshold, the API returns it immediately with `"source": "stale"` and queues a background refresh, so a later request can get fresher data. Search is different: search results are kept current by the scheduled crawl, so `"source": "stale"` on a search response is a freshness signal only and does not queue a refresh. Check the response's `source` and `dataAge` to see how fresh a given result is, and see [Data freshness](/borough/guides/data-freshness) for the per-tier thresholds. Live data is best-effort. Business live-first detail, `live` events on Pro+ listing streams, background refreshes, and listing-watcher polls all draw on a shared live-fetch budget. While that budget is held for Borough's scheduled updates, or live fetching is paused, detail routes keep serving cached data (`"source": "cached"` or `"stale"`), background refreshes are skipped, listing-watcher polls are skipped without being counted, and a listing stream ends its live attempt with an `error` event (`budget_exceeded` or `service_disabled`) after the `cached` event. ### Watcher limits Business customers can have up to **50 active watchers**: * **Listing watchers** check the live listing on every poll, so at most **5** can be active, and their `pollInterval` minimum and default is **3,600 seconds** (1 hour). * **Building and search watchers** poll Borough's own data, with a minimum `pollInterval` of **300 seconds** and a default of **900**. * A **search watcher's** filters can match at most **500** active listings. Broader filters are refused with `400 INVALID_PARAMS` when the watcher is created or resumed; narrow them with `areas`, `minPrice`/`maxPrice`, `beds`, `listingType`, or `buildingType`. Creating a watcher past a cap returns `429 QUOTA_EXCEEDED`, resuming one past a cap returns `400`, and a `pollInterval` below the minimum returns `400`. ## Overage billing When you exceed your included monthly quota, requests continue to flow instead of being blocked immediately. Each additional request is billed at the overage rate for your tier, up to the monthly overage cap. **How it works:** * Your included quota is consumed first (no overage charges) * Once the base quota is exhausted, each additional request incurs the overage rate * The **hard limit** is your base quota plus the maximum extra requests before hitting the dollar cap * Example: Starter gets 5,000 included + 10,000 overage ($50 ÷ $0.005) = **15,000 total** * Once the hard limit is reached, requests return `429 QUOTA_EXCEEDED` * Free anonymous quotas reset on the 1st of each calendar month * Paid quotas and overage counters reset with your Polar subscription billing period Usage is tracked via the `X-Quota-*` headers on metered responses. They are absent on the never-counted control calls below (`PATCH` and `DELETE` on watchers and webhooks, `GET /v1/usage`) and on requests rejected before metering, such as authentication errors. Paid tiers also receive `X-Quota-Overage-Limit` showing the hard limit. ### What counts as a request * Only successful (`2xx`) responses count. A request that fails is not charged. * `POST /v1/property/batch` counts one request per unique ID (up to 50 IDs per call on Starter, 500 on Pro and Business). * Each photo (`GET /v1/photos/{key}`) counts as one request, including repeat downloads of the same image, so cache photos you have already fetched. Keyless photo requests count against the Free tier's per-IP allowance. * Each watcher poll counts as one request, and a watcher is paused when you reach your hard limit. Over 30 days a listing watcher at its 1-hour interval uses about 720 requests, and a building or search watcher about 2,880 at the 15-minute default or 8,640 at the 5-minute minimum. A listing-watcher poll that is skipped because live fetching is held back or paused is not counted. * Each watcher stream connection counts as one request. Streams reconnect about every 5 minutes, so one kept open continuously costs about 8,640 requests per 30 days. See [Streaming](/borough/guides/streaming#quota-impact). * Updating, pausing, or deleting a watcher or webhook (`PATCH` or `DELETE` on `/v1/watchers/{id}` and `/v1/webhooks/{id}`) and `GET /v1/usage` are never counted. * All API keys on one subscription share a single quota counter. Overage charges are billed automatically through Polar.sh at the end of each billing period. ## Subscribe Click below to choose your plan and complete checkout. Your `BOROUGH-` license key is delivered immediately. Get API Key After subscribing, your license key will be emailed to you and you'll be redirected to the [quickstart guide](/borough/quickstart) to make your first request. ## Manage your subscription View your license key, update payment details, or change plans in the customer dashboard. # Qwady Docs Source: https://qwady.wiki/index API documentation, guides, and references for Qwady products Welcome to the Qwady documentation hub. Structured NYC real estate data via REST API. 50+ fields per listing, building data, market statistics, and live streaming.