13 KiB
Imported from docs/api.md in the go-bananas repository.
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
<link> tag, which is the primary use case.
Conventions
- All bodies are JSON. One shape, everywhere.
- Errors:
{ code, message, details?, requestId? }.codeis a stable machine-readable identifier and is part of the contract. requestIdis on every error and inX-Request-IDon every response.- Timestamps are RFC 3339 in UTC.
- Every mutable request accepts
baseVersionfor 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 |
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.
{
"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.
{
"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.
{
"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 |
PUT /api/v1/me/preferences
{"selectedTheme": "midnight", "selectedAppearance": "dark"}
{"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.
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" } } } }
}'
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:
{
"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.
{
"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 |
{
"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\":{…}}}" }
]
}
{
"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:
{
"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 |
{
"status": "ready",
"checks": {
"database": { "status": "ok" },
"themes": { "status": "ok", "count": 42, "databaseBytes": 212992 },
"cache": { "status": "ok", "entries": 24 }
}
}