@borough/sdk
v0.7.2
Published
Official TypeScript SDK for the Borough NYC Real Estate Data API
Readme
@borough/sdk
Official TypeScript SDK for the Borough NYC Real Estate Data API.
Installation
npm install @borough/sdk
# or
pnpm add @borough/sdk
# or
yarn add @borough/sdkQuick Start
import { BoroughClient } from "@borough/sdk";
const borough = new BoroughClient("BOROUGH-...");
// Search rentals in Manhattan under $3,500/mo
const results = await borough.rentals.search({
areas: "100",
maxPrice: 3500,
minBeds: 1,
amenities: ["DISHWASHER"],
});
console.log(`Found ${results.total} listings`);
for (const listing of results.data) {
console.log(`${listing.address.street} — $${listing.price}/mo`);
}
// Get full listing detail
const { data: listing } = await borough.listings.get("4961849");
console.log(listing.amenities, listing.buildingScores);The client also reads BOROUGH_API_KEY from the environment:
const borough = new BoroughClient(); // uses process.env.BOROUGH_API_KEYResources
Rentals & Sales
const rentals = await borough.rentals.search({ areas: "301", noFee: true });
const sales = await borough.sales.search({ areas: "200", saleType: "RESALE" });
const amenityFocused = await borough.rentals.search({
areas: "115",
maxPrice: 2500,
amenities: ["DISHWASHER", "WASHER_DRYER"],
});Listings
const { data } = await borough.listings.get("4961849");
const { data: same } = await borough.listings.getByUrl(
"/building/123-main/apartment-4a"
);
const { data: history } = await borough.listings.history("4961849");
const { data: fees } = await borough.listings.fees("4961849");
const { data: openHouses } = await borough.listings.openHouses("4961849");Buildings
const { data: building } = await borough.buildings.get("12345");
const listings = await borough.buildings.listings("12345", {
status: "ACTIVE",
listingType: "rental",
});
const { data: scores } = await borough.buildings.scores("12345");
const { data: violations } = await borough.buildings.violations("12345");Areas
const areas = await borough.areas.list({ level: 2, parentId: 100, include: "boundary" });Market
const { data: snapshot } = await borough.market.snapshot({ areaId: 301 });
const { data: trends } = await borough.market.trends({ areaId: 301 });
const { data: comparison } = await borough.market.compare({
areas: "301,302,303",
});Pagination
Search methods return a Promise of a PaginatedResult that supports async iteration:
// Iterate through all pages automatically
const results = await borough.rentals.search({ areas: "100" });
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 });
// Manual pagination
let page = await borough.rentals.search({ areas: "100" });
while (page) {
console.log(page.data.length, "listings on page", page.meta.page);
page = await page.nextPage();
}The auto-pagination cap
Auto-iteration fetches at most 100 pages by default. That is a guardrail against
runaway loops, not a size you should rely on: at the API's default perPage of 10 it
covers only 1,000 items, and a citywide rental search returns ~13,000.
If the cap is reached while pages remain, the SDK throws PaginationLimitError rather
than handing back a partial array that looks complete. Raise or remove the cap per call:
import { PaginationLimitError } from "@borough/sdk";
// Fetch everything, however many pages it takes
const results = await borough.rentals.search(
{ areas: "100,200,300,400,500", perPage: 500 },
{ maxAutoPages: Infinity }
);
const all = await results.toArray();
// Or accept a bounded scan explicitly, then check what you got
const bounded = await borough.rentals.search(
{ areas: "100" },
{ maxAutoPages: 10, onMaxAutoPages: "stop" }
);
const partial = await bounded.toArray();
if (bounded.truncated) {
console.warn(`Stopped early: ${partial.length} of ${bounded.total}`);
}maxAutoPages must be a positive integer or Infinity — anything else, including 0,
throws a RangeError. (Page one is fetched before iteration starts, so there is no
"fetch zero pages" mode to ask for.)
toArray({ limit }) does not throw as long as the limit is reachable within the cap —
an explicit item limit is a request you made, so iteration stops there before the cap
applies. Ask for more than the cap can deliver and it throws like any other run:
toArray({ limit: 5000 }) under the default 100 pages at perPage: 10 only ever reaches
1,000 items, so it hits the cap first. nextPage() is unaffected by the cap entirely.
Raising perPage (up to 500 on Pro and Business) is the cheapest way to stay under it.
truncated describes the most recent pass only. A PaginatedResult can be iterated more
than once, and each pass re-reads from page one and resets the flag.
Webhook Verification
Verify inbound webhook signatures using the Express or Next.js adapter. Requires standardwebhooks as a peer dependency:
npm install standardwebhooksExpress
import express from "express";
import { webhookMiddleware } from "@borough/sdk/webhooks/express";
const app = express();
app.post(
"/webhooks/borough",
express.raw({ type: "application/json" }),
webhookMiddleware(process.env.WEBHOOK_SECRET!, (event) => {
console.log("Received event:", event.type, event.data);
})
);Next.js (App Router)
import { webhookHandler } from "@borough/sdk/webhooks/nextjs";
export const POST = webhookHandler(process.env.WEBHOOK_SECRET!, (event) => {
console.log("Received event:", event.type, event.data);
});Error Handling
All API errors are typed:
import {
RateLimitError,
QuotaExceededError,
NotFoundError,
} from "@borough/sdk";
try {
await borough.listings.get("invalid");
} catch (err) {
if (err instanceof NotFoundError) {
console.log("Listing not found");
} else if (err instanceof RateLimitError) {
console.log("Slow down — retry after a moment");
} else if (err instanceof QuotaExceededError) {
console.log("Monthly quota exhausted");
}
}Configuration
const borough = new BoroughClient({
apiKey: "BOROUGH-...",
baseURL: "https://borough.qwady.app/v1", // default
timeout: 30_000, // ms, default; bounds each request, and for streams only the connection
maxRetries: 2, // exponential backoff with jitter
});Stream reconnection
const stream = await borough.watchers.stream("wch_123", {
maxConsecutiveFailures: 10, // default; connections in a row that may fail
signal: controller.signal, // abort to close the connection and end the loop
});
for await (const event of stream) console.log(event.event, event.data);watchers.stream() is meant to run for days, so it reconnects on its own. The
server closes a stream roughly every 5 minutes and the SDK reopens it with
Last-Event-ID, so the changes buffered while nobody was connected are
delivered rather than skipped (reconnectDelayMs, default 1 s, paces it;
consecutive connections that deliver nothing double the pause up to
maxReconnectDelayMs, 30 s).
A connection that breaks — a dropped body, or a reconnect that fails with a
5xx, a 429, or a transport error — is retried with jittered backoff, based on
the same two knobs, and resumes from the same place. The one exception is a 429
that carried a Retry-After: the server's instruction is honoured in full
rather than capped by maxReconnectDelayMs, and only aborting signal cuts
that wait short.
Two budgets bound the retrying. maxReconnects (unlimited by default) caps
every reopen, recovery and clean turnover alike. maxConsecutiveFailures
(default 10) caps how many connections in a row may fail; only a connection
that ends cleanly clears that streak, so a server that delivers one event and
then drops the socket every time counts each attempt as a failure and stops
instead of reconnecting for ever. It bounds failure streaks only — clean but
empty reconnects are paced by the doubling turnover delay above, not bounded by
either count.
Two things it deliberately does not cover:
- The first connection is not retried.
watchers.stream()opens eagerly, so a missing watcher, a paused one, or a transient 503 on that first connect rejects thestream()call itself. Recovery applies to the connections after it. Retry thestream()call if you want the first one covered too. - Resumption is per
stream()call. When the failure budget is exceeded the last error is thrown into yourfor awaitloop. You can start again by callingstream()again, but the new call begins with noLast-Event-ID, so it resumes from the current tip — changes the server buffered during the outage are not replayed.
Errors that a retry cannot fix — a revoked key (401), a tier restriction (403),
a deleted watcher (404), a paused one (400), an exhausted quota — are thrown
immediately. To stop a stream, break out of the for await loop or abort
options.signal: either one closes the connection and ends the iteration
quietly.
Documentation
Full API reference and guides: qwady.wiki/borough
Get an API key: Subscribe
License
MIT
