Articles are your help center's content. With the Articles API you list, search and read them, create them from HTML or Markdown, create or update up to 50 at a time, change them, move them to Trash, send readers' votes back from wherever else you show them, and read every vote with the message its reader typed.
Endpoints
Method and path | What it does | Scope |
|---|---|---|
| Lists or searches articles |
|
| Gets one article |
|
| Creates an article, or updates the one with the same |
|
| Creates or updates up to 50 articles |
|
| Changes some fields of an article |
|
| Moves an article to Trash |
|
| Records a reader's vote on an article |
|
| Lists readers' votes and the messages they typed, newest first |
|
A Read only API key holds content.read and analytics.read; a Read & write key also holds content.write. OAuth tokens need the scope in the table (see Scopes). Staged edits, versions and restores have their own page, Staged changes and versions.
The API answers every error in JSON, with or without Accept: application/json. Send the header anyway, as the examples do.
The article object
Every endpoint returns articles in this shape. Text fields are maps keyed by language code, and a response always carries every language the article has.
{
"id": 757,
"external_id": "kb/download-invoice",
"title": {
"de": "Rechnung herunterladen",
"en": "Download an invoice"
},
"slug": {
"de": "rechnung-herunterladen",
"en": "download-an-invoice"
},
"author": {
"id": 414,
"name": "Maya Chen",
"avatar": null
},
"content": {
"de": "<p>Öffnen Sie <strong>Abrechnung</strong>, dann <strong>Rechnungen</strong>, und klicken Sie auf <strong>PDF herunterladen</strong>.</p>",
"en": "<p>Open <strong>Billing</strong>, then <strong>Invoices</strong>, and click <strong>Download PDF</strong>.</p>"
},
"category_id": 333,
"categories": [],
"type": "article",
"published": true,
"has_staged_changes": false,
"staged_locales": [],
"staged_updated_at": null,
"visibility": "public",
"share_token": null,
"views": 0,
"audio_file": null,
"metadata": {
"title": {
"en": "Download an invoice as a PDF"
},
"description": {
"en": "Find every invoice under Billing and download it as a PDF for your records."
}
},
"ratings": {
"thumbs_up": "60",
"thumbs_down": "20",
"love": "20"
},
"published_translations": {
"de": "2026-09-30 08:27:03"
},
"shared_with": [],
"created_at": "2026-09-30 08:26:42",
"updated_at": "2026-09-30 08:27:03",
"_links": {
"view": {
"method": "GET",
"url": "https://acme.helpcenter.io/content/download-an-invoice"
}
}
}
Field | Type | Description |
|---|---|---|
| integer | The article's id. |
| string or null | Your own key for the article, when you created or updated it with one. See Create or update by your own key, below. |
| object | The title per language, for example |
| object | The URL slug per language. |
| object |
|
| object | The body per language, as HTML. Internal notes, the highlights of team notes and AI drafts waiting for review are removed from what the API returns. |
| integer | The article's category. |
| array of integers | The categories of an article that is in several. Empty for an article in one category, whose category is in |
| string |
|
| boolean |
|
| boolean, array, string or null | Whether edits are staged but not published yet, in which languages, and when they last changed. The rest of the object is always the live article. See Staged changes and versions. |
| string |
|
| string or null | The secret of a link-only article. The link is the article's address followed by |
| integer | An all-time counter that no longer counts new views. For current traffic, use |
| object or null | The audio version per language, when the article has one. |
| object | The title and description search engines show: |
| object | The share of reader votes, in whole percent: |
| object or array | The additional languages released to readers, as keys: |
| array of integers | The ids of the teammates a private article is shared with. |
| string | In UTC, as |
| object |
|
Languages and translations
Every key in
title,slug,contentandmetadatamust be a language your help center has: its default language or an additional one (GET /v1/siteslists them). Any other key is refused.Writes are per language. A language you leave out of a map keeps its value.
Reads always return every language. The
langparameter ofGET /v1/articlesonly affectssearch.Translations you write over the API are not shown to readers yet. A title and text in an additional language are saved as a translation waiting for release: readers get a not-found page for it until someone releases it, one article at a time with Released in the editor (see Translate an article) or for the whole language with Release translated drafts (see Release translations and change your default language). On Catalyst, a staged edit can release it too (see Staged changes and versions).
published_translationstells you what is released.
List articles
/v1/articlesList the articles of the help center, or search them.
Without search, the list is your team's view of the help center: every article that is not in Trash, drafts, private and link-only articles included, most recently changed first.
Parameter | Type | Description |
|---|---|---|
| integer | Default 100. From 1 to 100. |
| integer | Default 1. A page past the end returns an empty list. |
| integer | Only articles filed in this category, including articles in several categories that list it. Articles in its subcategories are not included. |
| string |
|
| string | Only articles changed at or after this time. Send UTC, such as |
| string |
|
| string |
|
| string | Keywords. Switches the list to what readers can find: see Search below. |
| string | The language |
The response's meta holds page, per_page, total_pages and items_count. To fetch everything with full content, Export walks the whole help center with a cursor. To stay in sync, pass the updated_at of your last run as updated_since, and use webhooks to learn about deletions, which a list cannot show. Putting a category's articles in a new order with PUT /v1/categories/{categoryId}/articles/order doesn't change their updated_at, so updated_since doesn't list the articles that moved.
Search
With search, the endpoint answers the way your help center's search answers a visitor who is not signed in:
Only published, public articles that readers can open are returned. Drafts, private and link-only articles, and articles in categories readers can't see, are left out, so
status=draftwithsearchreturns nothing. A help center that isn't published yet still returns its published articles here.A search serves up to 1,000 matches, best ranked first. A match ranked lower is never returned, on any page.
items_countcounts the matches you can page through, andlimit(up to 100) andpagepage through them: see Pagination.The best match comes first.
order_by=viewssorts the matches by views instead.A search matches in your default language, or in the language
langnames. In an additional language (lang=de), only released translations match.A
langyour help center does not have, such aslang=xx, is refused with422and"status": "validation_error".errors.langreadsThat language is not enabled on this help center.
It is keyword search, the same for every plan. To find drafts or private articles by their text, list without search and filter on your side.
Get an article
/v1/articles/{articleId}Get one article, in every language it has.
Returns any article of the help center that is not in Trash, drafts and private articles included. You always get the live article: staged edits are not merged in. An id from another help center, an unknown id and an article in Trash all answer 404 Not Found with Article not found.
This read counts against the limit of 120 writes a minute, not only the 300 requests a minute every call counts against. See Rate limits.
Create an article
/v1/articlesCreate an article, or update the one that already has the same external_id.
Field | Type | Description |
|---|---|---|
| object | Required. The title per language: |
| object | The body per language, as HTML unless you set |
| string |
|
| object | The URL slug per language. Leave it out and each language's slug is made from its title. A slug you send is turned into lowercase words joined by hyphens, and made unique in its language: when another article already has it, five random characters are added. An empty slug is made again from the title. A slug longer than 191 characters is refused with |
| integer | One of your categories, or |
| string |
|
| boolean |
|
| string |
|
| string | Your own key for the article, up to 191 characters. See Create or update by your own key, below. |
| boolean |
|
| object | The search engine title (up to 60 characters) and description (up to 160) per language. See Search engine title and description, below. |
Booleans are JSON true and false (or 1 and 0); the string "true" is refused. Fields the API does not take, such as status, author_id or shared_with, are ignored.
A few things to know about creating:
The create response is built before the article is read back, so values the database fills in read
nullthere, such asviews, andtypewhen you did not send it. The next read shows0andarticle.categoriesfiles the article under several categories. Send a list of category ids, as JSON numbers: the article reports"category_id": -1and the ids incategories. Sendcategory_idas well and it joins the list, counted once, so"category_id": 331, "categories": [332]files the article under both. Withoutcategories,category_idfiles the article under one.
Create or update by your own key
Give every article you import a stable key of your own, such as its file path or its id in your other system, and send it as external_id. Then a request that runs twice never makes a duplicate:
The first
POST /v1/articleswith a key creates the article:201 Createdwith"action": "created".Every later
POSTwith the same key updates that article:200 OKwith"action": "updated". Only the fields you send change, as withPATCH, buttitleis still required.Keys are trimmed, then matched exactly:
KB/Exportandkb/exportare two keys, and so arecafeandcafé.A key belongs to one help center. The same key in another help center is another article.
When the article with that key is in Trash, the next
POSTcreates a new article and moves the key to it.Two requests with the same key at the same moment run one after the other. If the second one waits more than 10 seconds, it gets
409 ConflictwithAnother request is currently importing this external_id. Retry shortly.To give an existing article a key, send
external_idin aPATCH. That replaces its old key. A key that belongs to another article is refused with409and the other article's id inconflicting_article_id. A key can be replaced but not removed.
Write in Markdown
With "content_format": "markdown", every language in content is converted to HTML when you write it. The API stores and returns HTML, never Markdown. This request:
{
"external_id": "kb/cancel-subscription",
"title": {
"en": "Cancel your subscription"
},
"content": {
"en": "## Before you cancel\n\nExport anything you want to keep.\n\n1. Open **Billing**.\n2. Click **Cancel subscription**."
},
"content_format": "markdown",
"category_id": 331,
"published": true
}
stores this content:
{
"content": {
"en": "<h2>Before you cancel</h2>\n<p>Export anything you want to keep.</p>\n<ol>\n<li>Open <strong>Billing</strong>.</li>\n<li>Click <strong>Cancel subscription</strong>.</li>\n</ol>\n"
}
}
The converter follows GitHub's Markdown: tables, fenced code blocks (the code gets a class such as language-bash), task lists and bare links work, and HTML inside the Markdown is kept. Links to javascript: addresses lose their address.
Copy images into your help center
With "rehost_images": true, every <img> whose src points to another website is downloaded, stored with your help center's images, and its src rewritten to the copy. An image you already stored is reused, not copied again. The copies are private images, which only readers allowed to see the article can open, when the help center is private, password-protected or limited to some IP addresses, when the article is private, or when every category it is in is private or team_private. A help center that is public but not published yet keeps public image addresses (see Images). The response then has an images block:
{
"images": {
"rehosted": 0,
"reused": 1,
"failed": [
{
"src": "https://helpcenter.io/images/og/missing-example.png",
"reason": "the image returned HTTP 404"
}
],
"failed_count": 1
}
}
JPEG, PNG, GIF and WebP images of up to 30 MB are copied, from
http,httpsanddata:addresses. Only thesrcattribute is read:srcsetand images in CSS are left alone.An image that fails keeps its original address and is listed in
failedwith the reason. The article is saved anyway.Each article copies at most 25 images, each download may take 10 seconds, and all downloads in one request share 45 seconds, across all items of a bulk request.
To upload images yourself and put their addresses in your HTML, use the Images API.
Search engine title and description
metadata sets what search engines and link previews show for the article, per language:
metadata.titleis up to 60 characters andmetadata.descriptionup to 160. Longer values are refused.They become the page's
<title>(followed by your help center's default page title), its meta description and its social preview tags.Only the languages you send change.
nullor""for a language removes its value, and the page goes back to the article's title and the start of its text.metadatatakes onlytitleanddescription. Other settings of the article's search listing are edited in the dashboard, see Set an article's search title, description and URL.
Say why you made a change
POST /v1/articles and PATCH /v1/articles/{articleId} take two optional fields that record why you made a change. The note and links are kept with the change, as part of the help center's activity record. Readers never see them, and no API answer, webhook, search result or help center page carries them.
Field | Type | Description |
|---|---|---|
| string | Plain text, up to 2,000 characters: why you made the change, what it fixes, where it came from. Control characters and invisible characters, such as zero-width and bidirectional marks, are removed, runs of more than two line breaks become two, and the ends are trimmed; a note that is empty after that is ignored. It is never read as markup. |
| array | Up to 5 links that back the note up, such as a ticket or a pull request, each |
An address with another scheme, an IP address,
localhost, a host without a dot (https://intranet/), a user name or password, a space, a control or invisible character, a backslash, or any of< > " `is refused.A bad note or link refuses the whole request, and nothing is written:
POSTanswers400with the fields,PATCHanswers422.A request that changes nothing keeps no note.
POST /v1/articles/bulk, deleting an article and restoring a version ignore both fields without reading them. Saving and publishing staged changes, and publishing a change set, take them too.
{
"change_refs.0.url": ["The change_refs.0.url must start with https://."]
}
Leave team notes on what you changed
PATCH /v1/articles/{articleId} also takes notes, up to 20 private team notes on passages of the text the request writes, and annotate_changes, one note per changed language that highlights what changed. Readers never see them; your team finds them highlighted in the editor. Both need a key with Team notes, and if any quote is refused, nothing is written. See Notes on a content write.
Create or update articles in bulk
/v1/articles/bulkCreate or update up to 50 articles in one request, with a result for each.
Send up to 50 articles in articles. Each item takes the fields of Create an article and runs on its own, in order: items with an external_id that already exists are updated, the rest are created, and one bad item does not stop the others. The same external_id twice in one request is created by the first item and updated by the second.
The answer is always 207 Multi-Status once the request is valid. Read every entry of results: each has the item's index in your list and a status of created, updated or error. meta counts them. A failed item carries its external_id, when it had one, and an error:
| What went wrong |
|---|---|
| The item is not an object. |
| A field is not valid. |
| A language key your help center does not have. |
| Another request is writing an article with the same |
| The article could not be saved. Retry the item. |
This script sends two articles and reports each result. Run it twice: the second run updates the same two articles instead of creating new ones, because each has an external_id.
// Create or update two articles in one request, and report every item.
const API = 'https://api.helpcenter.io/v1';
const articles = [
{
external_id: 'kb/export-your-data',
title: { en: 'Export your data' },
content: { en: 'Open **Settings**, then **Data**, and click **Export**.' },
content_format: 'markdown',
published: true,
},
{
external_id: 'kb/close-your-account',
title: { en: 'Close your account' },
content: { en: 'Export your data, then click **Close account**.' },
content_format: 'markdown',
},
];
async function main() {
const res = await fetch(`${API}/articles/bulk`, {
method: 'POST',
headers: {
apikey: process.env.HELPCENTER_API_KEY,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ articles }),
});
if (res.status !== 207) throw new Error(`Bulk request failed: HTTP ${res.status}`);
const { results, meta } = await res.json();
for (const item of results) {
const key = articles[item.index].external_id;
if (item.status === 'error') {
const details = JSON.stringify(item.error.errors || {});
console.log(`${key}: ${item.error.code}: ${item.error.message} ${details}`);
} else {
console.log(`${key}: ${item.status}, article ${item.article.id}`);
}
}
console.log(`${meta.created} created, ${meta.updated} updated, ${meta.failed} failed`);
if (meta.failed > 0) process.exit(1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
# Create or update two articles in one request, and report every item.
import json
import os
import sys
import urllib.request
API = "https://api.helpcenter.io/v1"
articles = [
{
"external_id": "kb/export-your-data",
"title": {"en": "Export your data"},
"content": {"en": "Open **Settings**, then **Data**, and click **Export**."},
"content_format": "markdown",
"published": True,
},
{
"external_id": "kb/close-your-account",
"title": {"en": "Close your account"},
"content": {"en": "Export your data, then click **Close account**."},
"content_format": "markdown",
},
]
request = urllib.request.Request(
f"{API}/articles/bulk",
data=json.dumps({"articles": articles}).encode(),
method="POST",
headers={
"apikey": os.environ["HELPCENTER_API_KEY"],
"Accept": "application/json",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(request) as response:
if response.status != 207:
sys.exit(f"Bulk request failed: HTTP {response.status}")
body = json.load(response)
for item in body["results"]:
key = articles[item["index"]]["external_id"]
if item["status"] == "error":
error = item["error"]
details = json.dumps(error.get("errors", {}))
print(f"{key}: {error['code']}: {error['message']} {details}")
else:
print(f"{key}: {item['status']}, article {item['article']['id']}")
meta = body["meta"]
print(f"{meta['created']} created, {meta['updated']} updated, {meta['failed']} failed")
sys.exit(1 if meta["failed"] else 0)
<?php
// Create or update two articles in one request, and report every item.
$api = 'https://api.helpcenter.io/v1';
$articles = [
[
'external_id' => 'kb/export-your-data',
'title' => ['en' => 'Export your data'],
'content' => ['en' => 'Open **Settings**, then **Data**, and click **Export**.'],
'content_format' => 'markdown',
'published' => true,
],
[
'external_id' => 'kb/close-your-account',
'title' => ['en' => 'Close your account'],
'content' => ['en' => 'Export your data, then click **Close account**.'],
'content_format' => 'markdown',
],
];
$ch = curl_init("$api/articles/bulk");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['articles' => $articles]),
CURLOPT_HTTPHEADER => [
'apikey: ' . getenv('HELPCENTER_API_KEY'),
'Accept: application/json',
'Content-Type: application/json',
],
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status !== 207) {
fwrite(STDERR, "Bulk request failed: HTTP $status\n");
exit(1);
}
foreach ($body['results'] as $item) {
$key = $articles[$item['index']]['external_id'];
if ($item['status'] === 'error') {
$error = $item['error'];
$details = json_encode($error['errors'] ?? new stdClass());
echo "$key: {$error['code']}: {$error['message']} $details\n";
} else {
echo "$key: {$item['status']}, article {$item['article']['id']}\n";
}
}
$meta = $body['meta'];
echo "{$meta['created']} created, {$meta['updated']} updated, {$meta['failed']} failed\n";
exit($meta['failed'] > 0 ? 1 : 0);
Bulk requests have their own limit: 20 a minute from one IP address, and that count is shared with export and image uploads from the same address. They do not count against the 120 writes a minute of your key. See Import articles in bulk for a complete import script.
Update an article
/v1/articles/{articleId}Change some fields of an article. Fields you leave out keep their values.
Send only the fields you want to change. They take the same values as when you create an article, and the change is live at once, even when the article has staged edits waiting.
What leaving a field out, an empty string and null do
You send | What happens |
|---|---|
Nothing for a field | The field keeps its value. An empty body |
A map without some language, such as | Only English changes. Other languages keep their values. |
| That language is emptied, and reads show |
| Nothing changes. |
|
|
| That language's value is removed. |
| The article becomes a draft and leaves your help center. |
| Ignored. |
| Ignored. An article's key can be replaced, not removed. |
The API trims spaces from the start and end of every string you send, HTML included, and turns an empty string into null before any of the above applies.
Things that surprise people
A new slug keeps the old link working. When a slug changes, the article's old address in that language redirects to the new one, and any earlier address that led to the old one now leads to the new one too. A new title alone keeps the slug. The help center answers the old address itself, so
POST /v1/redirectsrefuses it withsource_not_redirectable(see Redirects)."visibility": "link_only"gives the article ashare_tokenonce. Sending it again keeps the token, so links you already shared keep working.Changes you make over the API are recorded in the article's history without a person's name, and nobody watching the article is emailed about them.
Content you read back from the API has no internal notes, team note highlights or AI drafts waiting for review. If you write that content back, the internal notes and AI drafts are deleted from the article; the highlights of notes that are still open stay where their text is still there. Send only the languages you changed.
categoriestakes a list of category ids (as JSON numbers) and puts the article in exactly those categories, replacing the ones it had; the article then reports"category_id": -1. Acategory_idyou send with it joins that list, counted once."categories": []is ignored. Sending onlycategory_idto an article in several categories leaves it in them, so sendcategorieswith the new list instead. Don't send"category_id": -1yourself: an article with-1and nocategoriescan't be opened. See Show an article in more than one category.
Delete an article
/v1/articles/{articleId}Move an article to Trash.
Deleting moves the article to Trash, like deleting it in the dashboard. Readers get a not-found page at its address and it leaves your help center's search. Anyone who can edit content can restore it, with its history, from Trash in the dashboard (see Delete and restore articles).
The API can't list, restore or permanently delete articles in Trash: their ids answer 404 everywhere. An article in Trash keeps its external_id, so the next POST with that key creates a new article.
Relay reader feedback
/v1/articles/{articleId}/feedbackRecord a reader's vote on an article, for articles you show outside your help center.
When you show articles outside your help center, in your app or a chat tool, send your readers' votes here. They count in the article's ratings right away, the same as votes on your help center (see Article ratings and reader feedback).
Field | Type | Description |
|---|---|---|
| string | Required. |
| string | A stable id for the reader, up to 128 characters, such as a hashed user id. It is stored only as a hash. |
| string | The language the reader read. Default: your default language. A language your help center does not have is recorded as the default language. |
| string | What the reader wrote, up to 1000 characters. It is stored with the vote. The response doesn't repeat it; List reader feedback returns it. |
Each reader has one vote per article and language. The first is
201 Createdwith"action": "created"; a later vote replaces it:200 OKwith"action": "updated".Always send
visitor_key. Without it, the reader is your server's IP address, so every vote you relay for an article counts as one reader who keeps changing their mind.Drafts and private articles take votes too. Any article that is not in Trash works.
List reader feedback
/v1/feedbackList the votes readers left on your articles, with the messages they typed, newest first.
Every vote readers left on your articles, with the message a reader typed after voting: the votes on your help center and the ones you send with Relay reader feedback. It is the feedback log of your dashboard's statistics (see Article ratings and reader feedback), for an integration that takes each "this didn't help" to whoever fixes the article. Any API key can read it, Read only included; an OAuth token needs analytics.read. All parameters are optional:
Parameter | Type | Description |
|---|---|---|
| string | Only this vote: |
| 1 or 0 |
|
| string | Only votes in this language. It must be your default language or one you added, or the request answers |
| integer | Only votes on this article, including one in Trash. An id that isn't one of your help center's articles answers |
| string | Only votes whose |
| integer | The page, from 1, and votes per page, 1 to 100. Default |
meta gives page, per_page, total_pages (at least 1) and items_count, the number of votes that match your filters (see Pagination). Each vote in feedback has these fields:
Field | Description |
|---|---|
| The vote's id. It stays the same when the reader changes their vote or adds a message. |
| The article the vote is on. |
|
|
| The language the reader read the article in. |
| What the reader typed with the vote, or |
| The article's |
| When the vote that counts was cast or last changed. |
| When this reader first voted on this article in this language. |
article.status is one of these:
Status | The article |
|---|---|
| Is published. |
| Isn't published. |
| Is in Trash. Its votes stay in the list, with its title; |
| Is one the dashboard hides from the person behind an OAuth token, a Viewer or Translator. The vote keeps its |
Newest first, by
recorded_at. A reader has one vote per article and language. Changing it, or adding a message after voting, changes that vote: the sameidwith a laterrecorded_at, back at the top of the list. Keep track of votes byidandrecorded_attogether: byidalone you see each reader's vote once and miss what they changed.To poll, send the newest
recorded_atyou have asupdated_since. The moment itself is included, so that vote comes back once more: skip the ones you already have.The list never says who voted: no IP address and no
visitor_key.It reads your help center's records, not the analytics reports, so the analytics limit of 6 requests a minute doesn't apply. It counts against the 300 a minute of every request (see Rate limits).
With an AI agent's key
The key an AI agent gets when it creates a help center (see Migrate a knowledge base as an AI agent) reaches every endpoint on this page except Relay reader feedback and List reader feedback, which answer 403 with agent_not_allowed. A help center nobody has claimed holds up to 500 articles and 100 categories, drafts and Trash included: the request that would go over answers 422 with limit_reached (in a bulk request, in that item's error), and migration_request_endpoint says how to ask for more. While HelpCenter.io's team works on a migration request for the help center, the key's writes answer 409 with migration_in_progress.
Errors
The article endpoints answer errors in several shapes. Match on the status code first:
Status | When | Body |
|---|---|---|
| A query parameter of |
|
| A field of | The fields and messages, with no |
| A language key your help center does not have (create and update) |
|
| The credential is missing or not valid |
|
| A write with a Read only key, or a token without |
|
| No such article on |
|
| An |
|
| A field of |
|
| Too many requests |
|
For every status the API uses, see Errors.