# Design API

_Category: REST API reference_

Change a help center's design from code: its theme, pages and components, navigation, custom CSS and scripts. You edit the same draft that the template editor opens, a person reviews it there with a live preview, and visitors see nothing until you publish.

Start by reading where the design stands:

```
curl https://api.helpcenter.io/v1/template \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json"
```

The response names the draft and the live version, gives the link to review the draft, and outlines the draft (trimmed here to the colors and the synced regions):

```
{
  "status": "success",
  "template": {
    "engine": "v2",
    "site": {
      "id": 391,
      "name": "Acme Help",
      "subdomain": "acme"
    },
    "draft": {
      "id": 223,
      "etag": "\"223-1790778695000-9b93f595\"",
      "updated_at": "2026-09-30T14:31:35+00:00",
      "preset_key": "compass",
      "has_unpublished_changes": false
    },
    "published": {
      "id": 222,
      "status": "published",
      "live": true,
      "message": "Initial template",
      "preset_key": "compass",
      "published_at": "2026-09-30T14:31:28+00:00"
    },
    "urls": {
      "live_site": "https://acme.helpcenter.io",
      "customizer": "https://helpcenter.io/app/sites/acme/customizer-v2",
      "draft_preview": "https://acme.helpcenter.dev"
    }
  },
  "outline": {
    "theme": {
      "colors": {
        "primary": "#24644f",
        "accent": "#edf6f1",
        "fg": "#1c2926",
        "bg": "#ffffff",
        "muted": "#626c68",
        "surface": "#f6f8f6",
        "border": "#e2e8e4"
      }
    },
    "syncedRegions": [
      "footer"
    ],
    "syncedButDiverged": null
  }
}
```

## How it works

1. **Read.** `GET /v1/template` returns an outline of the draft and its `etag`. Read any part in full with `GET /v1/template/draft`, and look up what you can change with `GET /v1/template/schema`.
2. **Start from a theme, if you like.** `POST /v1/template/themes/{themeKey}/apply` copies a theme onto the draft, whole or only some parts of it, such as its colors and fonts.
3. **Edit.** `PATCH /v1/template/draft` applies a batch of operations to the draft, all or nothing.
4. **Review.** Send the person responsible for the design to `template.urls.customizer`. The template editor opens on the same draft, with a live preview, and they can adjust it there.
5. **Publish.** `POST /v1/template/publish` puts the draft live. It's the only call that changes what visitors see.

