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/apiAuthentication
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 datawrite: 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 status | Limit |
|---|---|
| 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 windowX-RateLimit-Remaining: requests remainingX-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 credentials403: 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 see404: Resource not found — or not visible to you. Private collections and inaccessible files return 404 rather than 403, so a response cannot confirm they exist409: Version conflict (re-fetch and retry), or duplicate content — both theprivate:andpublic:digests match an existing version413: Payload too large (file upload exceeds size limit)422: Validation error (e.g. missing files)429: Rate limited (wait and retry)