Guides and recipes

Publish release notes with change sets

Export
Download Markdown Use with AI

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). 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 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.

Was this article helpful?