- **One draft per help center, shared with the template editor.** Your edits appear in the editor, and edits made in the editor appear in your next read, so a person can pick up where your code stopped, and the other way round (see [Get started with the template editor](https://self.helpcenter.io/content/template-editor)).
- **Visitors keep seeing the live design until you publish.** `urls.draft_preview` shows the draft as a website too, but only to signed-in members of the help center's team.
- **You can always go back.** Discard the draft to return to the live design, or load an earlier version into the draft and review it before it goes live again.

## Help centers that use the template editor

The design API works with help centers designed in the template editor. On a help center that hasn't switched to the template editor yet, every endpoint answers `409 Conflict` with the code `template_migration_required`, and `customizer_url` in the body links to its template editor. The request changes nothing.

Switching is the help center owner's decision, and it happens in the dashboard: the template editor turns the current design into a draft to review, and publishing that draft completes the switch. From then on, the design API works.

## Get access

Access to the design is granted separately from access to your content, and checked twice on every request. The design API works on every plan, with an API key or an OAuth access token.

- **An API key must be created with design access.** When you create the key, tick **Allow access to the help center design** under **Design** (see [API keys](https://developers.helpcenter.io/content/api-keys)). A **Read only** key then holds `design.read` and can read the design. A **Read & write** key also holds `design.write` and can change and publish it. A key created without the option can't reach the design, whatever its scope, and you can't add the option to an existing key: create a new one. Keys with design access carry a **Design** tag in the list of keys.
- **OAuth apps** get `design.read` and `design.write` only when they ask for these scopes and the person connecting the app approves them. `content.read` and `content.write` don't include them (see [Scopes](https://developers.helpcenter.io/content/scopes)).
- **The person behind the credential must be able to edit the design.** Every request checks that the team member who created the key, or who approved the app, can still edit this help center's design in the dashboard: an Owner, Admin or Editor. Translators and Viewers can't. Without that access, the API answers `403 Forbidden` with the code `design_forbidden`, whatever the credential holds. Replace the key with one created by a current Owner or Admin, or connect the app again as someone who can edit the design.

A credential without the scope gets `403` with the code `insufficient_scope` and a message that names the scope, such as `This action requires the design.write scope.`

`design.write` reaches the help center's custom CSS and scripts, which run in every visitor's browser once the design is published. Treat a key with design access like an admin password: keep it on a server, give it only to tools you trust, and delete it when the work is done.

## Endpoints

| Method and path | What it does | Scope |
| --- | --- | --- |
| `GET /v1/template` | Read where the design stands, and an outline of the draft | `design.read` |
| `GET /v1/template/draft` | Read the draft document, whole or one part | `design.read` |
| `GET /v1/template/schema` | Read the customization guide | `design.read` |
| `GET /v1/template/themes` | List the themes | `design.read` |
| `GET /v1/template/themes/{themeKey}` | Read one theme, and what applying it would change | `design.read` |
| `POST /v1/template/themes/{themeKey}/apply` | Apply a theme to the draft | `design.write` |
| `PATCH /v1/template/draft` | Edit the draft | `design.write` |
| `POST /v1/template/draft/discard` | Discard the draft's changes | `design.write` |
| `POST /v1/template/publish` | Publish the draft | `design.write` |
| `GET /v1/template/versions` | List the published versions | `design.read` |
| `POST /v1/template/versions/{versionId}/restore` | Load an earlier version into the draft | `design.write` |

Every request counts toward your credential's 300 requests a minute, and the five writes also toward its 120 writes a minute (see [Rate limits](https://developers.helpcenter.io/content/rate-limits)).

## The template object

`GET /v1/template` and every write return `template`, which says where the design stands:

| Field | Type | Description |
| --- | --- | --- |
| `engine` | string | Always `v2`. |
| `site` | object | The help center: `id`, `name` (its current name, which becomes the draft's `brand.name` when the draft is published) and `subdomain`. |
| `draft.id` | integer | The draft. Every publish starts a new draft, with a new `id`. |
| `draft.etag` | string | The draft's current version. Send it with a write to change only the draft you read (see Avoid overwriting someone else's changes, below). The quotes inside it are part of the value. |
| `draft.updated_at` | string | When the draft last changed, ISO 8601 with an offset. |
| `draft.preset_key` | string | The key of the theme the draft came from, or of the theme last applied to it. Applying only some sections of a theme sets it too, and discarding the draft leaves it as it was. |
| `draft.has_unpublished_changes` | boolean | `true` when the draft differs from the live design. |
| `published` | object or null | The live version: `id`, `status`, `live`, `message`, `preset_key` and `published_at`, as described under List the published versions, below. |
| `urls.live_site` | string | The live help center, on its custom domain when it has one. |
| `urls.customizer` | string | The template editor, open on this draft. Send people here to review your changes and to publish them. |
| `urls.draft_preview` | string | The draft as a website. It opens only for signed-in members of the help center's team. |

## The design document

A help center's design is one document. `GET /v1/template/draft` returns it, and the customization guide describes every part of it.

| Part | What it holds |
| --- | --- |
| `brand` | The help center's name, tagline, logo, dark-mode logo, favicon and main website. When the draft is published, `brand.name` becomes the help center's name everywhere it appears, including the widget, contact form emails and the API. |
| `theme` | Colors for light mode (`colors`) and dark mode (`darkColors`), `fonts`, corner `radius` and `density`. |
| `meta` | The SEO title and description, social sharing texts and image, text direction and the search engine settings. |
| `pages` | Six pages: `home`, `category`, `article`, `search`, `contact` and `notFound`. Each has a `layout`, its own settings, and six regions: `header`, `hero`, `sidebar`, `main`, `footer` and `utility`. A region holds an ordered list of components, and a Hero or Container holds components of its own. |
| `syncedRegions` | The regions every page shares (see Synced regions whose pages differ, below). |
| `globals` | `navigation`, the menu that every Navigation component draws, and `scripts`, the scripts and snippets injected into the pages. |
| `customCssItems` | Labelled blocks of custom CSS. `customCss` is the stylesheet compiled from the enabled blocks, rebuilt on every change. |
| `schemaVersion`, `preset` | Kept by HelpCenter.io. You can't set them. |

A component is `{id, type, config, visibleOn, children}`. Its `id` is how operations find it, and how custom CSS targets it, with `[data-hc-instance-id="<id>"]`. To see how pages, regions and shared headers look in the template editor, see [Pages, regions and shared headers explained](https://self.helpcenter.io/content/pages-and-regions).

Text you set is in the help center's default language. Translations of design texts are made in the dashboard (see [Translate texts in your design](https://self.helpcenter.io/content/translate-design-texts)). The API reads them but can't edit them. When you move or copy a component, its translations go with it, and when you remove it, they go too.

## Read the design

GET`/v1/template`

Read where the design stands: the draft, the live version, the review link, and an outline of the draft.

The outline holds what you need to decide on an edit, without the full document:

- `brand`, `theme` and `meta`, in full.
- `pages`: for each page, `settings` with its `layout` and the settings that differ from the defaults, and `regions` with each region's components. A component shows its `id`, `type`, the settings that differ from its defaults, and its `children`. Values longer than 120 characters are cut short and large lists are summarized: read them whole with `section=component`.
- `sharedRegions`: the synced regions whose pages are the same, listed once instead of on every page.
- `syncedButDiverged`: `null`, or an object whose `regions` lists the synced regions whose pages differ, with a `note` on how to choose. Those regions then appear under each page.
- `styledByCustomCss` on a region: the labels of the custom CSS blocks that restyle it. A theme's CSS can lay a region out its own way, and whatever you add there is laid out the same way.
- `navigation`, `scripts` and `customCss`: lists of ids and labels, without the bodies. `presetStartedFrom` is the theme the document came from.

If the help center has no draft yet, for example because no one has opened its design since the help center was created, reading starts a new one from the live design.

## Read the draft document

GET`/v1/template/draft`

Read the draft document, whole or one part of it.

Every answer has `section`, the draft's `etag` and the part you asked for in `data`. The whole document of a theme can be large (the Compass theme's is about 85 KB, most of it custom CSS), so read the part you need.

| `section` | Other parameters | `data` |
| --- | --- | --- |
| `document` (the default) | None | The whole document. |
| `brand`, `theme`, `meta` | None | That part of the document. |
| `navigation` | None | The menu items. |
| `scripts` | `id`, optional | Every script, or the one with that `id`. |
| `custom_css` | `id`, optional | Every custom CSS block, or the one with that `id`. |
| `page` | `page`, required | One page: its settings and its six regions. |
| `region` | `page` and `region`, required | One region: `instances`, its components, and `config`, its styling. |
| `component` | `id`, required. `page`, optional | `page`, `region` and `component`: the component with all its settings and children. Add `page` when the id is on several pages. |
| `translations` | None | The translated design texts, by language code. An empty list, `[]`, when there are none. |

An unknown component id answers `404 Not Found` with `component_not_found`, and an unknown script or CSS block id `404` with `not_found`.

## Read the customization guide

GET`/v1/template/schema`

Read the customization guide: every operation, component, setting and rule the draft accepts, one topic at a time.

The guide is built from the components and settings this API accepts, so it always matches what `PATCH /v1/template/draft` checks. Read it before you change a component's settings.

| `topic` | What it holds |
| --- | --- |
| `overview` (the default) | The model of pages, regions and components, every operation with an example, an index of the components (what each is for, and on which pages and in which regions it can go), this help center's languages, FAQ sections and uploaded fonts, and the limits. |
| `components` | Every setting of the component types you list in `types`, comma-separated, up to 8, such as `types=Hero,SearchBar`: its type, allowed values or range, default and description, and what the component can hold. Without `types`, the index of components. |
| `settings` | Every path the `set` operation accepts, with the values it takes, and the region styling `update_region` takes. |
| `theme` | The color tokens, font formats, radius and density, and the CSS variables they become. |
| `css` | How custom CSS is scoped, the hooks for components and regions, dark mode, and what is removed. |
| `scripts` | Script positions, kinds, page filters and safety. |
| `navigation` | The format of menu items. |

## List themes

GET`/v1/template/themes`

List the themes you can apply to the draft.

Each theme has a `key` and `name`, a `tagline`, `summary` and `best_for`, its light and dark palettes, fonts and density, a `preview_image` and, for some, an `industry`. `current` is `true` for the theme in the draft's `preset_key`. The `blank` theme is an empty canvas: default colors, and no components except an ArticleMetadata on the article page and a ContactForm on the contact page.

## Read a theme

GET`/v1/template/themes/{themeKey}`

Read one theme, and which sections of the draft applying it would change.

One theme adds its `description`, `screenshots` of the home, category, article and search pages and of dark mode and a phone, and `anatomy`, what each page is built from. `changed_sections` lists the sections of your draft that applying the theme would change, and `sections` every section you can apply.

## Apply a theme

POST`/v1/template/themes/{themeKey}/apply`

Copy a theme onto the draft: every section, or only the ones you name.

| Field | Type | Description |
| --- | --- | --- |
| `sections` | array of strings | The parts of the theme to take. Default: every section, `brand` and `meta` included (see Name your sections, below). Any of `brand`, `theme.colors`, `theme.darkColors`, `theme.fonts`, `theme.radius`, `theme.density`, `pages.home`, `pages.category`, `pages.article`, `pages.search`, `pages.contact`, `pages.notFound`, `globals.navigation`, `globals.scripts`, `meta` and `customCss`. |
| `etag` | string | Optional. The draft's `etag` from your last read or write. |

- **Each section you apply replaces that part of the draft** with the theme's. Applying a page replaces its components, with new ids, so read the draft again before you edit them. Applying `customCss` replaces your custom CSS blocks. `warnings` says when either happened.
- **To borrow a theme's look without its layout**, apply only `theme.colors`, `theme.darkColors`, `theme.fonts`, `theme.radius` and `theme.density`.
- **Name your sections.** Without `sections`, every section is applied, `brand` and `meta` included: the draft gets the theme's placeholder name and tagline instead of yours, such as `Help Center`, loses your logo, dark-mode logo and favicon, and takes the theme's SEO title and description, with the no-index setting off. Once published, that placeholder name becomes your help center's name. The template editor leaves `brand` out unless someone ticks it.
- The draft's `preset_key` becomes the theme's key, even when you apply only some sections. Nothing changes for visitors until you publish.

## Edit the draft

PATCH`/v1/template/draft`

Edit the draft with a batch of up to 50 operations, applied in order and all or nothing.

| Field | Type | Description |
| --- | --- | --- |
| `operations` | array | Required. 1 to 50 operations, each an object with an `op` and that operation's fields. |
| `etag` | string | Optional. The draft's `etag` from your last read or write. |

- **In order.** Each operation sees what the ones before it did. Give a new container your own `id`, and you can add components into it in the same batch.
- **All or nothing.** The first operation that can't be applied fails the whole batch with `422 Unprocessable Entity` and `template_invalid`, and nothing is saved, not even the operations before it.
- **The template editor's rules.** A component goes only on the pages and in the regions it's made for, a component that's allowed once per region stays unique, a container takes only the component types it accepts, and a setting takes only the values the template editor offers.
- **What you get back.** `results` has one entry per operation, in order: `operation` (its position in the batch, from 0), `op`, and what it did, such as the `id` of a new component, CSS block or script, or the `paths` a `set` changed. `template` carries the new `etag`.
- **Warnings.** `warnings` points out what is allowed but probably not what you want, such as a script that every visitor will run, a Hero with an image background and no image, a Logo that shows an image when `brand.logoAssetId` is empty, or a batch that left the draft as it was.

### Operations

| Operation | Fields | What it does |
| --- | --- | --- |
| `set` | `path`, `value` | Changes document settings: `brand.*`, `theme.colors.*`, `theme.darkColors.*`, `theme.fonts.*`, `theme.radius.*`, `theme.density`, `meta.*` and `pages.<page>.<setting>`. An object sets several at once: `{"op": "set", "path": "theme.colors", "value": {"primary": "#1d4ed8", "accent": "#dbeafe"}}`. `null` empties a setting that can be empty. The guide's `settings` topic lists every path. |
| `add_component` | `page`, `region`, `type`, `config`, `index`, `parent_id`, `id` | Adds a component. Settings you leave out in `config` get the component's defaults. `index` is its position from 0; leave it out to add it at the end. `parent_id` puts it inside a Hero or Container, whose page and region it takes. `id` is optional: 1 to 64 letters, digits, `-` or `_`, starting with a letter or digit, and not used elsewhere in the draft. A new Hero comes with a SearchBar inside it. |
| `update_component` | `id`, `config`, `page` | Changes some of a component's settings. `null` puts a setting back to its default. Add `page` when the id is on several pages of a region that isn't synced. |
| `remove_component` | `id`, `page` | Removes a component and everything inside it. |
| `move_component` | `id`, `region`, `parent_id`, `index`, `page` | Moves a component on its page: to another region, into a container with `parent_id`, or out to the top level with `"parent_id": null`. With only `id` and `index`, it reorders the component among its neighbors, inside a container too. |
| `update_region` | `page`, `region`, `config` | Styles a region: `background` (a hex color or a token such as `var(--hc-color-primary)`), `padding` (`compact`, `standard` or `tall`), `borderBottom` (`true` or `false`) and `align` (`left` or `center`). `null` resets one. |
| `sync_region` | `region`, `enabled`, `source_page` | `true` makes every page share the region and copies the `source_page`'s version (default `home`) over the others. `false` lets pages differ. Works on `header`, `hero`, `sidebar`, `footer` and `utility`: `main` always belongs to its page. |
| `copy_region` | `region`, `from_page`, `to_pages` | Copies a region that isn't synced from one page to others, once. Later edits don't travel. |
| `add_css` | `css`, `label`, `enabled`, `index`, `id` | Adds a labelled custom CSS block. The stylesheet is rebuilt from the enabled blocks, in order. |
| `update_css` | `id`, `css`, `label`, `enabled`, `index` | Changes a block, or moves it with `index`. `"enabled": false` keeps the block without applying it. |
| `remove_css` | `id` | Deletes a block. |
| `add_script` | `position`, and one of `src`, `inlineBody` or `htmlBody`. Optional: `label`, `pages`, `enabled`, `defer`, `async`, `integrity`, `index`, `id` | Injects a script at `headStart`, `headEnd`, `bodyStart` or `bodyEnd`: an external `https` URL in `src`, JavaScript without `<script>` tags in `inlineBody`, or raw HTML such as a vendor snippet or a verification `<meta>` tag in `htmlBody`. `pages` limits it to some pages; `null` runs it on every page. |
| `update_script` | `id`, and any field of `add_script` | Changes a script. Sending a different body field switches it to that kind. |
| `remove_script` | `id` | Deletes a script. |
| `set_navigation` | `items` | Replaces the whole menu: up to 30 items, each `{id, label, href, type, cssClass, children}`, with one level of `children`, up to 20 per item. `type` is `link` (the default), `category` or `external`. Keep an item's `id` to keep its translations. An empty list clears the menu. |

- Every operation refuses fields it doesn't know, and the error names them.
- A custom CSS block, and all blocks together, can hold up to 204,800 bytes. A block that would render as nothing is refused. `@import`, `@charset`, `@namespace`, `expression()`, `behavior:` and `javascript:` URLs are removed when the page renders, and a warning says so. See [Custom CSS reference: selectors and examples](https://self.helpcenter.io/content/custom-css-reference).
- A script body can hold up to 51,200 bytes.

### When an operation fails

A batch that can't be applied answers `422`, and nothing is saved:

```
{
  "status": "error",
  "code": "template_invalid",
  "message": "Nothing was saved. operation 1 (set), theme.colors.primary: must be a hex colour like \"#635bff\" (or \"#rrggbbaa\") (got \"blue\")",
  "errors": [
    {
      "operation": 1,
      "op": "set",
      "path": "theme.colors.primary",
      "message": "must be a hex colour like \"#635bff\" (or \"#rrggbbaa\") (got \"blue\")"
    }
  ]
}
```

`errors` lists what went wrong, and `message` sums it up in one paragraph. Each error names the `operation` (its position in the batch, from 0), the `op`, the `path` of the field or setting, and in its `message` what would have been accepted. One operation can report several errors, for example a `set` with two invalid colors. An unknown component, CSS block or script id inside a batch is a `template_invalid` error too, not a `404`.

A request that isn't a batch at all, with no `operations` or more than 50 of them, answers `422` with `"status": "validation_error"` instead.

### Synced regions whose pages differ

A synced region is one region that every page shares: edit it on any page, and the edit is copied to all six pages, under the same component ids. `header` and `footer` are often synced.

A synced region can still hold different content on different pages. Some themes, for example, give category and article pages a header with a search field that the home page doesn't have. Editing such a region would copy one page's version over all the others, so the API refuses the edit until you choose what should happen. The outline lists these regions in `syncedButDiverged.regions`, and the refusal says how to choose:

```
{
  "status": "error",
  "code": "template_invalid",
  "message": "Nothing was saved. operation 0 (update_component), region: the header region is synced (one header shared by every page), but its pages currently differ: category, article have a different header from the home page. Editing it would copy the home page's header over all of them. Choose first, in this batch or a new one: {\"op\": \"sync_region\", \"region\": \"header\", \"enabled\": false} to keep each page's header and edit them separately (then name the page on each edit), or {\"op\": \"sync_region\", \"region\": \"header\", \"enabled\": true, \"source_page\": \"<page>\"} to make every page use that page's header.",
  "errors": [
    {
      "operation": 0,
      "op": "update_component",
      "path": "region",
      "message": "the header region is synced (one header shared by every page), but its pages currently differ: category, article have a different header from the home page. Editing it would copy the home page's header over all of them. Choose first, in this batch or a new one: {\"op\": \"sync_region\", \"region\": \"header\", \"enabled\": false} to keep each page's header and edit them separately (then name the page on each edit), or {\"op\": \"sync_region\", \"region\": \"header\", \"enabled\": true, \"source_page\": \"<page>\"} to make every page use that page's header."
    }
  ]
}
```

- **Keep each page's version:** `{"op": "sync_region", "region": "header", "enabled": false}`. Each page keeps its own copy, and the component ids don't change. Where the same id is on several pages, name the `page` in the edit: without it, the edit is refused and asks which page you mean.
- **Make every page use one page's version:** `{"op": "sync_region", "region": "header", "enabled": true, "source_page": "category"}`. The other pages lose what their own version had, and a warning says so.

You can choose and edit in the same batch:

```
{
  "operations": [
    {
      "op": "sync_region",
      "region": "header",
      "enabled": false
    },
    {
      "op": "update_component",
      "id": "01M3SC61SPBDHVRP65G50W2F8J",
      "page": "home",
      "config": {
        "variant": "text-only"
      }
    }
  ]
}
```

### Strings are stored as you send them

Unlike the rest of the API (see [Requests and responses](https://developers.helpcenter.io/content/requests-responses-and-conventions)), the design endpoints don't trim strings and don't turn empty strings into `null`:

- **An empty string clears a text.** `{"op": "update_component", "id": "01M3SBMGRXRW19W78F2THFE327", "config": {"subheading": ""}}` removes a Hero's subheading.
- **`null` puts a component setting back to its default.** The same operation with `"subheading": null` brings back the Hero's default subheading, "Search articles, browse categories, or contact us."
- **Settings that can't be empty refuse it.** `brand.name` and `meta.title` refuse an empty string, `null` and a string of only spaces.
- **Spaces and line breaks are kept** in Markdown, CSS and scripts, so an indented code line in a RichText component stays indented.

## Avoid overwriting someone else's changes

People can edit the draft in the template editor while your code works on it. Every write takes an optional `etag` to keep you from building on a draft you haven't seen:

1. Read the `etag`: `template.draft.etag` from `GET /v1/template` or any write, or `etag` from `GET /v1/template/draft`. The quotes inside it are part of the value: send it back exactly, as a JSON string.
2. Send it with your write. If the draft changed since you read it, because someone saved in the template editor, another integration wrote to it or someone published, nothing changes and the API answers `412 Precondition Failed` with `stale_draft` and the current `etag`.
3. Read the draft again, make your change on top of what is there now, and retry.

```
{
  "status": "error",
  "code": "stale_draft",
  "message": "The draft changed since you read it (someone edited it in the Template Editor, or another agent did), so nothing was changed. Read the template again and re-apply your change on top of what is there now.",
  "etag": "\"223-1790779485000-880c4754\""
}
```

- Every write returns the draft's new `etag` in `template.draft.etag`, so a series of writes can pass it along without reading in between.
- Without `etag`, a write applies to the draft as it is at that moment. Two writes never mix, but yours can land on changes you haven't seen.
- Every publish starts a new draft, so an `etag` from before a publish always answers `412`.
- The template editor checks the same `etag`. If a person saves there after your write, the editor says **This draft was changed elsewhere.** and offers **Reload**, which keeps your changes, or **Overwrite**, which replaces them with theirs.

## Discard the draft

POST`/v1/template/draft/discard`

Throw the draft's changes away: the draft goes back to the live design.

Discarding resets the draft to the live design, like **Reset draft** in the template editor. Every unpublished change is lost, including changes people made in the template editor, and it can't be undone. The draft keeps its `id`, and its `preset_key` stays as it was. The body is optional: send `etag` to discard only the draft you read.

## Publish the draft

POST`/v1/template/publish`

Publish the draft: visitors see it at once, it's recorded as a version, and a new draft starts from it.

| Field | Type | Description |
| --- | --- | --- |
| `message` | string | Optional. A note of up to 300 characters that says what changed. It becomes the version's `message`, and your team sees it in the template editor's **Version history**. |
| `etag` | string | Optional, and recommended: the `etag` of the draft that was reviewed. If the draft changed after the review, nothing is published and you get `412`. |

- **The whole draft goes live**, including changes people made in the template editor. The version that was live becomes an earlier version, and the draft becomes the new live version, keeping the draft's `id`. A new draft starts from it, with a new `id` and `etag`.
- **The brand name follows.** `brand.name` becomes the help center's name everywhere, including the widget, contact form emails and the API.
- **Nothing to publish.** A draft that is the same as the live design answers `409 Conflict` with `nothing_to_publish`.
- **Publish after the review.** Publishing changes what visitors see, so publish once the person responsible for the design has reviewed the draft and wants it live (see [Publish your design](https://self.helpcenter.io/content/publish-your-design)).

## List the published versions

GET`/v1/template/versions`

List the published versions of the design, newest first: the live one and up to 49 earlier ones.

The list is newest first and holds up to 50 versions: the live one and earlier ones.

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | The version. A draft keeps its id when it's published. |
| `status` | string | `published` for the live version, `archived` for earlier ones. |
| `live` | boolean | `true` for the version visitors see. |
| `message` | string or null | The note it was published with. |
| `preset_key` | string | The draft's `preset_key` when it was published. |
| `published_at` | string | When it was published, ISO 8601 with an offset. |
| `published_by` | string or null | The name of the team member who published it, or `null` when it isn't known. Only in this list. |

## Restore a version into the draft

POST`/v1/template/versions/{versionId}/restore`

Load an earlier version into the draft, for review. It goes live only when you publish.

- **It goes to the draft, not live.** The version, with its translated texts, replaces what the draft holds. Visitors see it only after you publish, so a restore is reviewed like any other change. In the template editor, **Restore** publishes at once; over the API it doesn't.
- **Any version in the list works**, the live one included. An id that isn't a version of this help center answers `404` with `version_not_found`.
- **The draft's changes are replaced.** The body is optional: send `etag` to restore only onto the draft you read.

## Try it end to end

This program reads the design, changes the draft's colors with the `etag` it read, reads again and retries if someone changed the draft in between, and prints the link to review the result. It doesn't publish. Run it with a **Read & write** key created with design access in `HELPCENTER_API_KEY`:

```
// Change the theme colors in the design draft, then print the link to review it.
// Visitors see nothing until someone publishes. Node.js 18 or later, no dependencies.
const API = 'https://api.helpcenter.io/v1';
const COLORS = { primary: '#1d4ed8', accent: '#dbeafe' };

async function api(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: {
      apikey: process.env.HELPCENTER_API_KEY,
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  return { status: res.status, data: await res.json() };
}

async function main() {
  for (let attempt = 1; attempt <= 3; attempt++) {
    // 1. Read where the design stands: the outline, and the draft's etag.
    const read = await api('GET', '/template');
    if (read.status !== 200) throw new Error(`GET /template: ${read.status} ${JSON.stringify(read.data)}`);
    console.log(`Primary color in the draft: ${read.data.outline.theme.colors.primary}`);

    // 2. Change the colors, but only if nobody changed the draft since you read it.
    const edit = await api('PATCH', '/template/draft', {
      etag: read.data.template.draft.etag,
      operations: [{ op: 'set', path: 'theme.colors', value: COLORS }],
    });
    if (edit.status === 412) {
      console.log('The draft changed since it was read. Reading it again.');
      continue;
    }
    if (edit.status !== 200) throw new Error(`PATCH /template/draft: ${edit.status} ${JSON.stringify(edit.data)}`);

    console.log(`Changed ${edit.data.results[0].paths.join(', ')}`);
    for (const warning of edit.data.warnings) console.log(`Warning: ${warning}`);
    // 3. A person reviews the draft in the template editor, then publishes it.
    console.log(`Review it in the template editor: ${edit.data.template.urls.customizer}`);
    return;
  }
  throw new Error('The draft kept changing. Try again later.');
}

main().catch((err) => {
  console.error(err.message);
  process.exit(1);
});
```

```
# Change the theme colors in the design draft, then print the link to review it.
# Visitors see nothing until someone publishes. Python 3.8 or later, standard library only.
import json
import os
import sys
import urllib.error
import urllib.request

API = "https://api.helpcenter.io/v1"
COLORS = {"primary": "#1d4ed8", "accent": "#dbeafe"}

def api(method, path, body=None):
    request = urllib.request.Request(
        API + path,
        method=method,
        data=None if body is None else json.dumps(body).encode(),
        headers={
            "apikey": os.environ["HELPCENTER_API_KEY"],
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(request) as response:
            return response.status, json.load(response)
    except urllib.error.HTTPError as err:
        return err.code, json.loads(err.read() or b"{}")

for attempt in range(3):
    # 1. Read where the design stands: the outline, and the draft's etag.
    status, read = api("GET", "/template")
    if status != 200:
        sys.exit(f"GET /template: {status} {read}")
    print(f"Primary color in the draft: {read['outline']['theme']['colors']['primary']}")

    # 2. Change the colors, but only if nobody changed the draft since you read it.
    status, edit = api("PATCH", "/template/draft", {
        "etag": read["template"]["draft"]["etag"],
        "operations": [{"op": "set", "path": "theme.colors", "value": COLORS}],
    })
    if status == 412:
        print("The draft changed since it was read. Reading it again.")
        continue
    if status != 200:
        sys.exit(f"PATCH /template/draft: {status} {edit}")

    print("Changed " + ", ".join(edit["results"][0]["paths"]))
    for warning in edit["warnings"]:
        print(f"Warning: {warning}")
    # 3. A person reviews the draft in the template editor, then publishes it.
    print(f"Review it in the template editor: {edit['template']['urls']['customizer']}")
    break
else:
    sys.exit("The draft kept changing. Try again later.")
```

```
<?php
// Change the theme colors in the design draft, then print the link to review it.
// Visitors see nothing until someone publishes. PHP 8 or later with the curl extension.

const API = 'https://api.helpcenter.io/v1';
const COLORS = ['primary' => '#1d4ed8', 'accent' => '#dbeafe'];

function api(string $method, string $path, ?array $body = null): array
{
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'apikey: ' . getenv('HELPCENTER_API_KEY'),
            'Accept: application/json',
            'Content-Type: application/json',
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $data = json_decode((string) curl_exec($ch), true);

    return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $data];
}

for ($attempt = 1; $attempt <= 3; $attempt++) {
    // 1. Read where the design stands: the outline, and the draft's etag.
    [$status, $read] = api('GET', '/template');
    if ($status !== 200) {
        fwrite(STDERR, "GET /template: $status " . json_encode($read) . "\n");
        exit(1);
    }
    echo 'Primary color in the draft: ' . $read['outline']['theme']['colors']['primary'] . "\n";

    // 2. Change the colors, but only if nobody changed the draft since you read it.
    [$status, $edit] = api('PATCH', '/template/draft', [
        'etag' => $read['template']['draft']['etag'],
        'operations' => [['op' => 'set', 'path' => 'theme.colors', 'value' => COLORS]],
    ]);
    if ($status === 412) {
        echo "The draft changed since it was read. Reading it again.\n";
        continue;
    }
    if ($status !== 200) {
        fwrite(STDERR, "PATCH /template/draft: $status " . json_encode($edit) . "\n");
        exit(1);
    }

    echo 'Changed ' . implode(', ', $edit['results'][0]['paths']) . "\n";
    foreach ($edit['warnings'] as $warning) {
        echo "Warning: $warning\n";
    }
    // 3. A person reviews the draft in the template editor, then publishes it.
    echo 'Review it in the template editor: ' . $edit['template']['urls']['customizer'] . "\n";
    exit(0);
}

fwrite(STDERR, "The draft kept changing. Try again later.\n");
exit(1);
```

It prints:

```
Primary color in the draft: #635bff
Changed theme.colors.primary, theme.colors.accent
Review it in the template editor: https://helpcenter.io/app/sites/acme/customizer-v2
```

After the review, when the person asks for it, publish the draft:

```
curl -X POST https://api.helpcenter.io/v1/template/publish \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"message": "New brand colors"}'
```

## Errors

| Status | Code | When |
| --- | --- | --- |
| `400` | `site_required` | An OAuth token reaches several help centers and the request named none (see [Choose which help center a request acts on](https://developers.helpcenter.io/content/choosing-a-help-center)). |
| `401` | None: `{"status": "unauthorized"}` | No key or token, or one the API doesn't accept. |
| `403` | `insufficient_scope` | The credential lacks `design.read` or `design.write`. Content scopes don't include them. |
| `403` | `design_forbidden` | The person behind the credential can't edit the design in the dashboard. |
| `403` | `site_forbidden` | An OAuth token named a help center it doesn't reach. |
| `404` | `unknown_theme` | No theme with that key. The message lists the keys. |
| `404` | `component_not_found`, `not_found` | Reading a component, script or custom CSS block that isn't in the draft. |
| `404` | `version_not_found` | No version with that id on this help center. |
| `409` | `template_migration_required` | The help center doesn't use the template editor yet. |
| `409` | `nothing_to_publish` | Publishing a draft that is the same as the live design. |
| `412` | `stale_draft` | The draft changed since the `etag` you sent. Nothing changed. |
| `422` | `template_invalid` | An operation couldn't be applied. Nothing was saved. |
| `422` | None: `"status": "validation_error"` | The request itself is invalid: an unknown `section`, `topic` or theme section, a missing `page`, `region` or `id`, no `operations`, or more than 50. |
| `429` | None | Too many requests. Wait for `Retry-After` seconds (see [Rate limits](https://developers.helpcenter.io/content/rate-limits)). |

For the shapes that every endpoint shares, see [Errors](https://developers.helpcenter.io/content/errors).

## Related

- [API keys](https://developers.helpcenter.io/content/api-keys)
- [Scopes](https://developers.helpcenter.io/content/scopes)
- [Errors](https://developers.helpcenter.io/content/errors)
- [The HelpCenter.io MCP server](https://developers.helpcenter.io/content/the-mcp-connector-for-ai-agents)
