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.
/tokensand/webhooksare 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
2xxwithin 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.