REST API reference

Categories

Export
Download Markdown Use with AI

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). 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).

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 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.

  • 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.

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

Was this article helpful?