REST API reference

Articles

Export
Download Markdown Use with AI

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

GET /v1/articles

Lists or searches articles

content.read

GET /v1/articles/{articleId}

Gets one article

content.read

POST /v1/articles

Creates an article, or updates the one with the same external_id

content.write

POST /v1/articles/bulk

Creates or updates up to 50 articles

content.write

PATCH /v1/articles/{articleId}

Changes some fields of an article

content.write

DELETE /v1/articles/{articleId}

Moves an article to Trash

content.write

POST /v1/articles/{articleId}/feedback

Records a reader's vote on an article

content.write

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

id

integer

The article's id.

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

title

object

The title per language, for example {"en": "Download an invoice"}. A language whose title was emptied shows null.

slug

object

The URL slug per language.

author

object

id, name and avatar (an image URL or null) of the author. An article created over the API is authored by the person who created the API key, or who connected the OAuth app.

content

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.

category_id

integer

The article's category. 0 means Uncategorized. -1 means the article is in several categories, listed in categories.

categories

array of integers

The categories of an article that is in several. Empty for an article in one category, whose category is in category_id.

type

string

article or faq. FAQ articles feed the FAQ sections of your help center.

published

boolean

true when the article is live, false for a draft.

has_staged_changes, staged_locales, staged_updated_at

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.

visibility

string

public, private (only the author and the teammates it is shared with) or link_only (anyone with the secret link). See Choose who can read an article.

share_token

string or null

The secret of a link-only article. The link is the article's address followed by ?share= and this token.

views

integer

An all-time counter that no longer counts new views. For current traffic, use views_in_range from the Analytics API.

audio_file

object or null

The audio version per language, when the article has one.

metadata

object

The title and description search engines show: {"title": {...}, "description": {...}}, each a map of languages. A field with no value is {}.

ratings

object

The share of reader votes, in whole percent: thumbs_up, thumbs_down and love, across all languages and all time. Strings such as "60" when the article has votes, the number 0 when it has none.

published_translations

object or array

The additional languages released to readers, as keys: {"de": ...}. The value is true or the time of the release, depending on how it was released, so test for the key. [] when none is released. The default language never appears here.

shared_with

array of integers

The ids of the teammates a private article is shared with.

created_at, updated_at

string

In UTC, as YYYY-MM-DD HH:MM:SS.

_links.view

object

{"method": "GET", "url": ...}, the article's public address. On a help center with several languages it redirects to the reader's language.

