REST API reference

Staged changes and versions

Export
Download Markdown Use with AI

Stage edits to a published article without readers seeing them, check them, then publish or discard them. Publishing keeps the text it replaced as a version, which you can list and restore.

Plans: Staged changes are part of the Catalyst plan, in early preview. A Catalyst help center that answers STAGING_UNAVAILABLE does not have early-preview access yet: in the dashboard, click Change sets in the left menu, then Request access. API keys and OAuth access tokens both work here.

Endpoints

Method and path

What it does

Scope

GET /v1/articles/{articleId}/staged

Read the staged copy, for one language or all

content.read

PATCH /v1/articles/{articleId}/staged

Stage changes for one language

content.write

POST /v1/articles/{articleId}/staged/publish

Publish staged changes

content.write

DELETE /v1/articles/{articleId}/staged

Discard staged changes

content.write

GET /v1/articles/{articleId}/versions

List earlier published versions

content.read

POST /v1/articles/{articleId}/versions/{versionId}/restore

Put a version back on the live article

content.write

Every one of these, reads included, counts toward the 120 writes a minute of your credential (see Rate limits). To see whether an article has staged changes, read it with GET /v1/articles/{articleId}: has_staged_changes, staged_locales and staged_updated_at tell you (see Articles).

How staging works

  • Only published articles are staged. A draft is already hidden from readers, so you write it with PATCH /v1/articles/{articleId}. Staging a draft answers 422 with NOT_PUBLISHED.

  • One staged copy per article and language. The title, content, URL slug and audio file are staged per language. The category, visibility, type, author, access list and SEO title and description belong to the whole article: you stage them from any language, and they apply to the article when any of its languages is published.

  • Readers see nothing until you publish. Staged changes do not appear on the help center, in its search, sitemap or widget, in AI answers, or in any API read of the article. Staging sends no webhook and does not change the article's updated_at.

  • Publishing is one live write. It updates the article, re-indexes it for search, sends the article.updated webhook, redirects the old URL when the slug changed, and keeps the text it replaced as a version.

  • Your team shares the same copy. A staged copy you create over the API is the one editors see in the dashboard, and the other way round (see Edit live articles safely with staged changes).

The staged object

Field

Type

Description

article_id

integer

The article.

locale

string

The language of this staged copy.

fields

object

The staged per-language fields, as plain values: any of title, content (HTML), slug, audio_file and has_custom_slug. Only the fields you staged.

shared

object

The staged article-wide fields: any of category_id, categories, visibility, type, author_id, shared_with and metadata (title and description maps).

release_translation

boolean

Whether publishing also releases this language to readers.

etag

string

The version of this language's staged fields. Send it back when you write them.

shared_etag

string or null

The version of the article-wide block. null when nothing article-wide is staged.

base_signature

string

A fingerprint of the live article the copy started from. You never send it.

updated_at

string

When the copy last changed, ISO 8601 with an offset.

_links

object

publish, discard and compare, each with a method and a url. compare is the live article.

An empty fields or shared comes back as an empty array, [], not as {}. This staged copy changes the English title and content, and the article's SEO title and description:

{
  "article_id": 597,
  "locale": "en",
  "fields": {
    "title": "Create and set up a board",
    "content": "<p>A board collects the cards your team is working on. Click <strong>New board</strong>, give it a name and choose who can see it.</p><p>To keep cards from moving on too early, add stage gates to the board.</p>"
  },
  "shared": {
    "metadata": {
      "title": {
        "de": "Ein Board erstellen",
        "en": "Create a board"
      },
      "description": {
        "en": "Create a board, choose who can see it and add stage gates."
      }
    }
  },
  "release_translation": false,
  "etag": "27a5b09c-e844-4c03-a090-78d298aac79d",
  "shared_etag": "94ce445b-b2bf-4a04-818b-2d89daf8be12",
  "base_signature": "386c17ffd445d01373cfff5c379199d57a6039ff1dceb13754f626f0f078f808",
  "updated_at": "2026-09-30T08:19:30+00:00",
  "_links": {
    "publish": {
      "method": "POST",
      "url": "https://api.helpcenter.io/v1/articles/597/staged/publish"
    },
    "discard": {
      "method": "DELETE",
      "url": "https://api.helpcenter.io/v1/articles/597/staged?locale=en"
    },
    "compare": {
      "method": "GET",
      "url": "https://api.helpcenter.io/v1/articles/597?locale=en"
    }
  }
}

Read the staged copy

With locale, you get one staged object. Without it, staged is an array with every staged language, sorted by language code. Either way, 404 means nothing is staged, which makes this a quick check before you write.

