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).
| Status | Meaning |
|---|
401 | No valid credential (missing/expired session, unknown token) |
403 | Authenticated, but the role isn’t allowed (token management by a non-admin) |
404 | Project/token not found or in another org — the two are indistinguishable, so cross-org probing can’t confirm existence |
Projects
| Method | Path | Auth | Purpose |
|---|
GET | /api/projects | either | List the org’s projects |
POST | /api/projects | either | Create a project |
GET | /api/projects/:slug | either | Fetch 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.
| Status | Meaning |
|---|
201 | Created |
400 | Invalid slug, name, sourceLanguage, or defaultFormat |
409 | A project with that slug already exists in the org |
413 | Request 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.
| Method | Path | Purpose |
|---|
GET | /…/bundles/:lang | The whole language as the canonical stored model |
GET | /…/bundles/:lang/:ns | One namespace, rendered to the i18next resource shape |
POST | /…/bundles/:lang/:ns | saveMissing — register missing keys (fill-only) |
PUT | /…/bundles/:lang/:ns | Push a whole namespace |
PUT | /…/bundles/:lang/:ns/:key | Edit 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.
| Status | Meaning |
|---|
200 | Read/edit succeeded (or 304 for an unchanged If-None-Match) |
400 | Invalid path segment or an unsafe/reserved namespace name |
404 | Project, language, or (for the whole-language read) namespace not in the org |
409 | A language or namespace name collides case-insensitively with an existing one |
422 | Unprocessable — 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.
| Method | Path | Role | Purpose |
|---|
GET | /api/tokens | any member | List the org’s tokens (safe metadata only — never the secret) |
POST | /api/tokens | owner/admin | Mint a token; the plaintext is returned once |
DELETE | /api/tokens/:id | owner/admin | Revoke 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.