ℹ️ JSON API: Themes

The Themes API lets apps browse a blog’s themes and installed plug-ins, read template files, create, edit, or delete text templates, and read or replace custom CSS. All endpoints start with https://micro.blog/themes. Changes use the same models and publishing methods as the web editor.

This first version does not install, activate, upgrade, create, or delete themes or plug-ins. It also does not upload binary assets or change other blog settings. A custom theme must already exist before an app can add template overrides to it; custom CSS does not require a custom theme.

Authentication and blog selection

Send a Micro.blog access token in Authorization: Bearer TOKEN. IndieAuth apps should request read to browse templates and CSS, and write to change them. Post-specific scopes such as create, update, and delete do not grant theme editing access. Existing app tokens with full access work too.

Theme management is restricted to the owner, matching the web theme editor. Membership in a team blog does not grant access to its owner’s themes. Unknown IDs and IDs belonging to another owner return HTTP 404.

Discovery, built-in template, and CSS endpoints accept an optional site_id query parameter. Omit it to use the user’s default blog. The selected blog must belong to the authenticated user; otherwise the response is 404. Apps can get blog IDs from microblog-site-id in each destination returned by GET /micropub?q=config.

Endpoints containing a numeric theme ID operate on that owned theme directly. They do not use site_id to redirect an edit to another theme.

Cross-origin requests with bearer tokens and OPTIONS preflights are supported. Cookie-authenticated writes must pass the normal same-origin CSRF checks. Responses are not cached.

Endpoints

Method Path Purpose
GET /themes?site_id=123 List custom themes and the blog’s installed plug-ins.
GET /themes/css?site_id=123 Read the blog’s custom CSS.
POST /themes/css?site_id=123 Replace the blog’s custom CSS.
GET /themes/:theme_id Read one owned theme or plug-in’s metadata.
GET /themes/:theme_id/templates List that theme’s files without their contents.
GET /themes/:theme_id/templates/:id Read a text template.
POST /themes/:theme_id/templates Add a text template.
POST /themes/:theme_id/templates/:id Edit or rename a text template.
DELETE /themes/:theme_id/templates/:id Delete a stored template.
GET /themes/defaults/templates?site_id=123 List the blog’s built-in fallback templates.
GET /themes/defaults/template?site_id=123&path=layouts%2Findex.html Read a built-in text template by path.

Discover themes and plug-ins

GET /themes returns three fields:

  • site: id, url, root_theme_id, and built_in_theme.
  • themes: the owner’s custom themes, including themes that are not selected for this blog. The selected root theme is included even if it is also a plug-in.
  • plugins: the selected blog’s installed plug-ins, including disabled plug-ins and plug-ins that provide a full theme.

site.root_theme_id is the blog’s site.theme_id, or null when there is no accessible root theme. This is distinct from the installed plug-ins. The root theme can provide custom overrides on top of them. site.built_in_theme is the configured built-in theme name, such as blank or marfa; an enabled full-theme plug-in can replace that base.

Each theme or plug-in has this shape:

{
  "id": 42,
  "site_id": null,
  "title": "My Custom Theme",
  "description": "",
  "version": "",
  "author_username": "",
  "github_url": "",
  "engine": "hugo",
  "is_plugin": false,
  "is_disabled": false,
  "is_card": false,
  "updated_at": "2026-10-28T10:00:00+00:00"
}

Theme IDs are also used for installed plug-ins. site_id identifies an associated blog, or is null for a custom theme without one. The root theme can be shared by multiple blogs. Lists are ordered by title and ID, with any additional root theme appended. Timestamps may be null when unavailable.

List and read templates

GET /themes/42/templates returns { "templates": [...] }, ordered by path and ID. These are the files stored in that specific theme, without merging in other themes or built-in defaults. All stored files are listed, including binary assets, so an app can display a complete file list without downloading the contents.

Each entry includes id, theme_id, path, is_text, read_only, and updated_at. Reading a single text template returns those same fields plus text:

{
  "id": 81,
  "theme_id": 42,
  "path": "layouts/partials/footer.html",
  "is_text": true,
  "read_only": false,
  "updated_at": "2026-10-28T10:00:00+00:00",
  "text": "<p>Thanks for reading.</p>\n"
}

Reading or editing binary contents returns 422. Supported text files use the web editor’s extensions: .html, .txt, .md, .markdown, .css, .scss, .json, .coffee, .svg, .toml, .map, .yml, .xml, .js, .ts, and .webmanifest, plus files named LICENSE or README.

Read defaults and create an override

The defaults endpoints expose the configured built-in theme and Blank fallback, with the configured theme winning when paths overlap. If a full-theme plug-in is active, defaults contain only the Blank fallback; read the plug-in’s own files using its numeric theme ID. These endpoints do not attempt to describe all of Hugo’s file precedence or configuration merging.

