API Overview

The Underlay API is a JSON REST API served at /api. All request and response bodies are JSON (except file uploads/downloads). A machine-readable reference is available at /llms.txt.

Base URL

https://www.underlay.org/api

Authentication

GET and HEAD requests need no authentication to read public data. Writes (POST, PATCH, PUT, DELETE) require authentication, with three exceptions. POST /api/records/batch and POST .../files/presign are reads sent as POSTs, because their hash lists are too long for a query string; they are reachable anonymously and make the same access checks as the matching GETs. POST /api/abuse-reports is open to anyone.

A key may also be passed as ?token= in the query string (this is how share and agent links work). That form is honored on GET/HEAD only, so a link prefetch can never drive a mutation; everything else must send an Authorization: Bearer header. An invalid or expired ?token= is rejected with 401, as a Bearer key is; it does not fall back to anonymous access.

There are two authentication methods:

API Keys (recommended for scripts & apps)

Pass your key as a Bearer token:

Authorization: Bearer ul_a1b2c3d4e5...

A key has one of three scopes, set as metadata.scope when it is created:

  • read: list and download what the key’s holder can see
  • write: also push versions, upload files, and create and edit collections, as a member of the owning organization
  • admin: also the holder’s owner or admin powers

A key never does more than its holder’s role allows, and read and write keys act as a plain member even when the holder is an owner. Owner and admin actions on a collection (deleting it, changing whether it is public, transferring it, managing its webhooks, storage and mirrors) need a signed-in session or an admin key held by an owner or admin of the owning organization. So do changing or deleting an organization, setting its NAAN, and deleting your account.

A key scoped to specific collections (this is how share and agent links work) is confined to them: it is rejected with 403 on account and organization endpoints, cannot enumerate other collections, and is treated as anonymous outside its scope. Within its collections it acts as a member, whatever its scope: it may push and upload, but changing visibility, deleting, transferring, and managing webhooks or mirrors get 403.

An agent link, created from a collection’s Share panel, is https://www.underlay.org/agent/<key>: a write key confined to that one collection, expiring after an hour. GET /agent/:key is an HTML page of instructions an AI agent can follow to push to the collection with that key. An expired or revoked key’s page is 404.

Create keys in your settings or with POST /api/auth/api-key/create. The /api/auth/api-key/* routes need a signed-in session: a key cannot create, list or revoke keys.

Session Cookies (browser)

The web UI authenticates via OAuth2/PKCE sign-in through KF Auth, handled by better-auth at /api/auth/*. A session lasts 7 days and is renewed while it is in use.

Invalid Credentials

If a key is provided (as a Bearer token or as ?token=) but is not a valid key, the request is immediately rejected with 401. It will not fall through to anonymous access.


Rate Limits

Every request spends units from a per-minute budget: the caller’s own budget when authenticated, or one shared by its IP address when anonymous.

CallerBudget
Anonymous API calls (/api/*), per IP60 units / minute
Anonymous web pages, ARK resolution and the sign-in routes (/api/auth/*), per IP600 units / minute
Authenticated (session or API key), per user; a key owned by an organization, per organization5,000 units / minute

Most API requests cost one unit. Requests that read a lot cost more:

RequestCost
/api/collections/:owner/:slug/export20
.../versions/:n/pack, .../versions/:n/records.ndjson and .../versions/:n/records.ndjson.gz10
POST /api/records/batch, /api/records/:hash/provenance, .../diff and .../history5
Web pages (record pages cost 5)2
Everything else1

Responses carry no rate-limit headers. A request over budget gets 429 Too Many Requests with Retry-After: 60 and this body:

{
  "error": "Rate limit exceeded",
  "statusCode": 429
}

On Cloudflare Workers the count is kept per Cloudflare location, so the limits are approximate. For any automated or scripted access, always use an API key to get the higher budget.


Error Responses

Errors return a JSON body with error and statusCode. Some add fields of their own, such as filesNeeded on a missing-files 422:

{
  "error": "Authentication required",
  "statusCode": 401
}

Common status codes:

  • 400: Bad request (invalid input)
  • 401: Authentication required or invalid credentials
  • 403: Insufficient permissions: a scope or role that doesn’t allow the action, an API key used outside the collections it is scoped to, or a collection-scoped key on an account or organization endpoint
  • 404: Resource not found, or not visible to you. Private collections and inaccessible files return 404 rather than 403, so a response cannot confirm they exist
  • 409: Version conflict (re-fetch and retry), a slug that is already taken, or no changes: the push has the same content as the latest version
  • 413: Payload too large (a body or file over its size limit)
  • 422: Validation error (e.g. missing files)
  • 429: Rate limited, or too many push sessions open at once; wait for Retry-After and retry
  • 451: The file has been withheld and is not served (only for a file you could otherwise read; any other file is 404)
  • 503: Storage is briefly busy while cleanup runs; retry after Retry-After (30 seconds)

Endpoints