Basalt docs
GitHub

API

Basalt has one API, and the web app is a client of it. There are no private fast paths that let the public surface rot (ADR-0009): anything the interface can do, you can do.

Everything lives under /api/v1 on the same origin as the app. The API reference lists every operation and is generated from the OpenAPI document the server emits — it cannot describe a route the server does not serve.

The machine-readable spec is packages/api-client/openapi.json (OpenAPI 3.1), regenerated on every build and diffed in CI, so a breaking change to /api/v1 cannot merge without a version bump.

Authenticating

Two credentials, one identity model.

Session cookies

The web app signs in through Better-Auth at /api/auth/* and gets an httpOnly cookie. That is what a browser uses. It is not what a script should use.

Personal access tokens

Settings → Tokens mints a token for one workspace, with scopes. Present it as a bearer credential:

curl https://notes.example.com/api/v1/workspaces/$WS/pages \
  -H "Authorization: Bearer basalt_pat_…"

The secret is shown once, at creation. It is stored hashed; there is no way to retrieve it later. The listing shows the last six characters so you can tell two tokens apart.

A token authenticates as the person who created it and can never do more than they can. Nothing below the authentication layer knows whether a request arrived with a cookie or a token, which is what keeps the permission model singular.

Four scopes:

Scope Covers
content:read Reading pages, blocks, collections, databases, views, fields, comments, activity, search, export.
content:write Creating and changing all of the above, plus import. Implies content:read.
admin:read Reading members, groups, invitations, page ACLs, your own capabilities.
admin:write Changing them — membership, roles, permissions, sharing. Implies admin:read.

The split is by what a mistake costs, not by resource: content is one authoring surface, and administration decides who may. Permissions and public sharing count as administration even where they hang off a page URL — an integration allowed to edit a page must not thereby be able to publish it to the internet.

Two things a token can never do:

  • Reach the credential surface. /tokens and /webhooks are session-only. A token that can mint tokens can mint a broader one that outlives the revocation of the first.
  • Leave its workspace. A token names one workspace; anything outside /api/v1/workspaces/{workspaceId} — creating a workspace, listing them, accepting an invitation, the public share reader — needs a session.

Routes in an area nobody has classified fail closed: a token gets 403, never an accidental yes.

Expiry and revocation

Tokens expire. The default life is 90 days, the maximum 365; there is deliberately no "never expires" option. Revoke one in Settings, or let it lapse.

A token whose owner leaves the workspace is revoked automatically the next time it is used, permanently — re-joining later does not bring it back.

Requests and responses

Errors

Every non-2xx response under /api has the same body:

{ "error": { "code": "not_found", "message": "page not found" } }

code is one of bad_request, validation_failed, unauthorized, forbidden, not_found, conflict, rate_limited, internal. Branch on code; message is for humans and may change.

404 does double duty on purpose: a workspace you are not a member of is indistinguishable from one that does not exist.

Pagination

List endpoints are cursor-based:

{ "items": [], "nextCursor": "eyJ…" }

Pass ?cursor= from the previous response to continue; nextCursor: null means there is no next page. ?limit= defaults to 50 and caps at 100. Cursors are opaque — do not parse them.

Cross-origin writes

Mutating requests and WebSocket upgrades that carry an Origin header are rejected unless the origin is the request's own host or BASE_URL. Requests without an Origin — curl, SDKs, servers — are unaffected, since they carry no ambient cookie.

Rate limits

Only the credential endpoints under /api/auth are rate limited (/sign-in/email, /sign-up/email), per client address. The /api/v1 surface is not throttled.

Pages as Markdown

Markdown is the contract, not an export format (ADR-0012). Any page reads and writes as canonical Markdown:

# Read
curl "$BASE/api/v1/workspaces/$WS/pages/$PAGE?format=markdown" \
  -H "Authorization: Bearer $TOKEN"

# Write — replaces the body
curl -X PUT "$BASE/api/v1/workspaces/$WS/pages/$PAGE?format=markdown" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: text/markdown" \
  --data-binary @page.md

The response is text/markdown. A PUT goes through the same collaborative document the editor uses, so a person with the page open sees your change arrive live rather than losing their work to it.

Two boundaries worth knowing:

  • The page title is not in the Markdown. It is structure, changed through PATCH /pages/{pageId}.
  • Field values are, as YAML frontmatter keyed by field key. They round-trip in both directions, except relations: relation values are emitted on read but ignored on write, because the relation table is authoritative and is edited through the relations endpoints.

The exact dialect — every block's canonical form, and the round-trip guarantees CI enforces — is the Markdown specification.

Real-time

Two WebSocket planes, both under /api/v1, both requiring a session:

Path Carries
/api/v1/collab Yjs document sync for the page you are editing, plus presence. This is where document text lives; it is not a REST resource.
/api/v1/events resource.changed frames so a client can invalidate caches instead of polling. Frames naming content a subscriber may not read are filtered out.

Neither is part of the OpenAPI document — they are not request/response surfaces. For polling-free automation, prefer webhooks.

Webhooks

Settings → Webhooks registers an endpoint for a chosen set of resource kinds (page, database, db_row, collection, field, db_view, permission, group, invitation, comment, workspace).

Deliveries are POST with a JSON body:

{
  "id": "9e1c…",
  "type": "resource.changed",
  "workspaceId": "2aa7…",
  "resource": "page",
  "resourceId": "5387…",
  "version": 12,
  "occurredAt": "2026-07-26T13:18:29.164Z"
}

id is stable across retries — deduplicate on it.

Headers:

Header Value
Basalt-Signature t=<unix seconds>,v1=<hex>
Basalt-Delivery The delivery id, same as id in the body.
Basalt-Event <resource>.changed.
Basalt-Attempt 1-based attempt number.

Verifying a delivery

The signature is HMAC-SHA256(secret, "<timestamp>.<rawBody>"), hex-encoded. Sign the raw body bytes, not a re-serialised object. The timestamp is inside the signed string, so a captured request cannot be replayed later with a fresh header — check its age yourself (five minutes is a sensible tolerance) and compare digests in constant time.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = new Map(header.split(',').map((p) => p.split('=', 2)));
  const t = Number(parts.get('t'));
  const v1 = parts.get('v1');
  if (!Number.isFinite(t) || typeof v1 !== 'string') return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(v1, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Delivery behaviour

  • Answer 2xx within 10 seconds. Anything else counts as a failure.
  • Failures retry 8 times total, backing off 10 s, 30 s, 2 min, 10 min, 30 min, 1 h, 2 h.
  • 3 consecutive dead deliveries disable the endpoint. Re-enable it in Settings once your receiver is healthy.
  • An endpoint that accumulates 1000 undelivered rows starts dropping events.
  • The secret is shown once at creation and can be rotated.

Clients

packages/api-client is a typed TypeScript client generated from the same route table the server registers, and it ships the OpenAPI document. It is not published to npm yet.

For anything else, generate a client from packages/api-client/openapi.json with your own tooling. The spec is byte-stable and CI-enforced, so it is safe to depend on.

Agents

If your reason for reading this page is "I want an LLM agent to work in my workspace", the Markdown surface above is the whole story — read a page, edit the text, write it back. There is also a first-party MCP server; see MCP.