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

# Themes in the Livry Public API

> Create, read, rename and delete Themes, manage their framework and library integrations, and read the CDN URLs their published files are served at.

A Theme belongs to an Environment and holds the **default** token set. A vendor shipping more than
one product is not forced onto a shared base — an Environment can hold any number of Themes.

This page covers the Theme entity. Its tokens are a sub-resource with its own rules; see
[Theme tokens](/api/theme-tokens).

## Endpoints

| | | Needs |
| - | - | - |
| `GET` | `/public/v1/environments/{environmentID}/themes` | `viewer` |
| `GET` | `/public/v1/environments/{environmentID}/themes/{themeID}` | `viewer` |
| `POST` | `/public/v1/environments/{environmentID}/themes` | `editor` |
| `PUT` | `/public/v1/environments/{environmentID}/themes/{themeID}` | `editor` |
| `PUT` | `/public/v1/environments/{environmentID}/themes/{themeID}/integrations` | **`admin`** |
| `DELETE` | `/public/v1/environments/{environmentID}/themes/{themeID}` | `editor` |

## List Themes

<ParamField query="slug" type="string">
  Exact match. This is how you turn a slug you know into the `id` you address by.
</ParamField>

<ParamField query="orderDir" default="ascending" type="string" />

<ParamField query="pageNumber" default="1" type="integer" />

<ParamField query="pageSize" default="100" type="integer" />

<ParamField query="includeTotals" default="false" type="boolean" />