Languages and translations

  • Every key in title, slug, content and metadata must be a language your help center has: its default language or an additional one (GET /v1/sites lists 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 lang parameter of GET /v1/articles only affects search.

  • 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_translations tells you what is released.

List articles

GET/v1/articles

List 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

limit

integer

Default 100. From 1 to 100.

page

integer

Default 1. A page past the end returns an empty list.

category_id

integer

Only articles filed in this category, including articles in several categories that list it. Articles in its subcategories are not included. 0 applies no filter. An id that is not one of your categories is a 400.

status

string

published or draft.

updated_since

string

Only articles changed at or after this time. Send UTC, such as 2026-09-30 08:00:00 or 2026-09-30T08:00:00Z, URL-encoded. An offset such as +02:00 is not converted, so the filter would be off by that many hours.

order_by

string

views sorts by all-time views. Without it, the order is last change first.

order_type

string

desc (default) or asc.

search

string

Keywords. Switches the list to what readers can find: see Search below.

lang

string

The language search matches in. Default: your help center's default language. Without search it changes nothing, because the response always holds every 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.

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=draft with search returns nothing.

  • A search returns at most 15 articles, whatever you send in limit and page. items_count counts those 15 at most, and limit and page page through them.

  • The best match comes first. order_by=views sorts 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

GET/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

POST/v1/articles

Create an article, or update the one that already has the same external_id.

Field

Type

Description

title

object

Required. The title per language: {"en": "Request a refund"}. Every title you send needs text: an empty or blank title makes the create fail with 500.

content

object

The body per language, as HTML unless you set content_format. Leave it out for an empty article.

content_format

string

html (default) or markdown. See Write in Markdown, below.

slug

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.

category_id

integer

One of your categories, or 0 for Uncategorized (the default).

type

string

article (the default) or faq.

published

boolean

true publishes the article right away. Default: a draft.

visibility

string

public (the default) or private. For link_only, see below.

external_id

string

Your own key for the article, up to 191 characters. See Create or update by your own key, below.

rehost_images

boolean

true copies images from other websites into your help center. Default false. See Copy images into your help center, below.

metadata

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 null there, such as views, and type when you did not send it. The next read shows 0 and article.

  • "visibility": "link_only" on create does not make the article link-only. It gets a share_token but stays public. Create the article as a draft (leave out published), then send {"visibility": "link_only", "published": true} in one PATCH and read the new share_token from its response. That way readers never see it without the link.

  • categories is not accepted on create: a create that sends it fails with 500 Internal Server Error and nothing is saved. Create the article with category_id, then send categories in a PATCH.

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/articles with a key creates the article: 201 Created with "action": "created".

  • Every later POST with the same key updates that article: 200 OK with "action": "updated". Only the fields you send change, as with PATCH, but title is 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 POST creates 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 Conflict with Another request is currently importing this external_id. Retry shortly.

  • To give an existing article a key, send external_id in a PATCH. That replaces its old key. A key that belongs to another article is refused with 409 and the other article's id in conflicting_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, https and data: addresses. Only the src attribute is read: srcset and images in CSS are left alone.

  • An image that fails keeps its original address and is listed in failed with 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.title is up to 60 characters and metadata.description up 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. null or "" for a language removes its value, and the page goes back to the article's title and the start of its text.

  • metadata takes only title and description. 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

POST/v1/articles/bulk

Create 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:

error.code

What went wrong

invalid_item

The item is not an object.

validation_error

A field is not valid. error.errors names each field and why.

invalid_argument

A language key your help center does not have.

external_id_busy

Another request is writing an article with the same external_id. Retry the item.

persist_failed

The article could not be saved, for example a new article that sends categories.

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

PATCH/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 {} changes nothing and answers 200.

A map without some language, such as {"title": {"en": "..."}}

Only English changes. Other languages keep their values.

"" or null for one language: {"content": {"en": ""}}

That language is emptied, and reads show null for it.

{} for title, slug or content

Nothing changes.

null or "" for a whole field: {"title": null}, {"type": ""}

422 Unprocessable Entity: the field must be an object, a string, a boolean or an integer.

null or "" for one language in metadata.title or metadata.description

That language's value is removed. "metadata": null is ignored.

"published": false

The article becomes a draft and leaves your help center.

"category_id": 0

Ignored. PATCH can't move an article to Uncategorized. On Catalyst, a staged edit can (see Staged changes and versions).

"external_id": null

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 new share_token every time you send it, and links with the old token stop working. Send it once, and leave visibility out 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.

  • categories takes 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 only category_id to an article in several categories leaves it in them, so send categories with the new list instead. Don't send "category_id": -1 yourself: an article with -1 and no categories can't be opened. See Show an article in more than one category.

Delete an article

DELETE/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

POST/v1/articles/{articleId}/feedback

Record 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

vote

string

Required. helpful (score 1), not_helpful (-1) or love (2).

visitor_key

string

A stable id for the reader, up to 128 characters, such as a hashed user id. It is stored only as a hash.

lang

string

The language the reader read. Default: your default language. A language your help center does not have is recorded as the default language.

message

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 Created with "action": "created"; a later vote replaces it: 200 OK with "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

400

A query parameter of GET /v1/articles is not valid

{"status": "validation_error", "errors": {...}}

400

A field of POST /v1/articles is not valid

The fields and messages, with no status: {"title": ["The title field is required."]}

400

A language key your help center does not have (create and update)

{"status": "error", "message": "Unsupported language key \"fr\" provided."}

401

The credential is missing or not valid

{"status": "unauthorized"}

403

A write with a Read only key, or a token without content.write

{"status": "error", "code": "insufficient_scope", "message": "This action requires the content.write scope."}

404

No such article on GET and feedback

{"status": "error", "message": "Article not found."}

404

No such article on PATCH and DELETE

An HTML page, not JSON

409

An external_id belongs to another article, or another request is writing it

{"status": "error", "message": ...}

422

A field of PATCH is not valid

{"message": ..., "errors": {...}}. Without Accept: application/json, a 302 redirect to the API's root instead.

422

A field of the feedback endpoint, or the articles list of a bulk request, is not valid

{"status": "validation_error", "message": "The request could not be accepted.", "errors": {...}}

429

Too many requests

{"message": "Too Many Attempts."} and a Retry-After header

For every status the API uses, see Errors.

Was this article helpful?