# HTTP API Two surfaces with different postures: | | `/api/v1` | `/rt/v1` | | --- | --- | --- | | Audience | Administrators | Applications and browsers | | Authentication | Required | None, by design | | Caching | `no-store` | Aggressive, immutable by URL | | Audit | Every mutation | No | | Rate limit | Generous | Applied | A theming service that required a credential could not be consumed from a `` tag, which is the primary use case. --- ## Conventions - All bodies are JSON. One shape, everywhere. - Errors: `{ code, message, details?, requestId? }`. `code` is a stable machine-readable identifier and is part of the contract. - `requestId` is on every error and in `X-Request-ID` on every response. - Timestamps are RFC 3339 in UTC. - Every mutable request accepts `baseVersion` for optimistic concurrency. | Code | Status | Meaning | | --- | --- | --- | | `bad_request` | 400 | Malformed | | `validation_failed` | 400 | Invalid, with per-field `details` | | `unauthorized` | 401 | No usable credential | | `forbidden` | 403 | Insufficient role | | `not_found` | 404 | Missing, or deliberately not disclosed | | `conflict` | 409 | Uniqueness, or an optimistic-concurrency conflict | | `immutable` | 409 | A built-in theme cannot be deleted | | `rate_limited` | 429 | With `Retry-After` | | `internal_error` | 500 | Unexpected | --- ## Runtime API ### `GET /rt/v1/theme.css` A theme's stylesheet. | Parameter | Required | Default | Purpose | | --- | --- | --- | --- | | `slug` | Yes | — | The theme | | `appearance` | No | The theme's default | `light` or `dark` | | `variant` | No | `full` | `full`, `tokens`, `minified` | | `v` | No | Latest | Pin a version | ```bash curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight' curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&appearance=dark' curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&variant=tokens' curl 'https://themes.example.com/rt/v1/theme.css?slug=midnight&v=7' ``` ``` HTTP/1.1 200 OK Content-Type: text/css; charset=utf-8 Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400 ETag: "a1f4b5833f60d17d42328a90213b20f6" X-Theme-Version: 7 X-Theme-Updated-At: 2026-10-04T09:14:27Z Vary: Accept-Encoding ``` A draft theme returns **404**, not 403, so a probe cannot confirm it exists. ### `GET /rt/v1/theme.json` Resolved tokens. ```json { "theme": { "slug": "midnight", "name": "Midnight", "version": 7, "appearance": "dark", "defaultAppearance": "dark", "updatedAt": "2026-10-04T09:14:27Z" }, "variables": { "--bn-color-semantic-primary": "#7c6bf5", "--bn-color-on-primary": "#0f0d1f", "--bn-radius-lg": "0.75rem", "--bn-space-4": "1rem" }, "tokens": { "color": { "semantic": { "primary": { "$type": "color", "$value": "#7c6bf5" } } } }, "meta": { "tags": ["dark", "violet"] } } ``` `variables` is a flat map for anything reading values. `tokens` is the nested DTCG document for mapping onto component props. ### `GET /rt/v1/manifest.json` The discovery document for building a picker. Deliberately small: no token data. ```json { "apiVersion": "1", "default": "midnight", "baseUrl": "https://themes.example.com/rt/v1", "themes": [ { "slug": "midnight", "name": "Midnight", "description": "Violet-accented dark interface.", "defaultAppearance": "dark", "version": 7, "tags": ["dark", "violet"], "css": "https://themes.example.com/rt/v1/theme.css?slug=midnight", "tokens": "https://themes.example.com/rt/v1/theme.json?slug=midnight" } ] } ``` ### `GET /rt/v1/embed` A ready-to-paste snippet. `?target=html` (the default, a full preview page), `react`, or `javascript`. ### SDK bundles `GET /rt/v1/runtime.js`, `/react.js`, `/global.js`, `/embed.js`. Immutable, served with a one-year `Cache-Control`, and CORS-open, since they are public assets loaded cross-origin. ### Path-addressable forms `/rt/v1/themes/{slug}/theme.css`, `/rt/v1/themes/{slug}/theme.json`, `/rt/v1/themes/{slug}/embed`. For consumers that prefer REST shapes or a CDN configured with path patterns rather than query strings. --- ## Administration API Requires a session cookie. Every mutation is audited. There is no machine credential: API keys were removed. The runtime API is public by design, and the administration API is for people, so a bearer token is not a credential here and a request carrying one is treated as unauthenticated. ### Auth | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/api/v1/auth/me` | The current session. Always 200 | | `GET` | `/api/v1/auth/config` | Client-visible configuration | | `GET` | `/auth/login` | Start the OIDC flow | | `GET` | `/auth/callback` | Complete it | | `GET`/`POST` | `/auth/logout` | End the session and the provider session | `/auth/me` always answers 200 with `authenticated: false` when there is no session, because "not logged in yet" is the normal state on first load and not an error the UI should handle as one. ```json { "authenticated": true, "mode": "oidc", "identity": { "subject": "a1b2-c3d4", "email": "designer@example.com", "name": "A Designer", "roles": ["editor"], "method": "oidc" }, "permissions": { "read": true, "write": true, "publish": true, "delete": false, "manageKeys": false, "manageUsers": false, "manageSettings": false }, "preferences": { "selectedTheme": "midnight", "selectedAppearance": "dark" } } ``` `preferences` is present only when `authenticated` is true. It rides along with the session so the UI can preselect a theme from the same round trip that establishes who the caller is, rather than making a second request before it can paint. ### Preferences Per-account interface preferences: which published theme the person has chosen, and which of its two colour schemes to render. Any authenticated role may read and write these, because a preference only affects what that one person sees and is keyed to their own subject. | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/api/v1/me/preferences` | The caller's preference, or empty when none is stored | | `PUT` | `/api/v1/me/preferences` | Record a preference and echo back what was stored | | `DELETE` | `/api/v1/me/preferences` | Forget it, returning the interface defaults | ```http PUT /api/v1/me/preferences {"selectedTheme": "midnight", "selectedAppearance": "dark"} ``` ```json {"selectedTheme": "midnight", "selectedAppearance": "dark"} ``` Both fields empty means nothing has been chosen, and that is a valid state rather than a missing one: the client applies its own default. An empty `selectedTheme` clears the choice, and a `selectedAppearance` sent with it is dropped rather than stored, because a scheme has nothing to select from. `selectedTheme` names a **published** theme rather than copying it, so editing or unpublishing that theme shows through everywhere without touching a stored value. Two consequences are worth knowing: - Saving a draft theme slug is a 400. Drafts are invisible to runtime consumers, so a preference pointing at one would leave the UI rendering a theme it cannot load. - A preference naming a theme that has since been deleted or archived **reads back as no choice**, so a stale value cannot pin the interface to something that no longer exists. The row is left alone; only the read is filtered. The response is authoritative and should be applied in place of the request. It is the only way a client learns that its value was filtered or cleared. Preferences are deliberately **not** claims in the session token. The token is issued by the identity provider and this service does not control it, so a choice made here has to be read back from this API. They are also stored per account rather than per browser, so they survive a re-login (`UpsertUser` rewrites the `users` projection on every login) and follow the person to another device. ### Themes | Method | Path | Role | | --- | --- | --- | | `GET` | `/api/v1/themes` | viewer | | `GET` | `/api/v1/themes/{slug}` | viewer | | `GET` | `/api/v1/themes/{slug}/export` | viewer | | `POST` | `/api/v1/themes` | editor | | `PATCH` | `/api/v1/themes/{slug}` | editor | | `POST` | `/api/v1/themes/{slug}/publish` | editor | | `POST` | `/api/v1/themes/{slug}/archive` | editor | | `POST` | `/api/v1/themes/{slug}/duplicate` | editor | | `POST` | `/api/v1/themes/import` | editor | | `DELETE` | `/api/v1/themes/{slug}` | admin | `GET /api/v1/themes` takes `q`, `status`, `system`, `tag`, `limit`, `offset`, `sort` (`updated`, `created`, `name`, `slug`) and `order`. An unrecognised `sort` falls back to the default rather than reaching SQL. Listings omit token documents. They are the bulk of each row and a listing never needs them. ```bash curl -X POST https://themes.example.com/api/v1/themes \ -H 'Content-Type: application/json' \ -b cookies.txt \ -d '{ "name": "Acme Product", "slug": "acme", "description": "The default theme for customer-facing applications", "status": "draft", "tokens": { "color": { "semantic": { "primary": { "$type": "color", "$value": "#4f46e5" } } } } }' ``` ```bash curl -X PATCH https://themes.example.com/api/v1/themes/acme \ -H 'Content-Type: application/json' -b cookies.txt \ -d '{ "tokens": { … }, "baseVersion": 3, "message": "rebalanced the neutrals" }' ``` `baseVersion` gives optimistic concurrency. If the theme has moved on, the write is rejected with 409 and a message naming both versions, rather than silently overwriting someone else's work. Publishing validates first: ```json { "code": "validation_failed", "message": "the theme cannot be published because its tokens are invalid", "details": [ { "field": "color.semantic.primary", "message": "not a valid colour: \"#nothex\"" } ] } ``` ### History | Method | Path | Role | | --- | --- | --- | | `GET` | `/api/v1/themes/{slug}/versions` | viewer | | `GET` | `/api/v1/themes/{slug}/versions/{version}` | viewer | | `GET` | `/api/v1/themes/{slug}/diff?from=1&to=2` | viewer | | `POST` | `/api/v1/themes/{slug}/versions/{version}/rollback` | editor | Rollback **appends**: the snapshot becomes the current state as a new version. History is never rewritten, so an application deployed against version 7 can never have version 7 taken away. ```json { "fromVersion": 6, "toVersion": 7, "added": ["color.semantic.tertiary"], "removed": [], "changed": [ { "path": "color.semantic.primary", "from": "#4f46e5", "to": "#7c6bf5" } ], "metaChanged": false } ``` ### Token tooling Read-only analysis of a submitted document. Any authenticated user may call these. | Method | Path | Purpose | | --- | --- | --- | | `POST` | `/api/v1/tokens/validate` | Validate without saving | | `GET` | `/api/v1/tokens/schema` | The group and kind registry | | `GET` | `/api/v1/tokens/palette?hue=&saturation=&lightness=&hueSpread=` | Generate a ramp | | `GET` | `/api/v1/tokens/contrast?fg=…&bg=…` | WCAG contrast | ```json { "valid": false, "tokenCount": 214, "issues": [ { "path": "color.semantic.primary", "severity": "error", "message": "not a valid colour: \"#nothex\"" }, { "path": "$appearances", "severity": "warning", "message": "no dark appearance overrides are defined", "hint": "add {\"$appearances\":{\"dark\":{…}}}" } ] } ``` ```json { "foreground": "#7c6bf5", "background": "#1a1d23", "ratio": 6.42, "rounded": 6.42, "level": "aa", "thresholds": { "aaLarge": 3, "aa": 4.5, "aaa": 7 }, "bestForeground": "#ffffff" } ``` ### Administration | Method | Path | Role | | --- | --- | --- | | `GET` | `/api/v1/stats` | viewer | | `GET` | `/api/v1/admin/users` | admin | | `GET` | `/api/v1/admin/audit` | admin | | `GET` | `/api/v1/admin/settings` | admin | | `PUT` | `/api/v1/admin/settings` | admin | | `POST` | `/api/v1/admin/cache/rebuild` | admin | Creating a key returns the plaintext **once**: ```json { "key": { "id": "key_x7f2", "name": "CI publish", "prefix": "gb_a1b2c3d4", "scopes": ["themes:write"] }, "secret": "gb_a1b2c3d4_…", "warning": "This secret is shown once and cannot be recovered. Store it now." } ``` --- ## Operational endpoints | Path | Purpose | | --- | --- | | `/livez`, `/healthz` | Liveness, with build information on `/healthz` | | `/readyz` | Readiness. Checks the database | | `/metrics` | Prometheus, when `config.enableMetrics` | ```json { "status": "ready", "checks": { "database": { "status": "ok" }, "themes": { "status": "ok", "count": 42, "databaseBytes": 212992 }, "cache": { "status": "ok", "entries": 24 } } } ```