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 |
|---|---|---|
| Read the staged copy, for one language or all |
|
| Stage changes for one language |
|
| Publish staged changes |
|
| Discard staged changes |
|
| List earlier published versions |
|
| Put a version back on the live article |
|
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 answers422withNOT_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.updatedwebhook, 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 |
|---|---|---|
| integer | The article. |
| string | The language of this staged copy. |
| object | The staged per-language fields, as plain values: any of |
| object | The staged article-wide fields: any of |
| boolean | Whether publishing also releases this language to readers. |
| string | The version of this language's staged fields. Send it back when you write them. |
| string or null | The version of the article-wide block. |
| string | A fingerprint of the live article the copy started from. You never send it. |
| string | When the copy last changed, ISO 8601 with an offset. |
| object |
|
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.
/v1/articles/{articleId}/stagedRead 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.
/v1/articles/{articleId}/stagedStage 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 |
|---|---|---|
| string | Required. The language to stage, as a supported language code such as |
| string | The title in this language. An empty string or |
| string | The body in this language, as HTML. Markdown is not converted here. |
| 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. |
| string | The URL of this language's audio file. |
| boolean | Marks the slug as written by hand. |
| integer | Article-wide. One of this help center's category ids, or |
| array of integers | Article-wide. Several of this help center's category ids, to file the article in more than one category. |
| string | Article-wide. |
| string | Article-wide. |
| integer | Article-wide. A member of your team. |
| array of integers | Article-wide. The ids of the team members who can read the article while it is private. |
| object | Article-wide. The SEO title (up to 60 characters) and description (up to 160) per language: |
| boolean |
|
| string | The |
| string | The |
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
200with"staged": nullandThese 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": truereleases it when you publish.Do not send
change_set_id. It answers422withCHANGE_SETS_UNAVAILABLE. To put staged changes in a release, add the article to a change set withPOST /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.
Read the staged copy, or keep the response of your last write.
Send
etagwhen you change the language's fields. Sendshared_etagwhen you change article-wide fields, or"none"if your read showed"shared_etag": null.If someone saved in between, you get
409 ConflictwithSTALE_ETAGand 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 |
|---|---|---|
|
| 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 |
|
| The article isn't published. Write it with |
|
| The article was unpublished after changes were staged. The staged changes are kept. |
|
| Someone saved since your read. Read again and retry. |
|
| You sent |
|
| A missing |
|
| 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.
/v1/articles/{articleId}/staged/publishPublish staged changes to the live article.
Field | Type | Description |
|---|---|---|
| string | Optional. The language to publish. Omit it to publish all. |
| boolean | Optional. |
The answer is
200withpublished_localesand the livearticle. 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
409withBASE_CHANGEDand the language concerned. Read the live article and restage, or retry with"force": trueto 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
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.
/v1/articles/{articleId}/stagedDiscard 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.
/v1/articles/{articleId}/versionsList 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.
limitcounts across languages unless you sendlocale: 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.
/v1/articles/{articleId}/versions/{versionId}/restorePut 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 |
|---|---|---|
|
| Staging changes without the feature. |
|
| A Read only key, or a token without |
| (message only) | No such article, staged copy or version. |
|
| Staging with an out-of-date |
|
| Publishing over a live change, without |
|
| Staging on an article that is not live. |
|
| Staging with |
|
| 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.