GET/v1/articles/{articleId}/staged

Read the staged copy of an article: one language, or all of them.

The 404 message is This article has no staged changes., or This article has no staged changes for that language. when you sent locale. An article that does not exist, or belongs to another help center, answers 404 with Article not found.

Stage changes

Send one language's changes. The first write creates the staged copy from the live article; later writes update it. Readers keep seeing the live version.

PATCH/v1/articles/{articleId}/staged

Stage changes to a published article for one language, without readers seeing them.

The body names one locale and gives that language's fields as plain values, not the {"en": …} maps that PATCH /v1/articles/{articleId} takes. The exception is metadata, which uses language maps.

Field

Type

Description

locale

string

Required. The language to stage, as a supported language code such as en, de or pt. Any supported code is accepted, including one your help center has not enabled, so check it before you send it.

title

string

The title in this language. An empty string or null clears it.

content

string

The body in this language, as HTML. Markdown is not converted here.

slug

string

The URL slug, up to 190 characters. HelpCenter.io turns it into a valid, unique slug. Empty makes a new one from the title. A title change alone keeps the current slug.

audio_file

string

The URL of this language's audio file. null removes it.

has_custom_slug

boolean

Marks the slug as written by hand.

category_id

integer

Article-wide. One of this help center's category ids, or 0 for none.

categories

array of integers

Article-wide. Several of this help center's category ids, to file the article in more than one category.

visibility

string

Article-wide. public, private or link_only.

type

string

Article-wide. article or faq.

author_id

integer

Article-wide. A member of your team.

shared_with

array of integers

Article-wide. The ids of the team members who can read the article while it is private.

metadata

object

Article-wide. The SEO title (up to 60 characters) and description (up to 160) per language: {"title": {"en": "…"}, "description": {"en": "…"}}. The languages must be enabled on your help center. Each language you send is merged into what is staged; null or an empty string clears that language.

release_translation

boolean

true: publishing also releases this language to readers. false withdraws that. It never hides a language that is already released.

etag

string

The etag from your last read or write of this language. See below.

shared_etag

string

The shared_etag from your last read or write, or "none" when it was null. See below.

  • Send only what changes. Fields you leave out keep their staged value, or the live value when nothing is staged. Unknown fields are ignored.

  • A write that matches the live article removes the staged copy. When the result equals the live article, the answer is 200 with "staged": null and These values match the live article, so there is nothing to stage., and any staged copy for that language is deleted.

  • Releasing a translation. A translation you add over the API stays hidden from readers until someone releases it. Staging it with "release_translation": true releases it when you publish.

  • Do not send change_set_id. It answers 422 with CHANGE_SETS_UNAVAILABLE. To put staged changes in a release, add the article to a change set with POST /v1/change-sets/{changeSetId}/items (see Change sets).

Avoid overwriting someone else's changes

Two tokens protect a staged copy. etag covers one language's fields, and shared_etag covers the article-wide block. etag changes on every write to that language, shared_etag whenever the article-wide block changes, and editors in the dashboard use the same protocol.

  1. Read the staged copy, or keep the response of your last write.

  2. Send etag when you change the language's fields. Send shared_etag when you change article-wide fields, or "none" if your read showed "shared_etag": null.

  3. If someone saved in between, you get 409 Conflict with STALE_ETAG and the current token. Read the copy again, merge, and retry.

A stale etag answers:

{
  "status": "error",
  "code": "STALE_ETAG",
  "message": "These staged changes were modified since you read them. Fetch them again and retry.",
  "scope": "locale",
  "etag": "da04cfdf-bd1a-413a-a5f9-a06650c73df7"
}

For the article-wide block, scope is shared and the body carries shared_etag instead of etag. Leave both tokens out and the last write wins, even over an editor's work in the dashboard.

Errors

Status

Code or message

When

403

STAGING_UNAVAILABLE

The help center does not have staged changes: it is not on Catalyst, or it does not have early-preview access yet. The message is Staged changes are not included in your current subscription plan. Upgrade to Catalyst to edit a live article without readers seeing the change.

422

NOT_PUBLISHED

The article isn't published. Write it with PATCH /v1/articles/{articleId}.

422

ARTICLE_UNPUBLISHED

The article was unpublished after changes were staged. The staged changes are kept.

409

STALE_ETAG

Someone saved since your read. Read again and retry.

422

CHANGE_SETS_UNAVAILABLE

You sent change_set_id.

422

validation_error

A missing locale (A locale is required: staged changes are held per article and language.), an SEO title over 60 characters, a category or team member that is not this help center's, and similar.

404

Article not found.

No such article in this help center.

Publish staged changes

