Tecto docs
tectoapp.io

API

There is one API, and the app you use is a client of it. There are no private fast paths that let the public surface rot: 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.

The basalt_pat_ prefix and the Basalt-* headers further down are the names the wire actually carries. They are the platform's, not any one product's, and they are spelled here the way your client will receive them — a prettier name in the documentation would be a string comparison that fails at runtime.

The workspace id

$WS above is not a placeholder you can skip: every path under /api/v1/workspaces/{workspaceId} names the workspace, and a token is scoped to exactly one. So the id is the other half of what a script needs, and Settings → Tokens shows it, copyable, in the same panel as the new secret — copy both at once.

GET /api/v1/workspaces also lists the workspaces you can reach, with their ids. But that route needs a session: a token cannot leave its workspace (see below), so it cannot ask which workspaces exist — a script that only has a token cannot discover the id it is missing.

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.

You do not have to keep this table. Every operation in the API reference names the scope it needs, and the machine-readable contract carries the same answer as x-token-scope on each operation — one of the four scopes above, or session-only. Both come from the single classifier the server admits requests with, so a client can work out what its token reaches by reading the contract instead of reproducing any of the rules above.

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.

Some refusals carry a third field, reason, naming which rule said no where one code has several:

{
  "error": {
    "code": "forbidden",
    "message": "this calendar is not switched on for writing",
    "reason": "publishing_disabled"
  }
}

It is a short, stable identifier — never a sentence — and it is optional: most codes have one cause, and a client that does not recognise a reason falls back to the code. Writing to a connected calendar is the case it exists for: three of its four refusals are forbidden, and they send you somewhere different (reconnect the account, flip a switch, or nothing you can do at all).

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

The /api/auth surface is rate limited per client address: a blanket budget a minute, with tighter budgets of its own on the credential paths — /sign-in/email, /sign-up/email, /sign-in/social, the password-reset pair and the verification pair.

Most of /api/v1 is not throttled. Three places are, and all three answer rate_limited (429) except where noted:

  • Link previews. POST /api/v1/workspaces/{workspaceId}/unfurl fetches at most 20 new URLs a minute per account. A URL already in the server's cache does not count against it.
  • The waitlist routes. /api/v1/waitlist, /waitlist/confirm and /waitlist/unsubscribe are public, so they are budgeted per client address — and signing up is budgeted per e-mail address as well, so one client cannot enumerate addresses and many clients cannot hammer one.
  • Two calendar buttons, which answer differently: POST …/connected-accounts/{connectionId}/resync and POST …/calendar/publications/retry each carry a one-minute cooldown, and a refusal is a 200 carrying retryAfterSeconds rather than an error — the second look is refused, not queued, so a button can say when instead of only that it will not.

Handle the rate_limited code; it is in the list above for a reason.

Pages as Markdown

Markdown is the contract, not an export format. 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. Nothing else is needed: no block ids, no tree walking, no format the agent has to be taught.

Where a product also ships a Model Context Protocol server, its own documentation says so and how to point an agent at it.