# Articles

_Category: REST API reference_

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](https://developers.helpcenter.io/content/scopes)). Staged edits, versions and restores have their own page, [Staged changes and versions](https://developers.helpcenter.io/content/staged-changes-api).

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](https://developers.helpcenter.io/content/staged-changes-api). |
| `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](https://self.helpcenter.io/content/article-access). |
| `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](https://developers.helpcenter.io/content/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](https://self.helpcenter.io/content/translate-an-article)) or for the whole language with **Release translated drafts** (see [Release translations and change your default language](https://self.helpcenter.io/content/manage-languages)). On Catalyst, a staged edit can release it too (see [Staged changes and versions](https://developers.helpcenter.io/content/staged-changes-api)). `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](https://developers.helpcenter.io/content/export-api) 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](https://developers.helpcenter.io/content/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=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](https://developers.helpcenter.io/content/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](https://developers.helpcenter.io/content/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](https://self.helpcenter.io/content/article-seo).

## 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](https://developers.helpcenter.io/content/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](https://developers.helpcenter.io/content/staged-changes-api)). |
| `"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](https://self.helpcenter.io/content/custom-redirects)). 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](https://self.helpcenter.io/content/multiple-categories).

## 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](https://self.helpcenter.io/content/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](https://self.helpcenter.io/content/article-ratings)).

| 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](https://developers.helpcenter.io/content/errors).

## Related

- [Categories](https://developers.helpcenter.io/content/categories-api)
- [Staged changes and versions](https://developers.helpcenter.io/content/staged-changes-api)
- [Images](https://developers.helpcenter.io/content/images-api)
- [Import articles in bulk](https://developers.helpcenter.io/content/import-articles-in-bulk)
- [Sync articles from a Git repository](https://developers.helpcenter.io/content/sync-articles-from-git)