```bash theme={null}
curl "https://api.livry.dev/public/v1/environments/$ENV/themes?slug=acme-web" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

## Create a Theme

```http theme={null}
POST /public/v1/environments/{environmentID}/themes
```

<CodeGroup>
  ```json Request theme={null}
  {
    "slug": "acme-web",
    "displayName": "Acme Web",
    "integration": {
      "framework": "react",
      "library": "chakraUI"
    }
  }
  ```

  ```json Response 201 theme={null}
  {
    "resource": {
      "id": "kQ3mW9rP",
      "environmentID": "nR7xL2vB:production",
      "slug": "acme-web",
      "displayName": "Acme Web",
      "tokenCount": 0,
      "latestVersion": null,
      "publishedAt": null,
      "hasUnpublishedChanges": false,
      "integrations": [
        { "framework": "react", "library": "chakraUI" }
      ],
      "metadata": {},
      "serving": {
        "mode": "public",
        "signingKeys": [],
        "allowedOrigins": [],
        "missingOrigin": "allowNonBrowsers",
        "allowedIPRanges": []
      },
      "assetSettings": {
        "prefix": "",
        "isLive": false,
        "liveVersion": null
      },
      "servingUrls": {
        "latest": [
          {
            "file": "tokens.css",
            "url": "https://cdn.livry.dev/nR7xL2vB:production/kQ3mW9rP/-/latest/tokens.css"
          }
        ],
        "pinned": null,
        "version": null
      },
      "createdAt": "2026-09-18T04:21:07Z",
      "updatedAt": "2026-09-18T04:21:07Z"
    }
  }
  ```
</CodeGroup>

`integration` is a single pair, and it is optional. A Theme created without one **serves nothing**
until you add one.

<Note>
  There is no `Location` header on the `201`. Building one means knowing the route template, which
  lives on the endpoint rather than in the code that writes the response, and a wrong `Location` is
  worse than none. The created resource's `id` is in the body.
</Note>

## Update a Theme

```http theme={null}
PUT /public/v1/environments/{environmentID}/themes/{themeID}
```

Renames the Theme and sets its labels. **It cannot touch tokens or integrations** — those are
different surfaces with different access levels.

<CodeGroup>
  ```json Rename only theme={null}
  {
    "slug": "acme-web",
    "displayName": "Acme Web (legacy)"
  }
  ```

  ```json Rename and relabel theme={null}
  {
    "slug": "acme-web",
    "displayName": "Acme Web",
    "metadata": {
      "owner": "platform-team",
      "acme.io/cost-centre": "CC-4417"
    }
  }
  ```

  ```json Clear the labels theme={null}
  {
    "slug": "acme-web",
    "displayName": "Acme Web",
    "metadata": {}
  }
  ```
</CodeGroup>

<Warning>
  **`metadata` is the one field on this `PUT` that is not a whole-resource replace.**

  Omit it and the stored labels are left alone. Send `{}` and they are cleared. That is deliberate:
  a client renaming a Theme should not have to re-send labels it never read.

  Everything else on this request *is* a replace. `slug` and `displayName` are both required.
</Warning>

### Label rules

| Rule | Bound |
| - | - |
| Keys per resource | 50 |
| Key length | 64 |
| Value length | 256 |
| Key alphabet | `A–Z a–z 0–9 . _ : / -`, starting with a letter or digit |

An empty value is allowed — a key on its own is a flag, and refusing it only makes people invent
`"true"`.

## Change what a Theme is served for

```http theme={null}
PUT /public/v1/environments/{environmentID}/themes/{themeID}/integrations
```

<CodeGroup>
  ```json Request theme={null}
  {
    "integrations": [
      { "framework": "react", "library": "chakraUI" },
      { "framework": "nextJS", "library": "buildTimeTokens" }
    ]
  }
  ```

  ```bash curl theme={null}
  curl -X PUT "$BASE/environments/$ENV/themes/$THEME/integrations" \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"integrations":[{"framework":"react","library":"chakraUI"}]}'
  ```
</CodeGroup>

Returns the whole Theme, `200`.

**The body is the whole list, never an add or a remove.** To add a pair, read the Theme, append, and
send the result. Two administrators editing at once are settled by the write itself; a merge would
have to guess.

The server refuses an empty list, a duplicate pair, and a pair the catalogue does not offer — all
`400`. A Theme with no integrations serves nothing, so emptying the list is **not** how you turn
serving off. (Set the serving mode in the portal for that.)

<Info>
  **This is `admin`, where renaming is `editor`.** The pairs decide which files the publish pipeline
  builds for every published version and every Variant of the Theme, which is administration of what
  the Environment serves rather than authoring in it.
</Info>

### What each pair makes the Theme serve

The response's `servingUrls` lists exactly the files the current integrations require, so you never
see a URL that answers `404` by design.

| Library | Serves |
| - | - |
| `plainCSS`, `tailwindCSS`, `chakraUI`, `shadcnUI`, `material`, `skeleton` | `tokens.css` |
| `mui`, `vuetify`, `primeVue`, `primeNg` | `tokens.values.json` |
| `buildTimeTokens` | `tokens.dtcg.json`, `tokens.json`, `tokens.flat.json` |

| Framework | Libraries it accepts |
| - | - |
| `react`, `nextJS` | universal + `chakraUI`, `mui`, `shadcnUI` |
| `vue`, `nuxt` | universal + `vuetify`, `primeVue` |
| `angular` | universal + `material`, `primeNg` |
| `svelte` | universal + `skeleton` |
| `solid`, `qwik`, `astro`, `alpine` | universal only |

"Universal" is `plainCSS`, `tailwindCSS` and `buildTimeTokens`.

<Note>
  Changing the list takes a few seconds to reach the edge. Files a removed pair alone needed are
  deleted from every published version, and files an added pair needs are written — asynchronously,
  after the response returns. The `servingUrls` on the response already list the new set, so polling
  those URLs is the right way to wait.
</Note>

## Delete a Theme

```http theme={null}
DELETE /public/v1/environments/{environmentID}/themes/{themeID}
```

Deletes the Theme, **every Variant beneath it**, and every version of both. Answers `200` with the
parent Environment — not a `204` — because your next question is what the Environment holds now.

<Warning>
  This is immediate and irreversible. Published versions are immutable *until the Theme is deleted*;
  deletion removes them. Anything reading its serving URLs starts getting `404`.
</Warning>

## The Theme resource

<ResponseField name="id" type="string" required />

<ResponseField name="environmentID" type="string" required />

<ResponseField name="slug" type="string" required />

<ResponseField name="displayName" type="string" required />

<ResponseField name="tokenCount" type="integer" required>
  Tokens in the current document — the draft if there is one, otherwise the newest published version.
</ResponseField>

<ResponseField name="latestVersion" type="integer | null">
  The newest published version number, or `null` if nothing has been published.
</ResponseField>

<ResponseField name="publishedAt" type="string | null">
  When that version was published. `null` alongside a null `latestVersion`.
</ResponseField>

<ResponseField name="hasUnpublishedChanges" type="boolean" required>
  Whether a draft is standing on top of the published version. "Is there a draft" and "are there
  unpublished changes" are the same question — an edit that reverted itself leaves nothing behind.
</ResponseField>

<ResponseField name="integrations" type="array" required>
  The framework and library pairs. See above.
</ResponseField>

<ResponseField name="metadata" type="object" required>
  Your labels. Never a secret.
</ResponseField>

<ResponseField name="serving" type="object" required>
  How the edge treats requests for this Theme. **Read-only on this API.**

  <Expandable title="fields">
    <ResponseField name="mode" type="string">
      `public` or `signed`. A new Theme is `public` — the fastest integration is a plain fetch.
    </ResponseField>

    <ResponseField name="signingKeys" type="string[]">
      The **key ids** of the Theme's signing keys. The secrets themselves are never returned by any
      API, at any access level.
    </ResponseField>

    <ResponseField name="allowedOrigins" type="string[]">
      The browser origin allowlist. Empty means no allowlist.
    </ResponseField>

    <ResponseField name="missingOrigin" type="string">
      What happens to a request that names no origin at all: `allowNonBrowsers` (the default —
      refused if it looks like a browser, allowed otherwise), `refuse`, or `allow`. Ignored when
      there is no allowlist. See [Access control](/serving/access-control).
    </ResponseField>

    <ResponseField name="allowedIPRanges" type="string[]">
      CIDR ranges. Empty means no restriction.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="assetSettings" type="object" required>
  What the served assets were built with. **Live, not draft** — a publish is the only thing that
  writes them.

  <Expandable title="fields">
    <ResponseField name="prefix" type="string">
      The prefix every custom property in `tokens.css` carries: the names in the served file are
      `--{prefix}-{path}`. Empty when there is none. If you generate CSS that references these
      properties, this is the field you need.
    </ResponseField>

    <ResponseField name="isLive" type="boolean">
      Whether CSS settings have ever been published. An empty prefix published on purpose is live;
      the same empty prefix by default is not — which is why this is a flag rather than something you
      could infer from `prefix`.
    </ResponseField>

    <ResponseField name="liveVersion" type="integer | null">
      The Theme version the live CSS settings were published in.
    </ResponseField>
  </Expandable>

  Publishing asset settings is not on this API yet — it creates a Theme version, which makes it a
  publish rather than a settings write.
</ResponseField>

<ResponseField name="servingUrls" type="object" required>
  Where this Theme's own files — no Variant applied — are served.

  <Expandable title="fields">
    <ResponseField name="latest" type="array">
      `{ file, url }` for each served file, at whatever is published most recently. **Present before
      the first publish**, where it answers `404`: the address is a property of the Theme, not of a
      version, so you can configure your app before your first deploy.
    </ResponseField>

    <ResponseField name="pinned" type="array | null">
      The same files frozen at `version`. `null` until something has been published. A pinned URL
      never changes content.
    </ResponseField>

    <ResponseField name="version" type="integer | null">
      The version `pinned` points at.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="createdAt" type="string" required />

<ResponseField name="updatedAt" type="string" required />


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.