Skip to main content
The astilba server exposes a JSON HTTP API under /api. The CLI and the dashboard are both just clients of it — anything they do, you can do with an HTTP request.
The server is Alpha and its HTTP surface may change before 1.0. Base URL is wherever you host it (e.g. https://astilba.example.com); paths below are relative to it.

Authentication

Every data endpoint is scoped to one organization, and the org is resolved from a server-verified credential — never from the request body or a query parameter. There are two credentials:
  • Session cookie — set by Better Auth on sign-in; used by the dashboard. The org is the session’s active organization.
  • x-api-key header — an org API token; used by the CLI and any programmatic client. The org is the token’s owning organization.
Most endpoints accept either credential. The exceptions are the /api/tokens management endpoints, which require a session cookie (you manage tokens from the dashboard, not with a token).
StatusMeaning
401No valid credential (missing/expired session, unknown token)
403Authenticated, but the role isn’t allowed (token management by a non-admin)
404Project/token not found or in another org — the two are indistinguishable, so cross-org probing can’t confirm existence

Projects

MethodPathAuthPurpose
GET/api/projectseitherList the org’s projects
POST/api/projectseitherCreate a project
GET/api/projects/:slugeitherFetch a project’s manifest
GET /api/projects returns { "projects": [{ "slug", "name", "sourceLanguage", "defaultFormat" }] }. POST /api/projects takes { "name", "slug", "sourceLanguage", "defaultFormat"? }. slug must be a URL-safe segment and sourceLanguage a BCP-47-ish tag; defaultFormat is i18next-json (nested keys, the default) or i18next-json-flat (literal dots in keys). Returns 201 with the created project.
StatusMeaning
201Created
400Invalid slug, name, sourceLanguage, or defaultFormat
409A project with that slug already exists in the org
413Request body too large
GET /api/projects/:slug returns the manifest — the project’s config plus the languages and namespaces a client enumerates to fetch each bundle:
{
  "defaultFormat": "i18next-json",
  "sourceLanguage": "en",
  "languages": ["en", "fr", "de"],
  "namespaces": ["common", "home"]
}
The manifest intentionally has no name or slug — the slug is the path parameter the caller already holds. Don’t expect the list-row shape here.

Bundles

A bundle is one language × one namespace. Reads and writes are all under /api/projects/:slug/bundles/… and accept either credential.
MethodPathPurpose
GET/…/bundles/:langThe whole language as the canonical stored model
GET/…/bundles/:lang/:nsOne namespace, rendered to the i18next resource shape
POST/…/bundles/:lang/:nssaveMissing — register missing keys (fill-only)
PUT/…/bundles/:lang/:nsPush a whole namespace
PUT/…/bundles/:lang/:ns/:keyEdit one simple value (overwrite)
GET /…/bundles/:lang returns the canonical representation for every namespace: { "language", "keys": [{ "key", "namespace", "message" }] }. This is the stored model, not i18next-rendered output. GET /…/bundles/:lang/:ns renders one namespace to a real i18next resource object (plurals and contexts exploded to CLDR-suffixed keys). It carries a weak ETag; send it back as If-None-Match and an unchanged bundle returns 304 Not Modified with no body. An unknown namespace returns 200 {} (i18next treats a missing-namespace load as a hard failure, so an empty object is the safe answer). POST /…/bundles/:lang/:ns is the i18next saveMissing sink: the body is one namespace’s i18next resource fragment, and each key is registered only if absent — a dev fallback never overwrites a real translation. PUT /…/bundles/:lang/:ns uploads a whole namespace (what astilba push does). The server applies the ownership policy: the source language overwrites, every other language fills only its gaps. Add ?dryRun=true to get the added/updated counts without writing. PUT /…/bundles/:lang/:ns/:key overwrites one value (what the dashboard’s inline editor does). It only accepts a simple string value: a plural or context key is rejected, both on the incoming value and by re-reading the stored message, so a scalar can’t silently clobber a plural’s variants.
StatusMeaning
200Read/edit succeeded (or 304 for an unchanged If-None-Match)
400Invalid path segment or an unsafe/reserved namespace name
404Project, language, or (for the whole-language read) namespace not in the org
409A language or namespace name collides case-insensitively with an existing one
422Unprocessable — ICU / non-native-i18next-v4 input, or a plural/context key where a simple value was required

Tokens

Org API tokens are managed with a session cookie — you can’t manage tokens with a token. Listing is open to any member; minting and revoking are owner/admin only. See Orgs & API tokens for the role model.
MethodPathRolePurpose
GET/api/tokensany memberList the org’s tokens (safe metadata only — never the secret)
POST/api/tokensowner/adminMint a token; the plaintext is returned once
DELETE/api/tokens/:idowner/adminRevoke a token
POST /api/tokens returns the plaintext key exactly once (Cache-Control: no-store); only its hash is stored, so it can never be shown again. A successful DELETE returns 204 No Content; deleting an unknown or another org’s token returns 404, and a member without the role gets 403.

Auth

Better Auth is mounted at /api/auth/* (sign-in/out, session, organization, and member management). Its own API-key routes (/api/auth/api-key/*) are explicitly blocked (404) — token management goes through the curated /api/tokens endpoints above so the org access-control rules always apply.

Conventions

  • Errors are uniform JSON ({ "error": "…" }) and never leak internals or stack traces.
  • Org-scoped reads set Cache-Control: private, no-cache and Vary: Cookie, X-API-Key, so a shared cache never serves one org’s bundle to another.
  • Write routes enforce a request body-size limit and return 413 when exceeded.