# Categories

_Category: REST API reference_

Categories arrange your articles into a tree. With the Categories API you read the tree, create, rename and move categories, set the order of categories and of the articles inside each one, and delete a category while you decide where its content goes.

## Endpoints

| Method and path | What it does | Scope |
| --- | --- | --- |
| `GET /v1/categories` | Lists every category | `content.read` |
| `POST /v1/categories` | Creates a category | `content.write` |
| `PATCH /v1/categories/{categoryId}` | Renames, describes, moves or reorders a category | `content.write` |
| `DELETE /v1/categories/{categoryId}` | Deletes a category and moves its content | `content.write` |
| `PUT /v1/categories/order` | Sets the order of many categories at once | `content.write` |
| `PUT /v1/categories/{categoryId}/articles/order` | Sets the order of the articles in a category | `content.write` |

A Read only API key can list categories; every other endpoint needs a Read & write key, or an OAuth token with `content.write`. Validation errors on the write endpoints always come back as JSON, but an unknown category id on `PATCH`, `DELETE` and the article order endpoint is answered with an HTML page and status `404`.

## The category object

```
{
  "id": 334,
  "parent": 331,
  "name": {
    "en": "Refunds",
    "de": "Rückerstattungen"
  },
  "description": {
    "en": "How refunds work and how to ask for one."
  },
  "icon": "fa://fa-solid fa-rotate-left",
  "position": 1,
  "privacy": "public",
  "created_at": "2026-09-30 08:27:07",
  "updated_at": "2026-09-30 08:27:07"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | integer | The category's id. Articles point to it in `category_id` or `categories`. |
| `parent` | integer or null | The id of the parent category, or `null` at the top level. When you write it, the field is called `parent_id`. |
| `name` | object | The name per language, for example `{"en": "Refunds"}`. |
| `description` | object or array | The description per language, or `[]` when the category has none. |
| `icon` | string or null | An image URL, or a Font Awesome icon written as `fa://` followed by its classes, such as `fa://fa-solid fa-credit-card`. |
| `position` | integer | The order among categories with the same parent, lowest first. Positions only compare within one parent. |
| `privacy` | string | Who can see the category. See the next table. |
| `created_at`, `updated_at` | string | In UTC, as `YYYY-MM-DD HH:MM:SS`. |

### Privacy

