# Staged changes and versions

_Category: REST API reference_

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

## 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](https://self.helpcenter.io/content/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](https://developers.helpcenter.io/content/change-sets-api)).

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

## Related

- [Change sets](https://developers.helpcenter.io/content/change-sets-api)
- [Articles](https://developers.helpcenter.io/content/articles-api)
- [Publish release notes with change sets](https://developers.helpcenter.io/content/publish-release-notes-with-change-sets)
