REST API reference

Articles

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

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

GET /v1/feedback

Lists readers' votes and the messages they typed, newest first

analytics.read

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

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. name and avatar are null when that account no longer exists, for example a team member who left; id stays.

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. A live article reaches readers once the help center is published too: GET /v1/sites reports that as published.

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. It opens for readers once the article and the help center are published; before that it answers 404 to everyone but the help center's team.

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: your help center's default language (the default) or one of its additional languages (GET /v1/sites lists them). Any other value is a 422. Without search it changes nothing and is not checked, 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. 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.

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 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_count counts the matches you can page through, and limit (up to 100) and page page through them: see Pagination.

  • The best match comes first. order_by=views sorts the matches by views instead.

  • A search matches in your default language, or in the language lang names. In an additional language (lang=de), only released translations match.

  • A lang your help center does not have, such as lang=xx, is refused with 422 and "status": "validation_error". errors.lang reads That 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

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 is refused with 400 naming the language, such as title.en, and nothing is saved.

content

object

The body per language, as HTML unless you set content_format. Leave it out for an empty article. Markup that would run script on your help center's pages is removed on save, such as <script> elements, event attributes like onclick and javascript: addresses. <style> elements are removed too.

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

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), private, or link_only: not listed or searchable, and open only with the link that carries its share_token.

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.

  • categories files the article under several categories. Send a list of category ids, as JSON numbers: the article reports "category_id": -1 and the ids in categories. Send category_id as well and it joins the list, counted once, so "category_id": 331, "categories": [332] files the article under both. Without categories, category_id files 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/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, then matched exactly: KB/Export and kb/export are two keys, and so are cafe and café.

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

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

change_note

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.

change_refs

array

Up to 5 links that back the note up, such as a ticket or a pull request, each {"label": "...", "url": "..."} with exactly those two keys. The label is up to 80 characters on one line. The url is an https:// address of a named host, up to 2,048 characters. Two links with the same address are stored once. You can send links without a note.

  • 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: POST answers 400 with the fields, PATCH answers 422.

  • 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

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

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. A title can't be emptied: an empty or blank title is refused with 422.

{} 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 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/redirects refuses it with source_not_redirectable (see Redirects).

  • "visibility": "link_only" gives the article a share_token once. 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.

  • categories takes 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. A category_id you send with it joins that list, counted once. "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. The response doesn't repeat it; List reader feedback returns it.

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

List reader feedback

GET/v1/feedback

List 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

vote

string

Only this vote: helpful, not_helpful or love.

with_message

1 or 0

1 or true: only votes that came with a message. 0 or false is the same as leaving it out; it doesn't pick the votes without one.

lang

string

Only votes in this language. It must be your default language or one you added, or the request answers 422.

article_id

integer

Only votes on this article, including one in Trash. An id that isn't one of your help center's articles answers 404, not an empty list.

updated_since

string

Only votes whose recorded_at is at or after this moment: a date such as 2026-11-01, or a date and time such as 2026-11-01T09:00:00Z. It reads an offset; without one, the time is UTC.

page, limit

integer

The page, from 1, and votes per page, 1 to 100. Default limit: 25.

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

id

The vote's id. It stays the same when the reader changes their vote or adds a message.

article_id

The article the vote is on.

vote, score

helpful (score 1), not_helpful (-1) or love (2). vote is null only for a score that none of the three stands for.

lang

The language the reader read the article in.

message

What the reader typed with the vote, or null. It is plain text, exactly as typed, and readers can type anything, markup and links included: escape it before you show it in a page.

article

The article's id, title, status and url, or null if the article no longer exists. title is in the vote's language when the article has one, otherwise in another of its languages.

recorded_at

When the vote that counts was cast or last changed.

created_at

When this reader first voted on this article in this language.

article.status is one of these:

Status

The article

published

Is published. url is its address on your help center.

draft

Isn't published. url is null.

deleted

Is in Trash. Its votes stay in the list, with its title; url is null.

private

Is one the dashboard hides from the person behind an OAuth token, a Viewer or Translator. The vote keeps its vote, lang and dates, but title, url and message are null, as in that person's feedback log in the dashboard, and that article's id as article_id answers 404. An API key never gets this status.

  • 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 same id with a later recorded_at, back at the top of the list. Keep track of votes by id and recorded_at together: by id alone you see each reader's vote once and miss what they changed.

  • To poll, send the newest recorded_at you have as updated_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

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" and a docs_url link

403

A write with a Read only key, or a token without content.write; on GET /v1/feedback, a token without analytics.read

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

404

No such article on GET, PATCH, DELETE and Relay reader feedback, or an article_id on GET /v1/feedback that isn't one of your help center's articles

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

409

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

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

422

A field of PATCH or of Relay reader feedback, a parameter of GET /v1/feedback (such as a lang your help center doesn't have), 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?