Keep your own copy of every category and article in your help center. A short script walks GET /v1/export and saves everything to one dated JSON file, and a scheduler runs it every night.
This is a copy you keep and control, outside HelpCenter.io. For the backups HelpCenter.io makes itself, see Do you back up my content?
Before you start
You need an API key. A Read only key is enough, and the safest choice. Owners and Admins can create one: see Create an API key. The API works on every plan.
You need Node.js 18 or later, or Python 3.8 or later. Neither script needs extra packages.
Step 1: Look at what the export returns
GET /v1/export returns your categories and your articles, with their full content, one page at a time:
curl "https://api.helpcenter.io/v1/export?limit=1" \
-H "apikey: $HELPCENTER_API_KEY" \
-H "Accept: application/json"
The first page holds every category, then the first articles, in the order of their ids. This one asked for a single article, and the categories are shortened to two:
{
"status": "success",
"snapshot": {
"generated_at": "2026-09-30T08:35:50+00:00",
"site_id": 12345,
"helpcenter_id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f"
},
"categories": [
{
"id": 301,
"parent": null,
"name": { "en": "General" },
"description": {
"en": "General information and most frequently asked questions about our product."
},
"icon": null,
"position": 100000,
"privacy": "public",
"created_at": "2026-09-30 08:03:17",
"updated_at": "2026-09-30 08:03:17"
},
{
"id": 340,
"parent": null,
"name": { "en": "Billing" },
"description": [],
"icon": null,
"position": 100000,
"privacy": "public",
"created_at": "2026-09-30 08:34:31",
"updated_at": "2026-09-30 08:34:31"
}
],
"articles": [
{
"id": 553,
"external_id": null,
"title": { "en": "What is Acme?" },
"slug": { "en": "what-is-acme" },
"author": { "id": 42, "name": "Maya Chen", "avatar": null },
"content": {
"en": "<p>Describe your product, app or service in a few sentences to kickstart your help center.</p>"
},
"category_id": 301,
"categories": [],
"type": "article",
"published": false,
"has_staged_changes": false,
"staged_locales": [],
"staged_updated_at": null,
"visibility": "public",
"share_token": null,
"views": 0,
"audio_file": null,
"metadata": { "title": {}, "description": {} },
"ratings": { "thumbs_up": 0, "thumbs_down": 0, "love": 0 },
"published_translations": [],
"shared_with": [],
"created_at": "2026-09-30 08:03:17",
"updated_at": "2026-09-30 08:03:17",
"_links": {
"view": {
"method": "GET",
"url": "https://acme.helpcenter.io/content/what-is-acme"
}
}
}
],
"cursor": { "next": 553, "has_more": true }
}
While cursor.has_more is true, ask for the next page with cursor set to cursor.next, here /v1/export?cursor=553. Later pages have "categories": []. On the last page, has_more is false and next is null.
Parameter | Type | Description |
|---|---|---|
| integer | Default |
| integer | Default |
The Export reference describes every field.
Step 2: Save the backup script
The script asks for 200 articles per page, follows the cursor to the end and writes one file named after the export's date in UTC, such as helpcenter-backup-2026-09-30.json. Save it as backup-helpcenter.mjs or backup_helpcenter.py:
// Save every category and article of a help center to a dated JSON file.
// HELPCENTER_API_KEY=... node backup-helpcenter.mjs
// Node.js 18 or later, no dependencies. A Read only key is enough.
import { writeFileSync } from 'node:fs';
const API = 'https://api.helpcenter.io/v1';
const KEY = process.env.HELPCENTER_API_KEY;
async function get(path) {
for (let attempt = 1; attempt <= 8; attempt++) {
const res = await fetch(API + path, {
headers: { apikey: KEY, Accept: 'application/json' },
});
if (res.ok) return res.json();
if (res.status !== 429) {
const text = await res.text();
throw new Error(`GET ${path}: HTTP ${res.status} ${text.slice(0, 300)}`);
}
const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
console.log(`Rate limited, retrying in ${wait} s`);
await new Promise((done) => setTimeout(done, wait * 1000));
}
throw new Error(`GET ${path}: still rate limited`);
}
async function main() {
if (!KEY) throw new Error('Set HELPCENTER_API_KEY to an API key.');
const backup = { snapshot: null, categories: [], articles: [] };
let cursor = 0;
for (;;) {
const page = await get(`/export?limit=200&cursor=${cursor}`);
backup.snapshot ??= page.snapshot;
backup.categories.push(...page.categories); // only the first page has categories
backup.articles.push(...page.articles);
if (!page.cursor.has_more) break;
cursor = page.cursor.next;
}
const file = `helpcenter-backup-${backup.snapshot.generated_at.slice(0, 10)}.json`;
writeFileSync(file, JSON.stringify(backup, null, 2));
console.log(
`Saved ${backup.categories.length} categories and ` +
`${backup.articles.length} articles to ${file}`,
);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
#!/usr/bin/env python3
"""Save every category and article of a help center to a dated JSON file.
HELPCENTER_API_KEY=... python3 backup_helpcenter.py
Python 3.8 or later, standard library only. A Read only key is enough.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
API = 'https://api.helpcenter.io/v1'
KEY = os.environ.get('HELPCENTER_API_KEY', '')
def get(path):
headers = {'apikey': KEY, 'Accept': 'application/json'}
for attempt in range(1, 9):
request = urllib.request.Request(API + path, headers=headers)
try:
with urllib.request.urlopen(request, timeout=300) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code != 429:
text = error.read().decode('utf-8', 'replace')[:300]
sys.exit(f'GET {path}: HTTP {error.code} {text}')
retry_after = error.headers.get('Retry-After', '')
wait = int(retry_after) if retry_after.isdigit() else 2 ** attempt
print(f'Rate limited, retrying in {wait} s')
time.sleep(wait)
sys.exit(f'GET {path}: still rate limited')
if not KEY:
sys.exit('Set HELPCENTER_API_KEY to an API key.')
backup = {'snapshot': None, 'categories': [], 'articles': []}
cursor = 0
while True:
page = get(f'/export?limit=200&cursor={cursor}')
backup['snapshot'] = backup['snapshot'] or page['snapshot']
backup['categories'] += page['categories'] # only the first page has categories
backup['articles'] += page['articles']
if not page['cursor']['has_more']:
break
cursor = page['cursor']['next']
name = f"helpcenter-backup-{backup['snapshot']['generated_at'][:10]}.json"
with open(name, 'w', encoding='utf-8') as file:
json.dump(backup, file, ensure_ascii=False, indent=2)
print(f"Saved {len(backup['categories'])} categories and "
f"{len(backup['articles'])} articles to {name}")
Step 3: Run it
With your key in the HELPCENTER_API_KEY environment variable:
export HELPCENTER_API_KEY="paste-your-key-here"
node backup-helpcenter.mjs
It reports what it saved:
Saved 5 categories and 10 articles to helpcenter-backup-2026-09-30.json
The file is one JSON object with three keys: snapshot from the first page, then categories and articles, each exactly as the API returned them.
The backup holds drafts, private articles and the share links of link-only articles. Keep it where only your team can read it.
Step 4: Run it every night
On a server, add it to the crontab of a user that can write to the backup folder. Keep the key in a file only that user can read, such as /etc/helpcenter-backup.env with the line export HELPCENTER_API_KEY=…. The second line deletes backups older than 30 days:
# m h dom mon dow command
30 2 * * * cd /var/backups/helpcenter && . /etc/helpcenter-backup.env && /usr/bin/node /opt/helpcenter/backup-helpcenter.mjs >> backup.log 2>&1
45 2 * * * find /var/backups/helpcenter -name 'helpcenter-backup-*.json' -mtime +30 -delete
Cron runs with a short PATH, so give the full path to node. which node prints it.
With GitHub Actions, commit the script as scripts/backup-helpcenter.mjs to a private repository, add the key as a repository secret named HELPCENTER_READ_KEY (Settings → Secrets and variables → Actions), and save this workflow as .github/workflows/backup-helpcenter.yml:
name: Back up HelpCenter.io
on:
schedule:
- cron: '30 2 * * *' # every night at 02:30 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
backup:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- run: node scripts/backup-helpcenter.mjs
env:
HELPCENTER_API_KEY: ${{ secrets.HELPCENTER_READ_KEY }}
- uses: actions/upload-artifact@v7
with:
name: helpcenter-backup
path: helpcenter-backup-*.json
retention-days: 30
Each night's file is attached to its workflow run, under Artifacts, for 30 days. You can also start a backup from the Actions tab, with Run workflow.
What the backup holds
In the backup:
Every article that isn't in the Trash, including drafts, private articles and link-only articles. Each has its title, slug and content in every language, its category, SEO title and description, published state, visibility,
external_id, views and rating shares.Every category, whatever its privacy, with its name, description, icon, position and parent.
The export's time and your help center's id, in
snapshot.
Not in the backup:
Articles in the Trash.
Image files. The content holds the images' addresses, so an image deleted from HelpCenter.io is gone from the backup too.
Version history, internal notes and pending AI drafts. Notes and drafts are taken out of the content.
Reader comments. They have their own endpoint: see Comments.
Your design, settings, team, API keys and interface translations. Interface translations have their own endpoint: see Interface translations.
The export reads your live content page by page, so it isn't a snapshot of a single moment. An article created while the script runs can appear, and one edited after its page was read is saved as it was when read. For a consistent copy, run the backup at a quiet time, such as at night.
Restore from a backup
There is no endpoint that takes a backup file back in one step. You restore by writing the content back through the API, or from the dashboard.
A deleted article. If it's still in the Trash, restore it there, with its history: see Delete and restore articles.
One article's text. To put back an article's title and content as they were in a backup, send them with PATCH /v1/articles/{articleId}. This needs a Read & write key and jq:
BACKUP=helpcenter-backup-2026-09-30.json
ARTICLE_ID=4711
jq --argjson id "$ARTICLE_ID" \
'.articles[] | select(.id == $id) | {title, content}' "$BACKUP" \
| curl -sS -X PATCH "https://api.helpcenter.io/v1/articles/$ARTICLE_ID" \
-H "apikey: $HELPCENTER_API_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data @-
The PATCH writes the live article directly. It replaces the languages the backup holds and keeps any language added since. Internal notes aren't in the backup, so the restored content has none.
A whole help center. Rebuild it in two passes, the way Import articles in bulk does:
Create the categories with
POST /v1/categories, parents first. Sendname,description,iconandpositionfrom the backup, and asparent_idthe new id of the category'sparent. Keep a map from each old id to its new id. Categories created through the API are public, so before step 2, give each category whoseprivacyisn'tpublicits setting again in the dashboard (see Make a category private). Its articles are then never shown to readers who shouldn't see them.Create the articles with
POST /v1/articles/bulk, 50 at a time. Sendtitle,slug,content,type,published,metadataandvisibility(publicorprivate), the new id of their category ascategory_id, and anexternal_idsuch asbackup:553, so you can run the restore again without copies. Leave out any language whose title is empty ornull: an empty title makes the article fail. An article with"category_id": -1is in several categories: send one of itscategoriesascategory_id, then all of them in aPATCHonce it exists. Create each link-only article as a draft, withoutpublishedandvisibility, then send"visibility": "link_only"and, if it was published,"published": truein onePATCH, so readers never see it without the link. It gets a new share link.
Some things don't come back this way:
Each article's author becomes the person who created the API key, and its dates, views and ratings start over.
Version history and comments.
Released translations: release them again in the dashboard (see Release translations and change your default language).
Category privacy: set it again in the dashboard, as in step 1.
The people a private article is shared with.
Troubleshooting
The script prints Rate limited and waits. HelpCenter.io counts export pages, bulk requests and image uploads from one IP address together, and refuses an export page once that count reaches 30 in a minute. The script waits the seconds in the Retry-After header and carries on. At 200 articles per page, a help center with 5,000 articles needs 25 pages. See Rate limits.
Set HELPCENTER_API_KEY to an API key. The environment variable is empty. In GitHub Actions, check that the repository has a secret named HELPCENTER_READ_KEY; with cron, check the env file.
GET /export?limit=200&cursor=0: HTTP 401 {"status":"unauthorized"} The key is wrong or was deleted. Create a new one and update the secret or the env file.
A limit over 200. The API refuses it with 422 Unprocessable Entity: {"status":"validation_error","message":"The request could not be accepted.","errors":{"limit":["The limit field must not be greater than 200."]}}.
The backup has fewer articles than the dashboard shows. Articles in the Trash aren't exported.