Built-in entries have id: null, theme_id: null, read_only: true, and a source field naming the built-in theme. Read a file using the exact path returned by the list endpoint. Server filesystem paths are never exposed or accepted.

To customize a built-in or installed plug-in template, read its text, then POST the same path with edited text into the custom root theme. If an override already exists, POST to that template’s ID instead. Editing a plug-in’s template directly changes the installed copy; a later plug-in upgrade may replace that change. Deleting an override allows the inherited template to take effect again.

Create, edit, and delete

POST requests require Content-Type: application/json. Creating a template requires both fields:

{
  "path": "layouts/partials/footer.html",
  "text": "<p>Thanks for reading.</p>\n"
}

Editing with POST /themes/:theme_id/templates/:id accepts path, text, or both. Omitting a field preserves its current value. An empty string is valid template text; null is not. Unknown fields are rejected. Paths must be relative, at most 250 characters, and use the same filename characters allowed by the editor: letters, numbers, periods, slashes, hyphens, underscores, and spaces. Absolute paths, empty components, . or .. components, and characters that would be silently removed by the editor are rejected.

Creating or renaming to an existing path in the same theme returns 409. The JSON request body is limited to 2 MiB. The existing template model strips readfile and readdir from saved text; use the returned text as the saved value. The API clears any previous binary copy so publishing uses that saved text.

Creating returns 201 with a Location header identifying the new template. Editing returns 200. Both return:

{
  "template": {
    "id": 81,
    "theme_id": 42,
    "path": "layouts/partials/footer.html",
    "is_text": true,
    "read_only": false,
    "updated_at": "2026-10-28T10:00:00+00:00",
    "text": "<p>Thanks for reading.</p>\n"
  },
  "publishing_site_ids": [123]
}

DELETE takes no request body and returns 200 with { "deleted": true, "publishing_site_ids": [123] }. Stored binary assets can also be deleted. Deleting a missing template returns 404.

Successful mutations update the theme timestamp, invalidate configuration caches, and queue publishing for every owned blog using the theme, including preview blogs. publishing_site_ids identifies those blogs; an empty array means the theme is not currently used. A successful response means the change was saved and publishing was queued, not that Hugo has finished or that the template is valid Hugo syntax. Publish errors continue to appear in the blog’s logs.

There is no revision locking in this version: the last saved edit wins. Apps should refresh templates before editing when another editor may be open.

Read and replace custom CSS

GET /themes/css?site_id=123 returns the selected blog’s custom CSS. Blogs without custom CSS return an empty string:

{
  "site_id": 123,
  "css": "body { color: navy; }"
}

To replace it, send POST /themes/css?site_id=123 with Content-Type: application/json and a body containing only css:

{
  "css": "body { color: navy; }"
}

css must be a string. An empty string clears the custom CSS; omitting it or sending null is rejected. The JSON request body is limited to 2 MiB. Omit site_id to read or update the user’s default blog.

A successful POST returns 200 with the same site_id and css fields as GET. It saves the CSS and immediately uploads /custom.css using the web editor’s publishing method. Only the selected blog is changed, even if other blogs share its theme. The setting for using custom CSS in newsletters is preserved.

Errors

API validation errors use { "error": "Description." }:

Status Meaning
400 Invalid JSON, fields, or path.
401 Authentication required or invalid token.
403 Insufficient scope or failed CSRF check.
404 Blog, theme, or template not found or not owned by the user.
409 Another template already has that path.
413 Request exceeds 2 MiB.
415 POST is not JSON.
422 Unsupported file type, a template could not be saved/deleted, or CSS could not be saved.
500 Unexpected storage or publishing failure.

Authentication and scope errors from the shared authentication layer may be plain text. Clients should check HTTP status before parsing JSON. A publishing failure can occur after a template or CSS has been saved; re-read the value before retrying a mutation.

Example

# discover the root theme and installed plug-ins
curl -H "Authorization: Bearer $TOKEN" \
  'https://micro.blog/themes?site_id=123'

# list files in a custom theme or installed plug-in
curl -H "Authorization: Bearer $TOKEN" \
  'https://micro.blog/themes/42/templates'

# edit a template and queue publishing
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"text":"<p>Updated footer.</p>"}' \
  'https://micro.blog/themes/42/templates/81'

# read the blog's custom CSS
curl -H "Authorization: Bearer $TOKEN" \
  'https://micro.blog/themes/css?site_id=123'

# replace custom CSS and publish /custom.css immediately
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"css":"body { color: navy; }"}' \
  'https://micro.blog/themes/css?site_id=123'