Apply the staged changes to the live article. Send locale to publish one language, or leave it out to publish every staged language at once.

POST/v1/articles/{articleId}/staged/publish

Publish staged changes to the live article.

Field

Type

Description

locale

string

Optional. The language to publish. Omit it to publish all.

force

boolean

Optional. true publishes even when the live article changed after the edits were staged.

  • The answer is 200 with published_locales and the live article. The article-wide changes are applied whichever language you publish.

  • If someone changed the live article after you staged, and publishing would overwrite that change, you get 409 with BASE_CHANGED and the language concerned. Read the live article and restage, or retry with "force": true to overwrite it.

  • Publishing does not change the article's status. On an article that was unpublished after the changes were staged, it applies the changes and the article stays unpublished.

  • Publishing, discarding and reading staged changes do not check the plan, so a help center that leaves Catalyst can still publish or discard what it had staged.

When publishing is refused, the body is {"status": "error", "message": "…"} with one of these messages:

Status

Message

422

Article 598 has no staged changes., or Article 598 has no staged changes for locale de.

422

These changes would leave the article with no title in any language, so it cannot be published. Give it a title first — the staged copy still holds everything else.

422

User 12 is not on this team, so they cannot be set as the author. Pick a current teammate and save again.

422

Category 5 does not belong to this site.

409

BASE_CHANGED: The live article changed after these edits were staged. Re-read the article, or retry with "force": true to overwrite the live version.

Discard staged changes

Delete the staged copy. Send locale to discard one language, or leave it out to discard all of them. The live article does not change, nothing is sent to readers or webhooks, and a discard cannot be undone.

DELETE/v1/articles/{articleId}/staged

Discard staged changes, leaving the live article as it is.

When you discard one language, the article-wide changes stay staged on the other languages. With nothing to discard, the answer is 404 with This article has no staged changes to discard.

List versions

List the earlier published versions of an article, newest first. Each has the title it had in its language and published_at, the time that text went live.

GET/v1/articles/{articleId}/versions

List earlier published versions of an article, newest first.

  • Versions are created only when staged changes are published and when a version is restored. An article you only ever write with PATCH /v1/articles/{articleId} has none.

  • HelpCenter.io keeps the 20 newest versions of each language.

  • limit counts across languages unless you send locale: the default of 20 can mix languages. Values outside 1 to 100 are brought into that range.

Restore a version

Put a version back on the live article, with no request body. It becomes live at once, re-indexed and announced with the article.updated webhook.

POST/v1/articles/{articleId}/versions/{versionId}/restore

Put an earlier version back on the live article.

  • A version holds one language's title, content, slug and audio file, plus the article-wide fields as they were at that time: category, visibility, type, author, access list and SEO title and description. Restoring puts all of them back.

  • The values it replaces are kept as a new version first, so you can restore those too.

  • Restoring does not change the article's status.

  • A version whose category has since been deleted cannot be restored: the request fails with 500 Internal Server Error.

A version id that belongs to another article answers 404 with No such version for this article. Versions and restores work on every plan, so a help center keeps the history it built while on Catalyst.

Try it end to end

This program creates a private test article, stages an edit, publishes it, restores the first version, then moves the test article to Trash. Readers never see the test article. Run it with a Read & write key in HELPCENTER_API_KEY on a help center that has staged changes:

// Stage an edit, publish it, then restore the version it replaced.
// Works on a private test article, so readers never see it. Node.js 18+.
const API = "https://api.helpcenter.io/v1";

