Documentation
The public read API
What the version one API serves without a key, why the health route says nothing about the price data, and the limits that apply to every caller.
Last reviewed against the product on .
The public read API at /api/v1 answers ordinary HTTP GET requests with JSON, with no key, no
account and no cookie. It exists because this site's other machine-readable surface is JSON-RPC over
MCP, which suits an AI client and suits nothing else: a crawler, a spreadsheet or a shell script had
no way in.
What each route answers
| Route | What the body carries |
|---|---|
| /api/v1 | The index: site name, description, a documentation link, an absolute URL for each of the other routes, and readOnly set to true |
| /api/v1/health | A status of ok, a version, and the moment the request was served |
| /api/v1/site | Site name, canonical origin, description, version, and the address of the MCP endpoint |
| /api/v1/capabilities | A count, then each capability as a name and one sentence |
| /api/v1/siblings | A count, then each sibling Novus property as a name and a URL |
Measured from createPublicApiHandler in src/lib/shared/public-api.ts and the route files under app/api/v1.
The shape is the estate's, not this site's. Every Novus property mounts the same contract from the same shared module, so a client that can read one site's API can walk them all without special casing any of them.
The field called version means two different things
On the index it is the version segment of the path itself, and it moves only when the shape of these
responses breaks. On /health and /site it is the application's own version, read from the package
manifest. Nothing in the response distinguishes them by name, so a client that reads one where it
meant the other will be quietly wrong rather than noisily broken.
Health describes the process, not the prices
Capabilities are the MCP catalogue, projected
The capability list is not written out for this API. It is built from the same tool array the MCP handler dispatches on and the public MCP server page renders. A second hand-written list would drift the first time somebody renamed a tool, and it would drift silently, because nothing compares the two.
Each entry carries a name and a description and no link. The tools share one human page and it has no per-tool anchors, so minting anchor links here would publish deep links to ids that do not exist in the served markup.
The list is also not gated on the MCP feature flag. The flag decides whether the JSON-RPC transport
answers, not what the site is for, so switching the transport off does not change what this API says
the site can do. Whether the endpoint is reachable is /mcp's own answer to give, and
the MCP server explains how it gives it.
- Routes
- 5
- the index, plus health, site, capabilities and siblings
- Capabilities listed
- 3
- projected from the MCP tool catalogue rather than listed again
- Siblings listed
- 9
- every other Novus property; this site is deliberately absent from its own list
Measured from PUBLIC_API_ROUTES in src/lib/shared/public-api.ts, mcpServerDefinition in src/lib/mcp/server.ts and NOVUS_APPS in src/lib/novus-apps.ts.
Methods, and what a browser gets
| Method | Answer | Why |
|---|---|---|
| GET and HEAD | The document described above | The only methods that read. Both take the same path through the handler. |
| OPTIONS | 204, no body, cross-origin headers only | Answered before the rate limiter is consulted, so a browser preflight never spends the caller's budget. |
| Anything else | 405, an Allow header, and a method_not_allowed body | Said in the body rather than left to the status code, because the alternative is somebody writing a POST client and discovering the truth in production. |
Measured from createPublicApiHandler and the CORS constant in src/lib/shared/public-api.ts.
Cross-origin access is open to any origin, and that is not a loosening. Every response is public and unauthenticated, and none of them carries a cookie. Restricting the origin would block exactly the browser-based clients the API exists for while protecting nothing, since anything server-side ignores cross-origin rules entirely. Credentials are not permitted on these responses, so a browser cannot be talked into attaching a session to one.
Caching, and why the routes are dynamic
- Browser cache
- 300 seconds
- max-age on the four cacheable routes
- Shared cache
- 600 seconds
- s-maxage, so the edge answers most repeat traffic
- Stale while revalidate
- 86400 seconds
- a burst after expiry is served from the edge while one request refreshes behind it
- Health
- no-store
- a cached health check reports the past
Measured from the CACHE_CONTROL and NO_STORE constants in src/lib/shared/public-api.ts.
Each route is rendered dynamically, which looks like a contradiction next to shared caching and is not one. A statically rendered route is generated once at build time and served to everyone from the edge, which would mean the rate limiter ran exactly once, during the build, and never again. Dynamic rendering keeps the limiter on the requests that reach the origin; the cache headers keep most requests from reaching it.
Rate limiting
- Budget
- 60 per minute
- per caller address, counted separately for each route
- Window
- 60 seconds
- opened by the first request after the previous window expired
- Tracked callers
- 5,000
- a ceiling on the bucket map, so a flood of distinct addresses cannot exhaust memory
Measured from RATE_POLICIES.publicRead, checkRateLimit and guard in src/lib/shared/rate-limit.ts.
The budget is scoped per route rather than per site on purpose. A client hammering /capabilities
cannot exhaust the allowance for /health and take the site's own monitoring down with it.
Every served response carries the rate-limit headers, not only the refusals, because a client can
pace itself only if it learns its budget while it is still being served. A refusal answers 429 as a
problem document with a Retry-After header, and is marked as never cacheable: a shared cache that
stored one over-budget client's refusal would serve it to everyone behind the same edge node, turning
a rate limit into an outage.
What this API will not do
It does not write. Every route is a read, and the index says so in its own body. There is no POST, no upload and no mutation, because an endpoint that changes state needs authentication, and most of the estate has no account system to authenticate against.
It has no keys, no dashboard and no usage console. Nothing here is issued, metered or revoked per caller. If you need a credential, that is the private MCP surface and its personal access tokens, which reach a different endpoint entirely.
It serves nothing private. Everything these routes return is already on this site's own public pages. No route reads a row belonging to a person or an organization, so there is nothing here that signing in would show you more of.
It takes no parameters. There is no filtering, no paging and no query string. Each route answers one fixed document, whole, which is what makes the responses identical for every caller and therefore safe for a shared cache to hold.
It is asked not to be crawled. robots.txt disallows the whole of /api/, which keeps a crawler
from spending its budget on routes that answer with limits or redirects. That is a courtesy to
crawlers and not a security control: the endpoints answer anyone who asks.