# Publish release notes with change sets

_Category: Guides and recipes_

Put your release notes and every article a release changes live at the moment the release ships: stage the edits, collect them in a change set, check it, then publish or schedule it and wait until it is live.

## Before you start

- **Plan:** staged changes and change sets are part of the Catalyst plan, in early preview. If your Catalyst help center answers `STAGING_UNAVAILABLE`, click **Change sets** in the dashboard's left menu, then **Request access**.
- **Key:** a Read & write API key, kept on your server or in your CI secrets (see [API keys](https://developers.helpcenter.io/content/api-keys)). Change sets do not accept OAuth access tokens.
- **Content:** the articles your release changes are published, so you stage edits to them. New articles, such as the release notes, start as drafts.
- **Tools:** `curl` and `jq` for the steps, and Node.js 18 or later, Python 3 or PHP 8 with the curl extension for the complete script. Send `Accept: application/json` on every request.

## 1. Stage the edits to live articles

For each article the release changes, stage the new text, one language per request. Readers keep seeing the live version until the change set publishes.

```
curl -sS -X PATCH "https://api.helpcenter.io/v1/articles/598/staged" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"locale": "en", "content": "<p>Drag a card to the next column to move it to that stage. If the column has a stage gate, the card moves once its checklist is complete.</p>"}'
```

The answer is `200` with the staged copy. When the values match the live article, `staged` is `null` and there is nothing to release for that article. See [Staged changes and versions](https://developers.helpcenter.io/content/staged-changes-api) for every field you can stage.

## 2. Write the new articles as drafts

Create the release notes, and any other new article, without `published`, so it stays a draft until the release. An `external_id` lets you run the same step again without creating a second article.

```
curl -sS -X POST "https://api.helpcenter.io/v1/articles" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "boards/stage-gates", "title": {"en": "Add stage gates to a board"}, "content": {"en": "<p>A stage gate stops a card from leaving a stage until its checklist is complete.</p>"}, "category_id": 307}'
```

The answer is `201 Created` with `"action": "created"` and the article, whose `published` is `false`. Keep its `id`. To launch a new section with the release, have your team share its category with **Team members only** in the meantime.

## 3. Create the change set

```
curl -sS -X POST "https://api.helpcenter.io/v1/change-sets" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name": "Release 3.4", "description": "Stage gates for boards"}'
```

The answer is `201 Created` with the empty change set. Keep `change_set.id`; the examples below use `3`.

## 4. Add the articles and categories

Add the articles you staged, the new drafts and the categories to show. A staged article adds one item per staged language, a draft adds one item that publishes it, and a category adds one item that sets its visibility:

```
curl -sS -X POST "https://api.helpcenter.io/v1/change-sets/3/items" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"article_ids": [597, 598, 600], "category_ids": [307], "category_visibility": "public"}'
```

Here article 597 has English and German edits staged, 598 has English edits, and 600 is a draft, so the answer adds 5 items: `"added": 5`. When an id cannot be added, for example a live article with nothing staged, the answer is `207` and `rejected` says which and why. The rest of the request still applies.

## 5. Check that it is ready

Every response with the change set includes `readiness`, and `GET /v1/change-sets/3` returns it too:

```
{
  "counts": {
    "staged_version": 3,
    "article_publish": 1,
    "category_visibility": 1
  },
  "locales": [
    "de",
    "en"
  ],
  "articles": 3,
  "blockers": [],
  "warnings": [
    {
      "article_id": 598,
      "article_title": "Move cards between stages",
      "reason": "locale_gap",
      "missing_locales": [
        "de"
      ],
      "message": "This article has no de changes, but the release includes de."
    }
  ],
  "publishable": true
}
```

Blockers stop the publish: a `conflict` means someone changed the live article after you staged, and `article_gone` or `category_gone` means the work was deleted. Warnings never stop it; this one says the release changes German text but article 598 ships in English only. Decide whether that is what you want.

## 6. Publish now, or schedule

To publish now, send only the release note:

```
curl -sS -X POST "https://api.helpcenter.io/v1/change-sets/3/publish" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"release_note": "Stage gates for boards"}'
```

The answer is `202 Accepted`, because the release runs after the response. The change set is shortened here:

```
{
  "status": "queued",
  "change_set": {
    "id": 3,
    "status": "publishing",
    "processed_items": 0,
    "total_items": 5
  },
  "status_url": "https://api.helpcenter.io/v1/change-sets/3/status"
}
```

To schedule instead, add `"scheduled_for": "2026-10-06T07:00:00Z"`. The answer is `202` with `"status": "scheduled"`, and the release publishes at the first minute at or after that time. Cancel it with `DELETE /v1/change-sets/3/schedule`.

## 7. Wait for the run and check the result

Poll `status_url` every few seconds until `done` is `true`:

```
until [ "$(curl -sS "https://api.helpcenter.io/v1/change-sets/3/status" \
  -H "apikey: $HELPCENTER_API_KEY" -H "Accept: application/json" | jq .done)" = true ]; do
  sleep 5
done
```

Then read `change_set_status`. `published` means every item applied. Two of the five items are shown here:

```
{
  "status": "success",
  "change_set_status": "published",
  "done": true,
  "processed_items": 5,
  "total_items": 5,
  "published_at": "2026-09-30T08:21:52+00:00",
  "items": [
    {
      "id": 2,
      "type": "staged_version",
      "article_id": 597,
      "locale": "de",
      "variant": "default",
      "applied": true,
      "error": null
    },
    {
      "id": 3,
      "type": "staged_version",
      "article_id": 597,
      "locale": "en",
      "variant": "default",
      "applied": true,
      "error": null
    }
  ]
}
```

## 8. Fix what failed, and publish again

Items apply one at a time, so a release can end `partially_published`: the items that worked are live, and each failed item has an `error`, such as `The live article changed after version 27 (en) was staged.` For that conflict, discard the article's staged copy, stage your edit again on top of the current live text, add the article to the change set again, and publish again. Items that already applied are skipped, so nothing is published twice.

## The complete script

This script does steps 1 to 8 for the release described at its top. It finds your articles by `external_id`, stages edits to the published ones, writes the others as drafts, and ships them in one change set. Put your own release in `RELEASE`, or build that list from your repository or changelog:

```
// Ship a docs release with a change set. Node.js 18+, no dependencies.
const API = "https://api.helpcenter.io/v1";
const KEY = process.env.HELPCENTER_API_KEY;

// The release. Build this list from your repository or changelog.
const RELEASE = {
  name: "Release 3.4",
  note: "Stage gates for boards",
  locale: "en",
  publishAt: process.env.RELEASE_AT || null, // for example 2026-10-06T07:00:00Z
  articles: [
    {
      externalId: "release-notes/3.4",
      category: "Release notes",
      title: "3.4 release notes",
      content:
        "<p>Boards get stage gates, so a card waits until its checklist is done.</p>",
    },
    {
      externalId: "boards/move-cards",
      category: "Getting started",
      title: "Move cards between stages",
      content:
        "<p>Drag a card to the next column. " +
        "A stage gate holds it until its checklist is done.</p>",
    },
  ],
};

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function api(method, path, body) {
  for (;;) {
    const res = await fetch(API + path, {
      method,
      headers: {
        apikey: KEY,
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    if (res.status === 429) {
      await sleep(1000 * Number(res.headers.get("retry-after") || 5));
      continue;
    }
    const data = await res.json();
    if (!res.ok) {
      throw new Error(`${method} ${path}: ${res.status} ${JSON.stringify(data)}`);
    }
    return data;
  }
}

async function allPages(path, key) {
  const items = [];
  for (let page = 1; ; page++) {
    const data = await api("GET", `${path}?limit=100&page=${page}`);
    items.push(...data[key]);
    if (page >= data.meta.total_pages) return items;
  }
}

async function main() {
  const { locale } = RELEASE;
  const articles = await allPages("/articles", "articles");
  const byExternalId = new Map(articles.map((a) => [a.external_id, a]));
  const categories = await allPages("/categories", "categories");
  const categoryId = (name) => {
    const category = categories.find((c) => c.name[locale] === name);
    if (!category) throw new Error(`No category named "${name}".`);
    return category.id;
  };

  // 1. Stage edits to live articles. Write new articles as drafts.
  const ids = [];
  for (const doc of RELEASE.articles) {
    const live = byExternalId.get(doc.externalId);
    if (live?.published) {
      const { staged } = await api("PATCH", `/articles/${live.id}/staged`, {
        locale,
        title: doc.title,
        content: doc.content,
      });
      console.log(staged ? `Staged: ${doc.title}` : `Unchanged: ${doc.title}`);
      if (staged) ids.push(live.id);
    } else {
      const { article } = await api("POST", "/articles", {
        external_id: doc.externalId,
        title: { [locale]: doc.title },
        content: { [locale]: doc.content },
        category_id: categoryId(doc.category),
      });
      console.log(`Draft: ${doc.title}`);
      ids.push(article.id);
    }
  }
  if (ids.length === 0) return console.log("Nothing to release.");

  // 2. Reuse an unfinished change set with this name, or create one.
  const { change_sets: sets } = await api("GET", "/change-sets?per_page=100");
  let set = sets.find((s) => s.name === RELEASE.name && s.status !== "published");
  if (!set) {
    ({ change_set: set } = await api("POST", "/change-sets", {
      name: RELEASE.name,
      description: RELEASE.note,
    }));
  }

  // 3. Add the articles, then check readiness.
  const added = await api("POST", `/change-sets/${set.id}/items`, { article_ids: ids });
  for (const r of added.rejected) console.log(`Not added: ${JSON.stringify(r)}`);
  const { readiness } = added.change_set;
  for (const w of readiness.warnings) console.log(`Warning: ${w.message}`);
  if (!readiness.publishable) {
    for (const b of readiness.blockers) console.log(`Blocker: ${b.message}`);
    throw new Error(`Change set ${set.id} cannot publish yet.`);
  }

  // 4. Publish now, or schedule. Only an open change set can be scheduled.
  const body = { release_note: RELEASE.note };
  if (RELEASE.publishAt) {
    if (set.status !== "open") {
      throw new Error(
        `Change set ${set.id} is ${set.status}: publish it without RELEASE_AT.`,
      );
    }
    body.scheduled_for = RELEASE.publishAt;
  }
  const publish = await api("POST", `/change-sets/${set.id}/publish`, body);
  if (publish.status === "scheduled") {
    return console.log(`Scheduled for ${publish.change_set.scheduled_for}`);
  }

  // 5. Wait until the run is done, then report.
  let status;
  do {
    await sleep(3000);
    status = await api("GET", `/change-sets/${set.id}/status`);
  } while (!status.done);
  console.log(`Change set ${set.id}: ${status.change_set_status}`);
  for (const item of status.items) {
    if (item.error) console.log(`Item ${item.id} failed: ${item.error}`);
  }
  if (status.change_set_status !== "published") process.exitCode = 1;
}

main().catch((err) => {
  console.error(err.message);
  process.exitCode = 1;
});
```

```
# Ship a docs release with a change set. Python 3, standard library only.
import json
import os
import sys
import time
import urllib.error
import urllib.request

API = "https://api.helpcenter.io/v1"
KEY = os.environ["HELPCENTER_API_KEY"]

# The release. Build this list from your repository or changelog.
RELEASE = {
    "name": "Release 3.4",
    "note": "Stage gates for boards",
    "locale": "en",
    "publish_at": os.environ.get("RELEASE_AT"),  # for example 2026-10-06T07:00:00Z
    "articles": [
        {
            "external_id": "release-notes/3.4",
            "category": "Release notes",
            "title": "3.4 release notes",
            "content": "<p>Boards get stage gates, "
            "so a card waits until its checklist is done.</p>",
        },
        {
            "external_id": "boards/move-cards",
            "category": "Getting started",
            "title": "Move cards between stages",
            "content": "<p>Drag a card to the next column. "
            "A stage gate holds it until its checklist is done.</p>",
        },
    ],
}

def api(method, path, body=None):
    data = None if body is None else json.dumps(body).encode()
    headers = {
        "apikey": KEY,
        "Accept": "application/json",
        "Content-Type": "application/json",
    }
    while True:
        req = urllib.request.Request(API + path, data=data, method=method, headers=headers)
        try:
            with urllib.request.urlopen(req) as res:
                return json.load(res)
        except urllib.error.HTTPError as err:
            if err.code == 429:
                time.sleep(int(err.headers.get("Retry-After", "5")))
                continue
            sys.exit(f"{method} {path}: {err.code} {err.read().decode()}")

def all_pages(path, key):
    items, page = [], 1
    while True:
        data = api("GET", f"{path}?limit=100&page={page}")
        items += data[key]
        if page >= data["meta"]["total_pages"]:
            return items
        page += 1

def main():
    locale = RELEASE["locale"]
    by_external_id = {a["external_id"]: a for a in all_pages("/articles", "articles")}
    categories = all_pages("/categories", "categories")

    def category_id(name):
        for category in categories:
            if category["name"].get(locale) == name:
                return category["id"]
        sys.exit(f'No category named "{name}".')

    # 1. Stage edits to live articles. Write new articles as drafts.
    ids = []
    for doc in RELEASE["articles"]:
        live = by_external_id.get(doc["external_id"])
        if live and live["published"]:
            staged = api("PATCH", f"/articles/{live['id']}/staged", {
                "locale": locale, "title": doc["title"], "content": doc["content"],
            })["staged"]
            print(("Staged: " if staged else "Unchanged: ") + doc["title"])
            if staged:
                ids.append(live["id"])
        else:
            article = api("POST", "/articles", {
                "external_id": doc["external_id"],
                "title": {locale: doc["title"]},
                "content": {locale: doc["content"]},
                "category_id": category_id(doc["category"]),
            })["article"]
            print("Draft: " + doc["title"])
            ids.append(article["id"])
    if not ids:
        print("Nothing to release.")
        return

    # 2. Reuse an unfinished change set with this name, or create one.
    sets = api("GET", "/change-sets?per_page=100")["change_sets"]
    change_set = next((s for s in sets
                       if s["name"] == RELEASE["name"] and s["status"] != "published"), None)
    if change_set is None:
        change_set = api("POST", "/change-sets", {
            "name": RELEASE["name"], "description": RELEASE["note"],
        })["change_set"]
    set_id = change_set["id"]

    # 3. Add the articles, then check readiness.
    added = api("POST", f"/change-sets/{set_id}/items", {"article_ids": ids})
    for rejected in added["rejected"]:
        print("Not added: " + json.dumps(rejected))
    readiness = added["change_set"]["readiness"]
    for warning in readiness["warnings"]:
        print("Warning: " + warning["message"])
    if not readiness["publishable"]:
        for blocker in readiness["blockers"]:
            print("Blocker: " + blocker["message"])
        sys.exit(f"Change set {set_id} cannot publish yet.")

    # 4. Publish now, or schedule. Only an open change set can be scheduled.
    body = {"release_note": RELEASE["note"]}
    if RELEASE["publish_at"]:
        if change_set["status"] != "open":
            sys.exit(f"Change set {set_id} is {change_set['status']}: "
                     "publish it without RELEASE_AT.")
        body["scheduled_for"] = RELEASE["publish_at"]
    published = api("POST", f"/change-sets/{set_id}/publish", body)
    if published["status"] == "scheduled":
        print("Scheduled for " + published["change_set"]["scheduled_for"])
        return

    # 5. Wait until the run is done, then report.
    while True:
        time.sleep(3)
        status = api("GET", f"/change-sets/{set_id}/status")
        if status["done"]:
            break
    print(f"Change set {set_id}: {status['change_set_status']}")
    for item in status["items"]:
        if item["error"]:
            print(f"Item {item['id']} failed: {item['error']}")
    if status["change_set_status"] != "published":
        sys.exit(1)

main()
```

```
<?php
// Ship a docs release with a change set. PHP 8+ with the curl extension.
const API = 'https://api.helpcenter.io/v1';

// The release. Build this list from your repository or changelog.
$release = [
    'name' => 'Release 3.4',
    'note' => 'Stage gates for boards',
    'locale' => 'en',
    'publish_at' => getenv('RELEASE_AT') ?: null, // for example 2026-10-06T07:00:00Z
    'articles' => [
        [
            'external_id' => 'release-notes/3.4',
            'category' => 'Release notes',
            'title' => '3.4 release notes',
            'content' => '<p>Boards get stage gates, '
                . 'so a card waits until its checklist is done.</p>',
        ],
        [
            'external_id' => 'boards/move-cards',
            'category' => 'Getting started',
            'title' => 'Move cards between stages',
            'content' => '<p>Drag a card to the next column. '
                . 'A stage gate holds it until its checklist is done.</p>',
        ],
    ],
];

function api(string $method, string $path, ?array $body = null): array
{
    while (true) {
        $ch = curl_init(API . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => 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($body));
        }
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $headers = substr($raw, 0, curl_getinfo($ch, CURLINFO_HEADER_SIZE));
        $data = json_decode(substr($raw, strlen($headers)), true);
        if ($status === 429) {
            preg_match('/^retry-after:\s*(\d+)/mi', $headers, $m);
            sleep((int) ($m[1] ?? 5));
            continue;
        }
        if ($status >= 400) {
            fwrite(STDERR, "$method $path: $status " . json_encode($data) . "\n");
            exit(1);
        }
        return $data;
    }
}

function allPages(string $path, string $key): array
{
    $items = [];
    for ($page = 1; ; $page++) {
        $data = api('GET', "$path?limit=100&page=$page");
        $items = array_merge($items, $data[$key]);
        if ($page >= $data['meta']['total_pages']) {
            return $items;
        }
    }
}

$locale = $release['locale'];
$byExternalId = array_column(allPages('/articles', 'articles'), null, 'external_id');
$categories = allPages('/categories', 'categories');
$categoryId = function (string $name) use ($categories, $locale): int {
    foreach ($categories as $category) {
        if (($category['name'][$locale] ?? null) === $name) {
            return $category['id'];
        }
    }
    fwrite(STDERR, "No category named \"$name\".\n");
    exit(1);
};

// 1. Stage edits to live articles. Write new articles as drafts.
$ids = [];
foreach ($release['articles'] as $doc) {
    $live = $byExternalId[$doc['external_id']] ?? null;
    if ($live && $live['published']) {
        $staged = api('PATCH', "/articles/{$live['id']}/staged", [
            'locale' => $locale,
            'title' => $doc['title'],
            'content' => $doc['content'],
        ])['staged'];
        echo ($staged ? 'Staged: ' : 'Unchanged: ') . $doc['title'] . "\n";
        if ($staged) {
            $ids[] = $live['id'];
        }
    } else {
        $article = api('POST', '/articles', [
            'external_id' => $doc['external_id'],
            'title' => [$locale => $doc['title']],
            'content' => [$locale => $doc['content']],
            'category_id' => $categoryId($doc['category']),
        ])['article'];
        echo "Draft: {$doc['title']}\n";
        $ids[] = $article['id'];
    }
}
if (!$ids) {
    echo "Nothing to release.\n";
    exit(0);
}

// 2. Reuse an unfinished change set with this name, or create one.
$set = null;
foreach (api('GET', '/change-sets?per_page=100')['change_sets'] as $candidate) {
    if ($candidate['name'] === $release['name'] && $candidate['status'] !== 'published') {
        $set = $candidate;
        break;
    }
}
$set ??= api('POST', '/change-sets', [
    'name' => $release['name'],
    'description' => $release['note'],
])['change_set'];

// 3. Add the articles, then check readiness.
$added = api('POST', "/change-sets/{$set['id']}/items", ['article_ids' => $ids]);
foreach ($added['rejected'] as $rejected) {
    echo 'Not added: ' . json_encode($rejected) . "\n";
}
$readiness = $added['change_set']['readiness'];
foreach ($readiness['warnings'] as $warning) {
    echo "Warning: {$warning['message']}\n";
}
if (!$readiness['publishable']) {
    foreach ($readiness['blockers'] as $blocker) {
        echo "Blocker: {$blocker['message']}\n";
    }
    fwrite(STDERR, "Change set {$set['id']} cannot publish yet.\n");
    exit(1);
}

// 4. Publish now, or schedule. Only an open change set can be scheduled.
$body = ['release_note' => $release['note']];
if ($release['publish_at']) {
    if ($set['status'] !== 'open') {
        fwrite(STDERR, "Change set {$set['id']} is {$set['status']}: "
            . "publish it without RELEASE_AT.\n");
        exit(1);
    }
    $body['scheduled_for'] = $release['publish_at'];
}
$published = api('POST', "/change-sets/{$set['id']}/publish", $body);
if ($published['status'] === 'scheduled') {
    echo "Scheduled for {$published['change_set']['scheduled_for']}\n";
    exit(0);
}

// 5. Wait until the run is done, then report.
do {
    sleep(3);
    $status = api('GET', "/change-sets/{$set['id']}/status");
} while (!$status['done']);
echo "Change set {$set['id']}: {$status['change_set_status']}\n";
foreach ($status['items'] as $item) {
    if ($item['error']) {
        echo "Item {$item['id']} failed: {$item['error']}\n";
    }
}
exit($status['change_set_status'] === 'published' ? 0 : 1);
```

Run it with your key, for example `HELPCENTER_API_KEY=… node release.js`. Set `RELEASE_AT` to an ISO 8601 time to schedule the release instead of publishing it at once. A first run prints:

```
Draft: 3.4 release notes
Staged: Move cards between stages
Change set 6: published
```

A second run finds nothing left to change and prints `Nothing to release.` What the script does, besides the steps above:

- An article whose `external_id` is not in your help center yet is created as a draft with that `external_id`, in the category named by `category`. The script stops if no category has that name.
- An article whose staged copy would equal the live version is skipped (`Unchanged`).
- It reuses an unfinished change set with the release's name, so once you fix what failed, running it again resumes that release instead of starting another. Only an `open` change set can be scheduled, so when that change set is `partially_published` or `failed` and `RELEASE_AT` is set, the script stops instead of scheduling it: run it again without `RELEASE_AT` to publish now.
- It stops before publishing when readiness has a blocker, and exits with status 1 when the release does not end `published`, so your pipeline notices.
- It waits and retries when it meets a `429`, for as long as the `Retry-After` header says.

Run it from your deploy pipeline once the release is out, or run it earlier with `RELEASE_AT` set to the launch time.

## Troubleshooting

| What you see | What to do |
| --- | --- |
| `401` with `Unknown API key.` on a change-set call | You are sending an OAuth access token. Change sets need an `apikey` header with a Read & write key. |
| `403` with `STAGING_UNAVAILABLE`, or `402` with `CHANGE_SETS_UNAVAILABLE` | The help center is not on Catalyst, or does not have early-preview access yet. See Before you start. |
| `422` with `NOT_PUBLISHED` when you stage | The article is a draft. Write it with `PATCH /v1/articles/{articleId}` and add it to the change set: it publishes with the release. |
| `rejected` with `… is live and has no staged changes, so there is nothing to release.` | The article has nothing staged: its edit was already published, discarded, or equal to the live text. |
| A `conflict` blocker | Someone changed the live article after you staged. Discard the staged copy, stage again, and add the article again. |
| `partially_published` or `failed` | Read `items[].error`, fix those items, and publish again. |
| A schedule that never publishes | Only `open` change sets publish on schedule. Publish a `partially_published` or `failed` change set now instead. |
| `429` | Every change-set request, status polls included, counts toward 120 writes a minute. Poll every few seconds, not in a tight loop. |

## Related

- [Change sets](https://developers.helpcenter.io/content/change-sets-api)
- [Staged changes and versions](https://developers.helpcenter.io/content/staged-changes-api)
