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 |
|---|---|---|
| Lists every category |
|
| Creates a category |
|
| Renames, describes, moves or reorders a category |
|
| Deletes a category and moves its content |
|
| Sets the order of many categories at once |
|
| Sets the order of the articles in a category |
|
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 |
|---|---|---|
| integer | The category's id. Articles point to it in |
| integer or null | The id of the parent category, or |
| object | The name per language, for example |
| object or array | The description per language, or |
| string or null | An image URL, or a Font Awesome icon written as |
| integer | The order among categories with the same parent, lowest first. Positions only compare within one parent. |
| string | Who can see the category. See the next table. |
| string | In UTC, as |
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).
| In the dashboard | Who can see the category and its articles |
|---|---|---|
| Everybody | Everyone who can read your help center. |
| Everybody with a link (hidden from navigation) | Anyone with a link. It is left out of navigation, article lists and search. |
| Team members only | Your team, signed in to HelpCenter.io. |
| 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
/v1/categoriesList 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
/v1/categoriesCreate a category, at the top level or under another category.
Field | Type | Description |
|---|---|---|
| object | Required. At least one language: |
| object | The description per language. |
| integer | One of your categories. |
| string | An image URL, or |
| integer | The order among its siblings, from |
Keys of
nameanddescriptionmust be languages your help center has, like the language keys of articles.Without
position, the category is stored at100000, after everything you have arranged. The create response shows0in that case; the list shows the stored value.Any
iconthat does not start withfa://is used as an image address, so a bare word such asrocketshows 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
/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.
nullor""for one language ofdescriptionremoves it. A name can't be emptied:nullor""innameanswers422."icon": nullremoves the icon."position": 0moves the category to the top of its siblings. To reorder many categories, usePUT /v1/categories/order."parent_id": 0moves 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 answer422.privacyis ignored.
When you rename or move a category, your help center's search is updated for the articles in it.
Delete a category
/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:
| What happens |
|---|---|
| Deletes the category only when it holds no articles and no subcategories. Otherwise nothing changes, and you get |
| Its articles become Uncategorized, and its subcategories move to the top level. An article that is also in other categories only leaves this one. |
| Its articles and subcategories move up to the deleted category's parent. For a top-level category, this is the same as |
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
/v1/categories/orderSet 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
/v1/categories/{categoryId}/articles/orderSet 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.