# NYC Data Hub: guide for website builders

This guide is for the people (or AI agents) building the other apps on unifiedbuildingpro.com. It explains how a website reads data from the hub, how it tells the hub which person is asking, and how a website can give its own AI chat access.

The hub is a separate service. Call it from your website's **server**, never from browser code with a website key. The full machine-readable contract is `lib/api-spec/openapi.yaml` in the hub project; this file is served live at `GET <hub>/api/docs/integration`.

## 1. Credentials

| Kind | Looks like | Who holds it | Where it may be used |
|---|---|---|---|
| Website key | `dh_live_` + 43 characters | Your website's server, as the secret `DATA_HUB_API_KEY` | REST routes under `/api/v1`. Never from a browser, never in the AI plug-in. |
| Person token | `dh_user_` + 43 characters | Minted by your server for one signed-in person, lifetime 1 to 60 minutes | REST routes and the AI plug-in `/api/mcp`. May be used from a browser page on unifiedbuildingpro.com. |
| Agent key | `dh_agent_` + 43 characters | An AI tool configured by its owner | The AI plug-in and REST read routes. Not for websites. |

Settings in your website's Secrets:

- `DATA_HUB_BASE_URL`: the hub's published https address (not the development preview).
- `DATA_HUB_API_KEY`: the website key the owner gave you.

The owner creates website keys with `pnpm hub live add-website "<name>"`. By default a website must say which signed-in person is asking (see section 3).

## 2. Reading records

```ts
const params = new URLSearchParams({ sourceKey: "nyc_pluto", entityKey: bbl, limit: "100" });
const response = await fetch(`${process.env.DATA_HUB_BASE_URL}/api/v1/records?${params}`, {
  headers: {
    Authorization: `Bearer ${process.env.DATA_HUB_API_KEY}`,
    "X-Data-Hub-User": signedInPerson.email, // the email your login verified
  },
  signal: AbortSignal.timeout(10000),
});
const result = await response.json();
// result.availability, result.records, result.source, result.hasMore, result.nextCursor, result.accessEventId
```

Rules:

- `entityKey` for `nyc_pluto` is a ten digit BBL: borough (1 digit) + block (5 digits) + lot (4 digits), leading zeros kept.
- If `hasMore` is true, call again with `cursor=<nextCursor>`. Each page is one lookup.
- Reads never contact the city. They return what the hub already holds.
- Do not forward the browser's `Origin` or `Cookie` headers to the hub. A website key sent with an `Origin` header is refused.

### Availability: read it first

Every records response has `availability`. Show it to people and never flatten it to "no records".

| Value | Meaning |
|---|---|
| `current` | Complete as of the last successful lookup, within the refresh policy. |
| `stale` | Complete, but the last successful lookup is older than the refresh policy. |
| `partial` | Some records are held, but the last lookup did not cover everything. |
| `failed` | The last lookup failed. Records shown are the last good copy. |
| `refreshing` | A lookup is running right now. |
| `not_fetched` | UNKNOWN. The hub never looked this up. Zero records is not evidence of anything. |

The single-record route `GET /api/v1/records/{sourceKey}/{externalId}` returns the same word in the `X-Data-Hub-Availability` header.

### Errors

Every error body is `{ "error": "<plain sentence>", "code": "<stable code>" }`. Common codes:

| HTTP | Codes |
|---|---|
| 401 | `credential_required`, `credential_invalid`, `credential_revoked`, `token_expired`, `token_revoked` |
| 403 | `origin_not_allowed`, `server_only_key`, `credential_paused`, `user_assertion_not_allowed`, `user_not_registered`, `user_disabled`, `user_required`, `scope_missing`, `source_not_allowed`, `website_key_not_for_mcp` |
| 404 | `source_not_found`, `record_not_found`, `event_not_found` |
| 429 | `daily_limit`, `rate_limited`, `too_many_tokens` (the `Retry-After` header says how many seconds to wait) |

On 429, wait the number of seconds in `Retry-After`. Daily limits reset at midnight New York time.

## 3. Saying which person is asking

Send `X-Data-Hub-User: <email>` with the website key. Only send emails your own login has verified: the hub trusts your website for this, and every claim is logged against your website.

- The person must be on the hub's list (the owner adds them with `pnpm hub live add-user`), or your website must be allowed to add people automatically (`--auto-add-users`). A person your website added stays limited to your website until the owner approves them.
- Unknown person: 403 `user_not_registered`. The owner sees these emails with `pnpm hub live waiting-users`.
- The person's own plan and limits apply, on top of your website's. Access is always the narrower of the two.
- `GET /api/v1/me` (with the same headers) returns what that person may read and how much of today's limit is used.

If the owner created your website with `--allow-no-user`, you may read without the header, under the website's own access only.

## 4. AI chat on your website

Your server mints a short-lived person token and hands it to the AI. The person's browser never sees a hub key.

```ts
// 1. Mint a token for the signed-in person (needs the users:assert permission on your website key).
const minted = await fetch(`${HUB}/api/v1/user-tokens`, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.DATA_HUB_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ user: signedInPerson.email, ttlSeconds: 900, purpose: "website chat" }),
}).then((r) => r.json());
// minted.token (dh_user_...), minted.tokenId, minted.expiresAt, minted.sources

// 2. Let Claude use the hub through the MCP connector.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const response = await client.beta.messages.create({
  model: "claude-opus-5-5",
  max_tokens: 16000,
  betas: ["mcp-client-2025-11-20"],
  mcp_servers: [{ type: "url", url: `${HUB}/api/mcp`, name: "nyc-data-hub", authorization_token: minted.token }],
  tools: [{ type: "mcp_toolset", mcp_server_name: "nyc-data-hub" }],
  messages: [{ role: "user", content: question }],
});
```

Notes:

- Cache one token per signed-in session on your server and mint a new one only when fewer than two minutes remain. There is a cap of 100 live tokens per person per website.
- Revoke with `DELETE /api/v1/user-tokens/{tokenId}` when the session ends (optional).
- The hub checks the person's permissions and daily limit on every tool call and logs each one against the person, your website and the token.
- For a chat that serves visitors who are not signed in, ask the owner for an agent key for your website instead; it has its own daily limit and off switch.
- Anthropic's servers call the hub, so the hub must be published at its public https address.

Alternative without a token: run your own tool loop on your server and call the REST routes with the website key plus `X-Data-Hub-User`. Same permissions, same logs.

## 5. Browser pages (optional)

A page on unifiedbuildingpro.com or any of its subdomains may call the hub directly with a person token (never a website key). The hub answers CORS preflights for those origins only. No cookies are used and credentials are never allowed in CORS, so there is no CSRF surface.

## 6. Acknowledging a display (optional)

After your page actually shows the data, your server may `POST /api/v1/access-events/{accessEventId}/displayed` with `{ "displayedAt": "<ISO time>" }`. A person token may acknowledge only its own reads. This is a client report, not proof a person looked.

## 7. What the hub does not do yet

- No automatic refresh schedule and only one source (PLUTO) so far; the owner preloads properties with `pnpm hub live fetch nyc_pluto <BBL>`. Expect `not_fetched` for properties nobody preloaded.
- No payments. Plans and limits are set by the owner.
- No sign-in. Logins stay on your website.
