Accounts API

Manage accounts, organizations and API keys.

Authentication

There are two authentication methods:

  • Session cookies: set via OAuth2/PKCE sign-in through KF Auth (handled by better-auth at /api/auth/*), used by the web UI
  • API keys: Authorization: Bearer ul_..., used by apps and scripts

User accounts are created automatically on first sign-in via KF Auth (OAuth2/PKCE), each with a personal account (an organization with isDefault: true) that the user owns. There are no local signup or login endpoints.

API keys have three scopes: read, write and admin. A key never does more than its holder’s role allows; see Authentication in the overview. The account endpoints below act for a user, so they take a session or an unscoped personal key: a key scoped to specific collections, or a key owned by an organization, gets 403. Endpoints that change something also refuse read keys. Changing or deleting an organization, setting its NAAN (PATCH /api/accounts/:slug/ark) and deleting your own account (DELETE /api/accounts/me, from your settings) take a session or an admin key: a write key gets 403, even when its holder is an owner. The /api/auth/* endpoints (organizations and API keys) need a signed-in session.


GET /api/accounts/me

Auth: session or an unscoped personal API key (any scope)

Get the authenticated user, with every organization they belong to. slug and displayName are those of the user’s personal account (the entry in orgs with isDefault: true).

Response 200

{
  "id": "uuid",
  "name": "Jane Doe",
  "email": "user@example.com",
  "image": "https://...",
  "slug": "jdoe",
  "displayName": "Jane Doe",
  "createdAt": "2026-01-15T00:00:00.000Z",
  "orgs": [
    {
      "organizationId": "uuid",
      "role": "owner",
      "slug": "jdoe",
      "name": "Jane Doe",
      "isDefault": true
    },
    {
      "organizationId": "uuid",
      "role": "member",
      "slug": "knowledge-futures",
      "name": "Knowledge Futures",
      "isDefault": false
    }
  ]
}

GET /api/accounts/:slug

No auth required

Get the public profile of any account. isDefault is true for a user’s personal account and false for an organization. arkNaan and arkShoulder are null when the account has no ARK settings of its own. An unknown slug returns 404.

Response 200

{
  "id": "uuid",
  "name": "Knowledge Futures",
  "slug": "knowledge-futures",
  "displayName": "Knowledge Futures",
  "avatarUrl": "https://...",
  "logo": null,
  "bio": "Open infrastructure for knowledge",
  "website": "https://www.knowledgefutures.org",
  "createdAt": "2026-01-15T00:00:00.000Z",
  "isDefault": false,
  "arkNaan": "12345",
  "arkShoulder": "ul"
}

GET /api/accounts/:slug/members

No auth required

List an account’s members and their roles (owner, admin or member). slug is the member’s personal account. An unknown slug returns 404.

Response 200

[
  { "role": "owner", "slug": "jdoe", "displayName": "Jane Doe" },
  { "role": "member", "slug": "asmith", "displayName": "Alex Smith" }
]

PATCH /api/accounts/:slug

Auth: an owner of the account, by session or an unscoped personal admin key

Update an organization’s profile. Pass only the fields to change: slug, displayName, bio, website and kfOrgId (the KF organization it is linked to, which must be one you belong to). null clears bio, website or kfOrgId.

Request

{
  "displayName": "Knowledge Futures",
  "bio": "Open infrastructure for knowledge",
  "website": "https://www.knowledgefutures.org"
}

Response 200

{ "ok": true, "slug": "knowledge-futures" }

Errors

403Not an owner of the account, a write key, or a kfOrgId you don’t belong to.
404No account with that slug.
409The new slug is already taken.
422The new slug is not a valid slug.

DELETE /api/accounts/:slug

Auth: an owner of the organization, by session or an unscoped personal admin key

Delete an organization, with its memberships, invitations and API keys. An organization that still holds collections is refused with 409: delete or transfer them first. A personal account can’t be deleted here (also 409); it is deleted from its own settings.

Response 200

{ "ok": true }

POST /api/auth/organization/create

Auth: session

Create an organization, with you as its owner. Managed by better-auth’s organization plugin; the slug must be a valid, unused account slug. GET /api/auth/organization/list lists the organizations you belong to.

Request

{
  "name": "My Lab",
  "slug": "my-lab"
}

better-auth’s POST /api/auth/organization/update and POST /api/auth/organization/delete are not served: they return 404 pointing at PATCH and DELETE /api/accounts/:slug, which apply Underlay’s rules for slugs and for organizations that still hold collections.


POST /api/accounts/invitations/accept

Auth: session or an unscoped personal key with write or admin scope

Accept an invitation to an organization. token is the invitation’s id; the invitation must be pending, unexpired and addressed to your account’s email. Anything else returns 404, whatever the reason.

Request

{ "token": "invitation-id" }

Response 200

{ "ok": true, "orgSlug": "my-lab" }

POST /api/auth/api-key/create

Auth: session

Create a new API key for the signed-in user. Managed by better-auth’s apiKey plugin. The raw key is in this response only; it is never shown again.

Request

{
  "name": "my-sync-script",
  "metadata": { "scope": "write", "collectionIds": ["uuid"] },
  "expiresIn": 7776000
}

Fields

nameOptional label, at most 32 characters.
metadata.scoperead (the default), write or admin.
metadata.collectionIdsOptional. Collection ids the key is confined to; omit it for a key that works wherever its holder does.
expiresInOptional lifetime in seconds, at most 365 days. Omitted, the key never expires.

Response 200

{
  "id": "uuid",
  "name": "my-sync-script",
  "start": "ul_a1b",
  "prefix": "ul",
  "enabled": true,
  "metadata": { "scope": "write", "collectionIds": ["uuid"] },
  "permissions": { "collections": ["write", "read"] },
  "expiresAt": "2026-04-15T00:00:00.000Z",
  "createdAt": "2026-01-15T00:00:00.000Z",
  "key": "ul_a1b2c3d4e5..."
}

GET /api/auth/api-key/list

Auth: session

List the signed-in user’s API keys. The raw key is not included. Takes optional limit and offset query parameters; total counts every key.

Response 200

{
  "apiKeys": [
    {
      "id": "uuid",
      "name": "my-sync-script",
      "start": "ul_a1b",
      "metadata": { "scope": "write", "collectionIds": ["uuid"] },
      "permissions": { "collections": ["write", "read"] },
      "createdAt": "2026-01-15T00:00:00.000Z",
      "expiresAt": "2026-04-15T00:00:00.000Z"
    }
  ],
  "total": 1
}

POST /api/auth/api-key/delete

Auth: session

Revoke one of your API keys.

Request

{ "keyId": "uuid" }

Response 200

{ "success": true }