Tecto docs
tectoapp.io

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. /tokens und /webhooks gehen 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 mit 429 rate_limited. Eine Adresse, die schon im Zwischenspeicher des Servers liegt, zählt nicht mit.
  • Warteliste, POST /api/v1/waitlist und 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 kommt 429 rate_limited.
  • Kalender neu einlesen, POST …/connected-accounts/{connectionId}/resync und POST …/calendar/publications/retry: kein Fehler. Innerhalb einer Minute nach dem letzten Versuch wird nichts eingereiht, und die Antwort trägt retryAfterSeconds. 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.