async function api(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: {
      apikey: process.env.HELPCENTER_API_KEY,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${JSON.stringify(data)}`);
  return data;
}

async function main() {
  const { article } = await api("POST", "/articles", {
    title: { en: "Staging test" },
    content: { en: "<p>First version.</p>" },
    visibility: "private",
    published: true,
  });
  const id = article.id;

  // 1. Stage an edit. The live article keeps the first version.
  const { staged } = await api("PATCH", `/articles/${id}/staged`, {
    locale: "en",
    content: "<p>Second version.</p>",
  });
  const live = (await api("GET", `/articles/${id}`)).article;
  console.log(`Staged ${staged.fields.content}, live is still ${live.content.en}`);

  // 2. Publish the staged copy.
  const published = await api("POST", `/articles/${id}/staged/publish`, {});
  console.log(`Published ${published.published_locales}: ${published.article.content.en}`);

  // 3. The text it replaced is now a version. Restore it.
  const { versions } = await api("GET", `/articles/${id}/versions`);
  const restored = await api("POST", `/articles/${id}/versions/${versions[0].id}/restore`);
  console.log(`Restored version ${versions[0].id}: ${restored.article.content.en}`);

  // Clean up: the test article goes to Trash.
  await api("DELETE", `/articles/${id}`);
}

main().catch((err) => {
  console.error(err.message);
  process.exitCode = 1;
});
# Stage an edit, publish it, then restore the version it replaced.
# Works on a private test article, so readers never see it. Python 3, standard library.
import json
import os
import sys
import urllib.error
import urllib.request

API = "https://api.helpcenter.io/v1"


def api(method, path, body=None):
    req = urllib.request.Request(
        API + path,
        method=method,
        data=None if body is None else json.dumps(body).encode(),
        headers={
            "apikey": os.environ["HELPCENTER_API_KEY"],
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req) as res:
            return json.load(res)
    except urllib.error.HTTPError as err:
        sys.exit(f"{method} {path}: {err.code} {err.read().decode()}")


article = api("POST", "/articles", {
    "title": {"en": "Staging test"},
    "content": {"en": "<p>First version.</p>"},
    "visibility": "private",
    "published": True,
})["article"]
article_id = article["id"]

# 1. Stage an edit. The live article keeps the first version.
staged = api("PATCH", f"/articles/{article_id}/staged", {
    "locale": "en",
    "content": "<p>Second version.</p>",
})["staged"]
live = api("GET", f"/articles/{article_id}")["article"]
print(f"Staged {staged['fields']['content']}, live is still {live['content']['en']}")

# 2. Publish the staged copy.
published = api("POST", f"/articles/{article_id}/staged/publish", {})
locales = ", ".join(published["published_locales"])
print(f"Published {locales}: {published['article']['content']['en']}")

# 3. The text it replaced is now a version. Restore it.
versions = api("GET", f"/articles/{article_id}/versions")["versions"]
restored = api("POST", f"/articles/{article_id}/versions/{versions[0]['id']}/restore")
print(f"Restored version {versions[0]['id']}: {restored['article']['content']['en']}")

# Clean up: the test article goes to Trash.
api("DELETE", f"/articles/{article_id}")
<?php
// Stage an edit, publish it, then restore the version it replaced.
// Works on a private test article, so readers never see it. PHP 8+ with curl.
const API = 'https://api.helpcenter.io/v1';

function api(string $method, string $path, ?array $body = null): array
{
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'apikey: ' . getenv('HELPCENTER_API_KEY'),
            'Accept: application/json',
            'Content-Type: application/json',
        ],
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode((object) $body));
    }
    $data = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    if ($status >= 400) {
        fwrite(STDERR, "$method $path: $status " . json_encode($data) . "\n");
        exit(1);
    }
    return $data;
}

$article = api('POST', '/articles', [
    'title' => ['en' => 'Staging test'],
    'content' => ['en' => '<p>First version.</p>'],
    'visibility' => 'private',
    'published' => true,
])['article'];
$id = $article['id'];

// 1. Stage an edit. The live article keeps the first version.
$staged = api('PATCH', "/articles/$id/staged", [
    'locale' => 'en',
    'content' => '<p>Second version.</p>',
])['staged'];
$live = api('GET', "/articles/$id")['article'];
echo "Staged {$staged['fields']['content']}, live is still {$live['content']['en']}\n";

// 2. Publish the staged copy.
$published = api('POST', "/articles/$id/staged/publish", []);
echo 'Published ' . implode(',', $published['published_locales'])
    . ": {$published['article']['content']['en']}\n";

// 3. The text it replaced is now a version. Restore it.
$versions = api('GET', "/articles/$id/versions")['versions'];
$restored = api('POST', "/articles/$id/versions/{$versions[0]['id']}/restore");
echo "Restored version {$versions[0]['id']}: {$restored['article']['content']['en']}\n";

// Clean up: the test article goes to Trash.
api('DELETE', "/articles/$id");

The program prints:

Staged <p>Second version.</p>, live is still <p>First version.</p>
Published en: <p>Second version.</p>
Restored version 48: <p>First version.</p>

Errors

Status

Code

Where

403

STAGING_UNAVAILABLE

Staging changes without the feature.

403

insufficient_scope

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

404

(message only)

No such article, staged copy or version.

409

STALE_ETAG

Staging with an out-of-date etag or shared_etag.

409

BASE_CHANGED

Publishing over a live change, without force.

422

NOT_PUBLISHED, ARTICLE_UNPUBLISHED

Staging on an article that is not live.

422

CHANGE_SETS_UNAVAILABLE

Staging with change_set_id.

422

validation_error or a message

An invalid field, or a publish that cannot be applied.

These endpoints return code, where change sets return reason. For the shapes every endpoint shares, see Errors.

Was this article helpful?