> ## Documentation Index
> Fetch the complete documentation index at: https://docs.astilba.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Server HTTP API

> The astilba server's HTTP API: every endpoint, how it is authenticated, and its status codes. The CLI and dashboard are both clients of this API.

The astilba server exposes a JSON HTTP API under `/api`. The [CLI](/reference/cli) and the
[dashboard](/guides/dashboard) are both just clients of it — anything they do, you can do with
an HTTP request.

<Warning>
  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.
</Warning>

## 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](/guides/orgs-and-tokens); 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:

```json theme={null}
{
  "defaultFormat": "i18next-json",
  "sourceLanguage": "en",
  "languages": ["en", "fr", "de"],
  "namespaces": ["common", "home"]
}
```

<Warning>
  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.
</Warning>

## 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 value or plural form (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). The `:key` may be a plain value, a context key (`friend_male` — an ordinary key), or a
single plural **form** (`item_one`, whose CLDR suffix peels to base `item`): a form is merged
onto the stored plural, keeping its other forms. It refuses (`422`) a scalar aimed at a plural's
base key — that would drop the other forms — and a plural form aimed at a non-plural (or
wrong-kind) target.

| 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 value that doesn't fit its key (a scalar on a plural's base key, or a plural form on a non-plural target) |

## 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](/guides/orgs-and-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.
