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://underlay.org/api

Authentication

GET and HEAD requests need no authentication to read public data. Writes (POST, PATCH, PUT, DELETE) require authentication, with one exception: POST .../files/presign is a read in POST clothing — the hash list is too long for a query string — and is reachable anonymously, subject to the same per-file access check as the single-file GET.

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. Unlike a Bearer token, an invalid or expired ?token= does not 401 — it falls through to anonymous access and returns whatever is public.

There are two authentication methods:

API Keys (recommended for scripts & apps)

Pass your key as a Bearer token:

Authorization: Bearer ul_a1b2c3d4e5...

Keys have two grantable scopes:

  • read: list and download data
  • write: push versions, upload files, manage collections you have rights to

admin is not grantable through the API — a request for it is clamped down to write. Destructive actions such as deleting a collection are gated on your role in the owning organization (owner or admin), not on a key scope.

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.

Create keys in your organization settings or via POST /api/auth/api-key/create.

Session Cookies (browser)

The web UI authenticates via OAuth2/PKCE sign-in through KF Auth, handled by better-auth at /api/auth/*. Sessions expire after 30 days.

Invalid Credentials

If a Bearer token is provided but does not match any key, the request is immediately rejected with 401. It will not fall through to anonymous access.


Rate Limits

All API requests are rate-limited per IP (unauthenticated) or per account (authenticated). Authenticated requests get a significantly higher allowance:

Auth statusLimit
Unauthenticated (by IP)60 requests / minute
Authenticated (by account)5,000 requests / minute

Every response includes rate limit headers:

  • X-RateLimit-Limit: max requests in the current window
  • X-RateLimit-Remaining: requests remaining
  • X-RateLimit-Reset: seconds until the window resets

When you exceed the limit, you'll receive a 429 Too Many Requests response with a Retry-After header indicating how long to wait.

For any automated or scripted access, always use an API key to get the higher rate limit.


Error Responses

Errors return a JSON body with error and statusCode:

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

Common status codes:

  • 400: Bad request (invalid input)
  • 401: Authentication required or invalid credentials
  • 403: Insufficient permissions — wrong scope, an API key used outside the collections it is scoped to, a collection-scoped key on an account/org endpoint, or a fork of a collection whose private content you cannot see
  • 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), or duplicate content — both the private: and public: digests match an existing version
  • 413: Payload too large (file upload exceeds size limit)
  • 422: Validation error (e.g. missing files)
  • 429: Rate limited (wait and retry)

Endpoints