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:
curlandjqfor the steps, and Node.js 18 or later, Python 3 or PHP 8 with the curl extension for the complete script. SendAccept: application/jsonon 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_idis not in your help center yet is created as a draft with thatexternal_id, in the category named bycategory. 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
openchange set can be scheduled, so when that change set ispartially_publishedorfailedandRELEASE_ATis set, the script stops instead of scheduling it: run it again withoutRELEASE_ATto 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 theRetry-Afterheader 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 |
|---|---|
| You are sending an OAuth access token. Change sets need an |
| The help center is not on Catalyst, or does not have early-preview access yet. See Before you start. |
| The article is a draft. Write it with |
| The article has nothing staged: its edit was already published, discarded, or equal to the live text. |
A | Someone changed the live article after you staged. Discard the staged copy, stage again, and add the article again. |
| Read |
A schedule that never publishes | Only |
| Every change-set request, status polls included, counts toward 120 writes a minute. Poll every few seconds, not in a tight loop. |