Privacy is set in the dashboard under **Share with** (see [Make a category private](https://self.helpcenter.io/content/private-categories)). The Categories endpoints report it and can't change it: categories you create with them are always `public`. Over the API, only a change set can change it, when it publishes (see [Change sets](https://developers.helpcenter.io/content/change-sets-api)).

| `privacy` | In the dashboard | Who can see the category and its articles |
| --- | --- | --- |
| `public` | **Everybody** | Everyone who can read your help center. |
| `unlisted` | **Everybody with a link (hidden from navigation)** | Anyone with a link. It is left out of navigation, article lists and search. |
| `team_private` | **Team members only** | Your team, signed in to HelpCenter.io. |
| `private` | **Me and selected team members** | The teammates it is shared with. |

A hidden category hides everything below it. Under a `team_private` parent, or a `private` one that isn't shared with the reader, readers can't open the subcategories or their articles, even when those are `public`: they get a not-found page. Under an `unlisted` parent, subcategories and their articles are left out of navigation, lists and search, but their links still open.

The API does not apply these rules for you. `GET /v1/categories`, [Export](https://developers.helpcenter.io/content/export-api) and article reads without `search` return everything, each category with its own `privacy`. Before you show content outside your help center, check the `privacy` of the category and of every parent above it.

## List categories

GET`/v1/categories`

List every category of the help center, with its parent, position and privacy.

The list holds every category that has not been deleted, whatever its `privacy`, in no guaranteed order. Build the tree yourself: group categories by `parent` and sort each group by `position`. This script prints the whole tree:

```
// Print every category as an indented tree, siblings in their order.
const API = 'https://api.helpcenter.io/v1';
const headers = { apikey: process.env.HELPCENTER_API_KEY, Accept: 'application/json' };
const LANG = 'en'; // the language to print names in

async function allCategories() {
  const categories = [];
  for (let page = 1; ; page++) {
    const res = await fetch(`${API}/categories?limit=100&page=${page}`, { headers });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const body = await res.json();
    categories.push(...body.categories);
    if (page >= body.meta.total_pages) return categories;
  }
}

async function main() {
  const categories = await allCategories();
  const ids = new Set(categories.map((c) => c.id));
  const children = new Map();
  for (const c of categories) {
    const parent = ids.has(c.parent) ? c.parent : 0;
    if (!children.has(parent)) children.set(parent, []);
    children.get(parent).push(c);
  }
  const seen = new Set();
  const show = (parent, depth) => {
    const siblings = (children.get(parent) || [])
      .sort((a, b) => a.position - b.position || a.id - b.id);
    for (const c of siblings) {
      if (seen.has(c.id)) continue;
      seen.add(c.id);
      const name = c.name[LANG] || Object.values(c.name)[0] || '';
      console.log(`${'  '.repeat(depth)}${name} (id ${c.id}, ${c.privacy})`);
      show(c.id, depth + 1);
    }
  };
  show(0, 0);
}

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

```
# Print every category as an indented tree, siblings in their order.
import json
import os
import urllib.request

API = "https://api.helpcenter.io/v1"
HEADERS = {"apikey": os.environ["HELPCENTER_API_KEY"], "Accept": "application/json"}
LANG = "en"  # the language to print names in

def all_categories():
    categories, page = [], 1
    while True:
        url = f"{API}/categories?limit=100&page={page}"
        request = urllib.request.Request(url, headers=HEADERS)
        with urllib.request.urlopen(request) as response:
            body = json.load(response)
        categories += body["categories"]
        if page >= body["meta"]["total_pages"]:
            return categories
        page += 1

categories = all_categories()
ids = {c["id"] for c in categories}
children = {}
for c in categories:
    parent = c["parent"] if c["parent"] in ids else 0
    children.setdefault(parent, []).append(c)

seen = set()

def show(parent, depth):
    for c in sorted(children.get(parent, []), key=lambda c: (c["position"], c["id"])):
        if c["id"] in seen:
            continue
        seen.add(c["id"])
        name = c["name"].get(LANG) or next(iter(c["name"].values()), "")
        print(f"{'  ' * depth}{name} (id {c['id']}, {c['privacy']})")
        show(c["id"], depth + 1)

show(0, 0)
```

```
<?php
// Print every category as an indented tree, siblings in their order.
$api = 'https://api.helpcenter.io/v1';
$headers = ['apikey: ' . getenv('HELPCENTER_API_KEY'), 'Accept: application/json'];
const LANG = 'en'; // the language to print names in

$categories = [];
for ($page = 1; ; $page++) {
    $ch = curl_init("$api/categories?limit=100&page=$page");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => $headers,
    ]);
    $body = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    if ($status !== 200) {
        throw new RuntimeException("HTTP $status");
    }
    array_push($categories, ...$body['categories']);
    if ($page >= $body['meta']['total_pages']) {
        break;
    }
}

$ids = array_column($categories, 'id');
$children = [];
foreach ($categories as $c) {
    $parent = in_array($c['parent'], $ids, true) ? $c['parent'] : 0;
    $children[$parent][] = $c;
}

function show(array $children, int $parent, int $depth, array &$seen): void
{
    $siblings = $children[$parent] ?? [];
    usort($siblings, fn ($a, $b) => [$a['position'], $a['id']]
        <=> [$b['position'], $b['id']]);
    foreach ($siblings as $c) {
        if (isset($seen[$c['id']])) {
            continue;
        }
        $seen[$c['id']] = true;
        $name = $c['name'][LANG] ?? array_values($c['name'])[0] ?? '';
        echo str_repeat('  ', $depth) . "$name (id {$c['id']}, {$c['privacy']})\n";
        show($children, $c['id'], $depth + 1, $seen);
    }
}

$seen = [];
show($children, 0, 0, $seen);
```

A category whose `parent` is not in the list is treated as a top-level category.

An invalid `limit` or `page` answers `422` with `{"message": ..., "errors": {...}}` when you send `Accept: application/json`. Without that header you get a `302` redirect to the API's root, and a client that follows redirects reports `200 OK` with the API's welcome message instead of an error.

## Create a category

POST`/v1/categories`

Create a category, at the top level or under another category.

| Field | Type | Description |
| --- | --- | --- |
| `name` | object | Required. At least one language: `{"en": "Refunds"}`. Each name up to 190 characters. |
| `description` | object | The description per language. |
| `parent_id` | integer | One of your categories. `0`, or leave it out, for the top level. |
| `icon` | string | An image URL, or `fa://` followed by Font Awesome classes. Up to 190 characters. |
| `position` | integer | The order among its siblings, from `0`. Leave it out to place the category after the others. |

- Keys of `name` and `description` must be languages your help center has, like the language keys of articles.
- Without `position`, the category is stored at `100000`, after everything you have arranged. The create response shows `0` in that case; the list shows the stored value.
- Any `icon` that does not start with `fa://` is used as an image address, so a bare word such as `rocket` shows a broken image. To pick icons and images in the dashboard, see [Add a category icon, image and description](https://self.helpcenter.io/content/category-icons-and-images).
- New categories are `public`.

## Update a category

PATCH`/v1/categories/{categoryId}`

Rename, describe, move or reorder a category. Fields you leave out keep their values.

Send only what changes: `name`, `description`, `parent_id`, `icon` or `position`. Fields you leave out keep their values, and so do languages you leave out of `name` and `description`.

- `null` or `""` for one language of `description` removes it. A name can't be emptied: `null` or `""` in `name` answers `422`.
- `"icon": null` removes the icon.
- `"position": 0` moves the category to the top of its siblings. To reorder many categories, use `PUT /v1/categories/order`.
- `"parent_id": 0` moves the category to the top level. Any other value must be one of your categories: an id from another help center is refused like an unknown one. It also can't be the category itself or a category below it, which would make a loop. All of these answer `422`.
- `privacy` is ignored.

When you rename or move a category, your help center's search is updated for the articles in it.

## Delete a category

DELETE`/v1/categories/{categoryId}`

Delete a category, and choose what happens to its articles and subcategories.

Say what happens to the category's content with `on_orphan`, in the query string:

| `on_orphan` | What happens |
| --- | --- |
| `refuse` (default) | Deletes the category only when it holds no articles and no subcategories. Otherwise nothing changes, and you get `409 Conflict` with `"code": "category_not_empty"`, `articles_count` and `children_count`. Drafts count; articles and subcategories in Trash don't. |
| `uncategorize` | Its articles become Uncategorized, and its subcategories move to the top level. An article that is also in other categories only leaves this one. |
| `reparent` | Its articles and subcategories move up to the deleted category's parent. For a top-level category, this is the same as `uncategorize`. |

The response counts what moved: `articles_moved` and `children_moved`, and where to, in `moved_to` (the parent's id, or `0`). Articles and subcategories in Trash that belonged to the category are moved the same way, so they work when someone restores them. They are included in those counts, and `trashed_articles_refiled` and `trashed_children_refiled` say how many of them were in Trash.

Moved articles get a new `updated_at`, so `updated_since` finds them, but no `article.updated` webhook is sent for an article that was only in this category. The API can't restore a deleted category.

## Order categories

PUT`/v1/categories/order`

Set the order of many categories in one request.

Send up to 500 categories in `categories`, each with its `id` and, optionally, a `position`. Leave `position` out and each category takes its place in your list, from `0`: send the categories top to bottom and you are done. You can mix levels in one request, because each category is ordered among its own siblings.

The response lists the categories you sent, sorted by `position`. `updated_count` counts the categories it found, whether or not their position changed.

## Order the articles in a category

PUT`/v1/categories/{categoryId}/articles/order`

Set the order of the articles in one category.

Send up to 1000 articles in `articles`, each with its `id` and an optional `position`, which works as above. Only articles in this category are written; the ids of any others, including ids from other help centers, come back in `skipped_article_ids`. Articles you don't list keep their positions. An article in several categories has its own position in each.

**Read `order_visible` in the response.** Category pages use this order only when your design's article order is set to custom. When it is not, the order is saved but readers don't see it: `order_visible` is `false`, `message` says `Article order saved, but this help center does not currently display it.`, `article_sort` names the order in use and `hint` says which setting to change. The endpoint never changes that setting for you. To change it, see [Change the order of articles and categories](https://self.helpcenter.io/content/reorder-articles-and-categories).

Reordering changes no `updated_at` and sends no webhooks, so a sync that uses `updated_since` won't see it.

## Related

- [Articles](https://developers.helpcenter.io/content/articles-api)
- [Export](https://developers.helpcenter.io/content/export-api)
- [Sync articles from a Git repository](https://developers.helpcenter.io/content/sync-articles-from-git)
