API
Es gibt genau eine Schnittstelle, und die App, die Sie benutzen, ist selbst ein Client davon. Keine internen Abkürzungen, an denen die öffentliche Oberfläche langsam verrottet: Was die Bedienoberfläche kann, können Sie auch.
Alles liegt unter /api/v1 auf demselben Ursprung wie die App. Die
API-Referenz führt jede Operation auf und wird aus dem
OpenAPI-Dokument erzeugt, das der Server ausgibt — sie kann keine Route
beschreiben, die es nicht gibt.
Maschinenlesbar liegt das Ganze als packages/api-client/openapi.json
(OpenAPI 3.1) vor, bei jedem Build neu erzeugt und in der Prüfstrecke
abgeglichen. Eine brechende Änderung an /api/v1 kommt also nicht ohne
Versionssprung durch.
Authentifizieren
Zwei Zugangsarten, ein Identitätsmodell.
Sitzungs-Cookies
Die Web-App meldet sich über Better-Auth unter /api/auth/* an und bekommt ein
httpOnly-Cookie. Das ist der Weg des Browsers. Für ein Skript ist er der
falsche.
Persönliche Zugriffstoken
Unter Einstellungen → Tokens erzeugen Sie ein Token für genau einen Workspace, mit Geltungsbereichen. Sie schicken es als Bearer-Credential mit:
curl https://notes.example.com/api/v1/workspaces/$WS/pages \
-H "Authorization: Bearer basalt_pat_…"
Das Geheimnis erscheint ein einziges Mal, beim Anlegen. Gespeichert wird nur sein Hash; später herausholen lässt es sich nicht. Die Liste zeigt die letzten sechs Zeichen, damit Sie zwei Token auseinanderhalten können.
Das Präfix basalt_pat_ und die Basalt-*-Header weiter unten sind die Namen,
die tatsächlich über die Leitung gehen. Sie gehören der Plattform, nicht einem
einzelnen Produkt, und stehen hier so, wie Ihr Client sie empfangen wird — ein
hübscherer Name in der Dokumentation wäre ein Zeichenvergleich, der zur Laufzeit
scheitert.
Die Workspace-Kennung
$WS oben ist kein Platzhalter, den Sie weglassen können: Jeder Pfad unter
/api/v1/workspaces/{workspaceId} benennt den Workspace, und ein Token gilt für
genau einen. Die Kennung ist damit die zweite Hälfte dessen, was ein Skript
braucht — und Einstellungen → Tokens zeigt sie kopierbar im selben Feld wie
das neue Geheimnis. Nehmen Sie beides in einem Zug mit.
GET /api/v1/workspaces listet zwar ebenfalls die erreichbaren Workspaces samt
Kennungen auf. Diese Route braucht aber eine Sitzung: Ein Token kann seinen
Workspace nicht verlassen (siehe unten) und deshalb auch nicht fragen, welche es
gibt. Ein Skript, das nur ein Token hat, kann die fehlende Kennung nicht
ermitteln.
Ein Token authentifiziert als die Person, die es erzeugt hat, und kann nie mehr als sie. Unterhalb der Authentifizierungsschicht weiß nichts mehr, ob eine Anfrage mit Cookie oder mit Token kam — genau das hält das Rechtemodell einteilig.
Vier Geltungsbereiche:
| Bereich | Deckt ab |
|---|---|
content:read |
Seiten, Blöcke, Collections, Datenbanken, Ansichten, Felder, Kommentare, Aktivität, Suche und Export lesen. |
content:write |
All das anlegen und ändern, dazu Import. Schließt content:read ein. |
admin:read |
Mitglieder, Gruppen, Einladungen, Seitenrechte und die eigenen Befugnisse lesen. |
admin:write |
Sie ändern — Mitgliedschaft, Rollen, Rechte, Freigaben. Schließt admin:read ein. |
Geschnitten ist das danach, was ein Fehler kostet, nicht nach Ressource: Inhalte sind eine Autorenoberfläche, die Verwaltung entscheidet, wer darf. Rechte und öffentliche Freigaben zählen zur Verwaltung, auch wo sie an einer Seiten-URL hängen — eine Integration, die eine Seite bearbeiten darf, soll sie nicht nebenbei ins offene Netz stellen können.
Zwei Dinge kann ein Token nie:
- An die Zugangsverwaltung.
/tokensund/webhooksgehen nur mit Sitzung. Ein Token, das Token erzeugen kann, erzeugt ein weiteres — und das überlebt den Widerruf des ersten. - Den Workspace verlassen. Ein Token benennt einen Workspace; alles
außerhalb von
/api/v1/workspaces/{workspaceId}— einen Workspace anlegen, sie auflisten, eine Einladung annehmen, der öffentliche Freigabeleser — braucht eine Sitzung.
Routen in einem Bereich, den niemand eingeordnet hat, verweigern sich: Ein Token
bekommt 403, nie ein versehentliches Ja.
Diese Tabelle müssen Sie nicht mitführen. Jede Operation in der API-Referenz
nennt den Bereich, den sie braucht, und der maschinenlesbare Vertrag trägt
dieselbe Antwort als x-token-scope an jeder Operation — einen der vier
Bereiche oben oder session-only. Beides stammt aus derselben Einordnung, mit
der der Server Anfragen zulässt; ein Client kann also aus dem Vertrag ablesen,
wohin sein Token reicht, statt eine der Regeln oben nachzubauen.
Ablauf und Widerruf
Token laufen ab. Voreingestellt sind 90 Tage, das Höchstmaß sind 365; ein „läuft nie ab" gibt es bewusst nicht. Widerrufen Sie eines in den Einstellungen, oder lassen Sie es verfallen.
Ein Token, dessen Besitzer den Workspace verlässt, wird bei der nächsten Verwendung endgültig widerrufen — ein späterer Wiedereintritt holt es nicht zurück.
Anfragen und Antworten
Fehler
Jede Antwort unter /api, die nicht 2xx ist, hat denselben Körper:
{ "error": { "code": "not_found", "message": "page not found" } }
code ist eines von bad_request, validation_failed, unauthorized,
forbidden, not_found, conflict, rate_limited, internal. Verzweigen Sie
über code; message ist für Menschen und darf sich ändern.
Manche Ablehnungen tragen ein drittes Feld, reason. Es benennt, welche
Regel nein gesagt hat, wenn ein code mehrere Ursachen hat:
{
"error": {
"code": "forbidden",
"message": "this calendar is not switched on for writing",
"reason": "publishing_disabled"
}
}
Es ist eine kurze, stabile Kennung — nie ein Satz — und optional: Die meisten
Codes haben genau eine Ursache, und wer einen reason nicht kennt, fällt auf den
code zurück. Der Fall, für den es existiert, ist das Schreiben in einen
verbundenen Kalender: Drei seiner vier Ablehnungen sind forbidden und führen an
verschiedene Orte (Konto neu verbinden, einen Schalter umlegen, oder gar nichts).
404 erfüllt absichtlich zwei Aufgaben: Ein Workspace, in dem Sie nicht Mitglied
sind, ist von einem, den es nicht gibt, nicht zu unterscheiden.
Seitenweise Abfrage
Listen-Endpunkte arbeiten mit Cursorn:
{ "items": [ … ], "nextCursor": "eyJ…" }
Übergeben Sie ?cursor= aus der vorigen Antwort, um weiterzublättern;
nextCursor: null heißt, es kommt nichts mehr. ?limit= steht voreingestellt
auf 50 und ist bei 100 gedeckelt. Cursor sind undurchsichtig — zerlegen
Sie sie nicht.
Schreibende Anfragen von fremden Ursprüngen
Ändernde Anfragen und WebSocket-Upgrades, die einen Origin-Header tragen,
werden abgewiesen, sofern der Ursprung nicht der eigene Host der Anfrage oder
BASE_URL ist. Anfragen ohne Origin — curl, SDKs, Server — sind nicht
betroffen: Sie führen kein Cookie mit sich.
Ratenbegrenzung
Die Oberfläche unter /api/auth ist je Clientadresse begrenzt: ein Gesamtbudget
je Minute, dazu engere eigene Budgets auf den Zugangspfaden — /sign-in/email,
/sign-up/email, /sign-in/social, dem Paar zum Zurücksetzen des Passworts und
dem Paar zur Bestätigung der Adresse.
Der größte Teil von /api/v1 wird nicht gedrosselt. Drei Stellen aber schon:
- Linkvorschauen,
POST /api/v1/workspaces/{workspaceId}/unfurl: höchstens 20 neue Adressen je Minute und Konto. Darüber antwortet die Route mit429 rate_limited. Eine Adresse, die schon im Zwischenspeicher des Servers liegt, zählt nicht mit. - Warteliste,
POST /api/v1/waitlistund die beiden Routen zum Bestätigen und Abmelden: öffentlich, deshalb begrenzt je Clientadresse. Die Anmeldung zusätzlich je E-Mail-Adresse — so kann ein Client keine Adressen durchprobieren, und viele Clients können nicht auf eine einzelne einhämmern. Auch hier kommt429 rate_limited. - Kalender neu einlesen,
POST …/connected-accounts/{connectionId}/resyncundPOST …/calendar/publications/retry: kein Fehler. Innerhalb einer Minute nach dem letzten Versuch wird nichts eingereiht, und die Antwort trägtretryAfterSeconds. Warten Sie diese Sekunden ab, statt erneut zu drücken.
Behandeln Sie rate_limited. Der Code steht aus diesem Grund in der Liste oben.
Seiten als Markdown
Markdown ist der Vertrag, kein Exportformat. Jede Seite liest und schreibt sich als kanonisches Markdown:
# Lesen
curl "$BASE/api/v1/workspaces/$WS/pages/$PAGE?format=markdown" \
-H "Authorization: Bearer $TOKEN"
# Schreiben — ersetzt den Körper
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
Die Antwort ist text/markdown. Ein PUT läuft durch dasselbe kollaborative
Dokument wie der Editor: Wer die Seite offen hat, sieht Ihre Änderung eintreffen,
statt seine eigene Arbeit an sie zu verlieren.
Zwei Grenzen sind es wert, gekannt zu werden:
- Der Seitentitel steht nicht im Markdown. Er ist Struktur und wird über
PATCH /pages/{pageId}geändert. - Feldwerte schon, als YAML-Frontmatter, benannt nach dem Feldschlüssel. Sie gehen in beide Richtungen durch — mit einer Ausnahme: Relationen werden beim Lesen ausgegeben, beim Schreiben aber ignoriert. Maßgeblich ist die Relationstabelle, und die wird über die Relations-Endpunkte gepflegt.
Den genauen Dialekt — die kanonische Form jedes Blocks und die Garantien für den Rundlauf, die die Prüfstrecke erzwingt — beschreibt die Markdown-Spezifikation.
Echtzeit
Zwei WebSocket-Ebenen, beide unter /api/v1, beide mit Sitzungspflicht:
| Pfad | Trägt |
|---|---|
/api/v1/collab |
Yjs-Abgleich für die Seite, die Sie gerade bearbeiten, samt Anwesenheit. Hier lebt der Text des Dokuments; eine REST-Ressource ist das nicht. |
/api/v1/events |
resource.changed-Rahmen, damit ein Client Zwischenspeicher verwerfen kann, statt zu pollen. Rahmen, die Inhalte benennen, die ein Empfänger nicht lesen darf, werden herausgefiltert. |
Keine der beiden steht im OpenAPI-Dokument — sie sind keine Anfrage/Antwort-Oberflächen. Für Automatisierung ohne Polling nehmen Sie besser Webhooks.
Webhooks
Unter Einstellungen → Webhooks melden Sie einen Endpunkt für eine gewählte Menge
von Ressourcenarten an (page, database, db_row, collection, field,
db_view, permission, group, invitation, comment, workspace).
Zugestellt wird per POST mit einem JSON-Körper:
{
"id": "9e1c…",
"type": "resource.changed",
"workspaceId": "2aa7…",
"resource": "page",
"resourceId": "5387…",
"version": 12,
"occurredAt": "2026-07-26T13:18:29.164Z"
}
id bleibt über Wiederholungen hinweg gleich — entdoppeln Sie darüber.
Header:
| Header | Wert |
|---|---|
Basalt-Signature |
t=<Unix-Sekunden>,v1=<hex> |
Basalt-Delivery |
Die Zustellkennung, dieselbe wie id im Körper. |
Basalt-Event |
<resource>.changed. |
Basalt-Attempt |
Versuchsnummer, beginnend bei 1. |
Eine Zustellung prüfen
Die Signatur ist HMAC-SHA256(secret, "<timestamp>.<rawBody>"), hexkodiert.
Signieren Sie die rohen Körperbytes, nicht ein neu serialisiertes Objekt.
Der Zeitstempel steckt in der signierten Zeichenkette, eine mitgeschnittene
Anfrage lässt sich also nicht später mit frischem Header wiedereinspielen —
prüfen Sie ihr Alter selbst (fünf Minuten sind eine vernünftige Toleranz) und
vergleichen Sie die Prüfsummen in konstanter Zeit.
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);
}
Wie zugestellt wird
- Antworten Sie innerhalb von 10 Sekunden mit
2xx. Alles andere gilt als Fehlschlag. - Fehlschläge werden achtmal wiederholt, mit wachsendem Abstand: 10 s, 30 s, 2 min, 10 min, 30 min, 1 h, 2 h.
- Drei tote Zustellungen hintereinander schalten den Endpunkt ab. Aktivieren Sie ihn in den Einstellungen wieder, sobald Ihr Empfänger gesund ist.
- Ein Endpunkt, bei dem sich 1000 unzugestellte Zeilen ansammeln, beginnt Ereignisse zu verwerfen.
- Das Geheimnis erscheint einmal beim Anlegen und lässt sich wechseln.
Clients
packages/api-client ist ein typisierter TypeScript-Client, erzeugt aus
derselben Routentabelle, die der Server registriert, und er bringt das
OpenAPI-Dokument mit. Auf npm liegt er noch nicht.
Für alles andere erzeugen Sie sich einen Client aus
packages/api-client/openapi.json mit Ihrem eigenen Werkzeug. Das Dokument ist
bytegleich reproduzierbar und wird in der Prüfstrecke erzwungen — Sie können
sich darauf verlassen.
KI-Assistenten
Wenn Sie diese Seite lesen, weil ein Sprachmodell in Ihrem Workspace arbeiten soll: Die Markdown-Oberfläche weiter oben ist die ganze Geschichte. Seite lesen, Text ändern, zurückschreiben. Mehr braucht es nicht — keine Block-Kennungen, kein Baumlaufen, kein Format, das dem Assistenten erst beigebracht werden muss.
Wo ein Produkt zusätzlich einen Model-Context-Protocol-Server mitbringt, sagt seine eigene Dokumentation das — und wie Sie einen Assistenten darauf richten.