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, and send readers' votes back from wherever else you show them.
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 |
|
A Read only API key holds content.read; a Read & write key holds both scopes. OAuth tokens need the scope in the table (see Scopes). Staged edits, versions and restores have their own page, Staged changes and versions.
Send Accept: application/json with every request. Without it, an invalid PATCH is answered with a redirect instead of a JSON error. Some errors are HTML pages even with it: the Errors table at the end of this page lists them.
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.
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 search returns at most 15 articles, whatever you send in
limitandpage.items_countcounts those 15 at most, andlimitandpagepage through them.The best match comes first.
order_by=viewssorts the matches by views instead.In an additional language (
lang=de), only released translations match.On a help center whose default language is not English, a search only returns articles that also have a released English translation. Without English, it returns no results.
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 stored exactly as sent: send lowercase words joined by hyphens, and make each one unique. |
| 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."visibility": "link_only"on create does not make the article link-only. It gets ashare_tokenbut stayspublic. Create the article as a draft (leave outpublished), then send{"visibility": "link_only", "published": true}in onePATCHand read the newshare_tokenfrom its response. That way readers never see it without the link.categoriesis not accepted on create: a create that sends it fails with500 Internal Server Errorand nothing is saved. Create the article withcategory_id, then sendcategoriesin aPATCH.
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. Don't rely on upper and lower case to tell two keys apart.
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 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.
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, for example a new article that sends |
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 creates no redirect. The article's old address answers with a not-found page as soon as the slug changes. If people have the old link, add a redirect in the dashboard (see Redirect old links to the right page). A new title keeps the old slug.
"visibility": "link_only"makes a newshare_tokenevery time you send it, and links with the old token stop working. Send it once, and leavevisibilityout of later updates.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, they are deleted from the article. Send only the languages you changed.
categoriestakes a list of category ids (as JSON numbers) and puts the article in exactly those categories; the article then reports"category_id": -1."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 and not returned. |
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.
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 |
|
| No such article on | An HTML page, not JSON |
| An |
|
| A field of |
|
| A field of the feedback endpoint, or the |
|
| Too many requests |
|
For every status the API uses, see Errors.