# Back up your help center

_Category: Guides and recipes_

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?](https://self.helpcenter.io/content/do-you-create-a-backup-of-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](https://self.helpcenter.io/content/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 |
| --- | --- | --- |
| `cursor` | integer | Default `0`, the first page. The page starts after the article with this id. |
| `limit` | integer | Default `100`. Articles per page, from 1 to 200. |

The [Export](https://developers.helpcenter.io/content/export-api) 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](https://developers.helpcenter.io/content/comments-api).
- Your design, settings, team, API keys and interface translations. Interface translations have their own endpoint: see [Interface translations](https://developers.helpcenter.io/content/interface-translations-api).

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](https://self.helpcenter.io/content/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](https://developers.helpcenter.io/content/import-articles-in-bulk) does:

1. Create the categories with `POST /v1/categories`, parents first. Send `name`, `description`, `icon` and `position` from the backup, and as `parent_id` the new id of the category's `parent`. 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 whose `privacy` isn't `public` its setting again in the dashboard (see [Make a category private](https://self.helpcenter.io/content/private-categories)). Its articles are then never shown to readers who shouldn't see them.
2. Create the articles with `POST /v1/articles/bulk`, 50 at a time. Send `title`, `slug`, `content`, `type`, `published`, `metadata` and `visibility` (`public` or `private`), the new id of their category as `category_id`, and an `external_id` such as `backup:553`, so you can run the restore again without copies. Leave out any language whose title is empty or `null`: an empty title makes the article fail. An article with `"category_id": -1` is in several categories: send one of its `categories` as `category_id`, then all of them in a `PATCH` once it exists. Create each link-only article as a draft, without `published` and `visibility`, then send `"visibility": "link_only"` and, if it was published, `"published": true` in one `PATCH`, 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](https://self.helpcenter.io/content/manage-languages)).
- 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](https://developers.helpcenter.io/content/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.

## Related

- [Export](https://developers.helpcenter.io/content/export-api)
- [Pagination](https://developers.helpcenter.io/content/pagination)
- [Import articles in bulk](https://developers.helpcenter.io/content/import-articles-in-bulk)
