Guides and recipes

Sync articles from a Git repository

Export
Download Markdown Use with AI

Keep your help articles as Markdown files in a Git repository and publish them from there. On every push to main, a GitHub Actions workflow creates an article for each new file, updates the articles whose files changed, and moves the articles of deleted files to the Trash.

The whole sync is one Node.js script with no dependencies. It uses each file's path as the article's external_id, so you can run it as often as you like without creating duplicates.

Before you start

  • You need a Read & write API key for the help center. Owners and Admins can create one: see Create an API key. The API works on every plan.

  • You need Node.js 18 or later on your computer for the first run. The workflow uses Node.js 24 on GitHub's runner.

  • Your articles are .md files in one folder of the repository, such as docs. Subfolders are fine.

How files become articles

Each Markdown file becomes one article in your help center's default language. The script reads a few settings from the file's front matter, the lines between the two --- at the top:

---
title: Export your invoices
category: Billing
slug: export-invoices
description: Download every invoice as a PDF or CSV file.
---

You can download your invoices at any time.

1. Open **Billing**, then **Invoices**.
2. Click **Export**.

![The Export button on the Invoices page](../images/export-button.png)

| Format | What you get |
| --- | --- |
| PDF | One file per invoice |
| CSV | One row per invoice |

Front matter

What it sets

title

The article's title. Required, unless the file starts with a # Title heading instead. The script takes that heading out of the body, because the help center already shows the title above the article.

category

The category, by name. Billing > Invoices means Invoices under Billing. Without it, a new article is filed under Uncategorized. Categories that don't exist yet are created, and are public: for a private one, create it in the dashboard before you sync articles into it (see Make a category private).

slug

The end of the article's address, such as export-invoices: lowercase letters, digits and hyphens. Without it, HelpCenter.io makes one from the title when it creates the article, and keeps it when the title changes later.

description

The meta description that search engines can show, up to 160 characters. Removing the line clears it.

published

false makes the article a draft, even if it was published before. Without the line, the article is published.

The rest of the file is the body. The script sends it with content_format: "markdown", and HelpCenter.io stores it as HTML. GitHub-flavored Markdown works: tables, fenced code, task lists, and raw HTML. Images with a relative path, such as ![The Export button](../images/export-button.png), are uploaded to HelpCenter.io, and the script points them at the uploaded copy.

Step 1: Add the sync script

Save this as scripts/sync-helpcenter.mjs in your repository:

// Sync a folder of Markdown files to a HelpCenter.io help center.
// Run it from the root of your repository:
//   HELPCENTER_API_KEY=... node scripts/sync-helpcenter.mjs docs
// Node.js 18 or later, no dependencies.
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';

const API = 'https://api.helpcenter.io/v1';
const KEY = process.env.HELPCENTER_API_KEY;
const FOLDER = process.argv[2] ?? 'docs';
const BATCH_SIZE = 50; // the most POST /v1/articles/bulk accepts
const IMAGE_TYPES = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;

const toKey = (file) => relative(process.cwd(), file).split(sep).join('/');
const prefix = `${toKey(resolve(FOLDER))}/`; // every article this script owns
const sleep = (seconds) => new Promise((done) => setTimeout(done, seconds * 1000));

// One request, with a wait and a retry whenever the API answers 429.
async function api(method, path, { json, form } = {}) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(API + path, {
      method,
      headers: {
        apikey: KEY,
        Accept: 'application/json',
        ...(json ? { 'Content-Type': 'application/json' } : {}),
      },
      body: json ? JSON.stringify(json) : form,
    });
    if (res.status === 429 && attempt < 8) {
      const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
      console.log(`Rate limited on ${method} ${path}, retrying in ${wait} s`);
      await sleep(wait);
      continue;
    }
    const text = await res.text();
    let body = null;
    try {
      body = JSON.parse(text);
    } catch {}
    return { status: res.status, body, text };
  }
}

function expect(res, statuses, what) {
  if (!statuses.includes(res.status)) {
    throw new Error(`${what}: HTTP ${res.status} ${res.text.slice(0, 300)}`);
  }
  return res.body;
}

function markdownFiles(dir) {
  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const path = join(dir, entry.name);
    if (entry.isDirectory()) return markdownFiles(path);
    return extname(entry.name) === '.md' ? [path] : [];
  });
}

// Front matter: simple `key: value` lines between two `---` lines.
function parse(file) {
  let text = readFileSync(file, 'utf8');
  if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // a byte order mark
  const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
  const meta = {};
  for (const line of match ? match[1].split(/\r?\n/) : []) {
    const pair = line.match(/^([A-Za-z_][\w-]*):\s*(.*?)\s*$/);
    if (pair) meta[pair[1]] = pair[2].replace(/^(["'])(.*)\1$/, '$2');
  }
  let body = match ? text.slice(match[0].length) : text;
  // A leading "# Title" is the title: the help center shows it above the article.
  const heading = body.match(/^\s*#[ \t]+(.+?)[ \t#]*(?:\r?\n|$)/);
  if (heading) body = body.slice(heading[0].length);
  return { meta, body, title: meta.title || heading?.[1] };
}

// Markdown images with a relative path, like ![Alt text](images/button.png).
const IMAGE = /(!\[[^\]]*\]\(\s*)([^\s)]+)/g;
const isLocal = (src) => !/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(src);

function check(file, { meta, body, title }) {
  const problems = [];
  if (!title) {
    problems.push('no title: add "title:" to the front matter or start with "# Title"');
  }
  if (meta.slug && !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(meta.slug)) {
    problems.push('slug: use lowercase letters, digits and hyphens');
  }
  if ((meta.description ?? '').length > 160) {
    problems.push('description: 160 characters at most');
  }
  if (toKey(file).length > 191) problems.push('path: 191 characters at most');
  for (const [, , src] of body.matchAll(IMAGE)) {
    if (!isLocal(src)) continue;
    const path = resolve(dirname(file), decodeURI(src));
    if (!existsSync(path)) {
      problems.push(`image not found: ${src}`);
    } else if (!IMAGE_TYPES.includes(extname(path).toLowerCase())) {
      problems.push(`image type: ${src} (use PNG, JPEG, GIF or WebP)`);
    } else if (statSync(path).size > MAX_IMAGE_BYTES) {
      problems.push(`image over 10 MB: ${src}`);
    }
  }
  return problems.map((problem) => `${toKey(file)}: ${problem}`);
}

async function categoryResolver(language) {
  const all = [];
  for (let page = 1; ; page++) {
    const res = await api('GET', `/categories?limit=100&page=${page}`);
    const body = expect(res, [200], 'List categories');
    all.push(...body.categories);
    if (page >= body.meta.total_pages) break;
  }
  const same = (a, b) => a?.trim().toLowerCase() === b.toLowerCase();
  // "Billing", or "Billing > Invoices" for a subcategory. Missing ones are created.
  return async function categoryId(path) {
    let parent = null;
    for (const name of path.split('>').map((part) => part.trim()).filter(Boolean)) {
      let category = all.find(
        (c) => c.parent === parent && same(c.name?.[language], name),
      );
      if (!category) {
        const json = { name: { [language]: name } };
        if (parent) json.parent_id = parent;
        const res = await api('POST', '/categories', { json });
        const created = expect(res, [201], `Create category "${name}"`).category;
        category = { ...created, parent };
        all.push(category);
        console.log(`Created category "${name}"`);
      }
      parent = category.id;
    }
    return parent;
  };
}

const imageUrls = new Map(); // sha256 of the file -> its URL on HelpCenter.io
async function imageUrl(path) {
  const bytes = readFileSync(path);
  const checksum = createHash('sha256').update(bytes).digest('hex');
  if (!imageUrls.has(checksum)) {
    // Uploaded before? Then reuse it rather than upload it again.
    const found = await api('GET', `/images?checksum=${checksum}&limit=1`);
    let url = expect(found, [200], 'Look up image').images[0]?.url;
    if (!url) {
      const form = new FormData();
      form.append('file', new Blob([bytes]), basename(path));
      const res = await api('POST', '/images', { form });
      url = expect(res, [200, 201], `Upload ${toKey(path)}`).image.url;
      console.log(`Uploaded ${toKey(path)}`);
    }
    imageUrls.set(checksum, url);
  }
  return imageUrls.get(checksum);
}

async function main() {
  if (!KEY) throw new Error('Set HELPCENTER_API_KEY to a Read & write API key.');
  if (!existsSync(FOLDER) || prefix.startsWith('..') || prefix === '/') {
    throw new Error(`"${FOLDER}" must be a folder inside the current directory.`);
  }

  // 1. Read and check every file before anything is written.
  const files = markdownFiles(FOLDER).sort();
  if (files.length === 0) {
    throw new Error(`No .md files in ${FOLDER}; nothing was changed.`);
  }
  const docs = files.map((file) => ({ file, ...parse(file) }));
  const problems = docs.flatMap((doc) => check(doc.file, doc));
  if (problems.length) {
    throw new Error(`Fix these files first:\n${problems.join('\n')}`);
  }

  // 2. Build one article per file. Its path is the article's external_id.
  const site = expect(await api('GET', '/sites'), [200], 'Read the help center');
  const language = site.sites[0].default_language;
  const categoryId = await categoryResolver(language);
  const articles = [];
  for (const { file, meta, body, title } of docs) {
    const urls = new Map();
    for (const [, , src] of body.matchAll(IMAGE)) {
      if (!isLocal(src)) continue;
      urls.set(src, await imageUrl(resolve(dirname(file), decodeURI(src))));
    }
    const markdown = body.replace(IMAGE, (all, start, src) => {
      return start + (urls.get(src) ?? src);
    });
    articles.push({
      external_id: toKey(file),
      title: { [language]: title },
      content: { [language]: markdown },
      content_format: 'markdown',
      published: !['false', 'no'].includes((meta.published ?? '').toLowerCase()),
      metadata: { description: { [language]: meta.description || null } },
      ...(meta.slug ? { slug: { [language]: meta.slug } } : {}),
      ...(meta.category ? { category_id: await categoryId(meta.category) } : {}),
    });
  }

  // 3. Create or update them, 50 per request.
  const counts = { created: 0, updated: 0, failed: 0 };
  const written = new Set(); // ids of the articles this run created or updated
  for (let start = 0; start < articles.length; start += BATCH_SIZE) {
    const batch = articles.slice(start, start + BATCH_SIZE);
    const res = await api('POST', '/articles/bulk', { json: { articles: batch } });
    for (const result of expect(res, [207], 'Bulk upsert').results) {
      counts[result.status === 'error' ? 'failed' : result.status]++;
      if (result.article) written.add(result.article.id);
      if (result.status === 'error') {
        const { code, message, errors } = result.error;
        const detail = errors ? ` ${JSON.stringify(errors)}` : '';
        console.error(`${batch[result.index].external_id}: ${code} ${message}${detail}`);
      }
    }
  }

  // 4. Move articles whose file is gone to the Trash.
  const current = new Set(articles.map((article) => article.external_id));
  let trashed = 0;
  let cursor = 0;
  for (;;) {
    const res = await api('GET', `/export?limit=200&cursor=${cursor}`);
    const page = expect(res, [200], 'Export');
    for (const article of page.articles) {
      const id = article.external_id;
      if (!id?.startsWith(prefix) || !id.endsWith('.md') || current.has(id)) continue;
      // Written by this run, so its file exists, even if its key's case differs.
      if (written.has(article.id)) continue;
      const deleted = await api('DELETE', `/articles/${article.id}`);
      expect(deleted, [200, 404], `Delete ${id}`);
      console.log(`Moved to Trash: ${id}`);
      trashed++;
    }
    if (!page.cursor.has_more) break;
    cursor = page.cursor.next;
  }

  console.log(
    `${articles.length} files: ${counts.created} created, ${counts.updated} updated, ` +
      `${counts.failed} failed, ${trashed} moved to Trash`,
  );
  if (counts.failed) process.exitCode = 1;
}

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

It works in four passes: it checks every file, builds one article per file, creates or updates the articles 50 at a time, and then moves the articles whose files are gone to the Trash. How it works, further down, explains each pass.

Step 2: Run it from your computer

Run it once by hand before you automate it. From the root of the repository, with your key in the HELPCENTER_API_KEY environment variable, pass the folder to sync:

export HELPCENTER_API_KEY="paste-your-key-here"
node scripts/sync-helpcenter.mjs docs

The first run uploads the image, creates the categories it needs and creates the articles:

Uploaded docs/images/export-button.png
Created category "Billing"
Created category "Getting started"
3 files: 3 created, 0 updated, 0 failed, 0 moved to Trash

Run it again, and nothing new is created, because every file maps to the article it created the first time:

3 files: 0 created, 3 updated, 0 failed, 0 moved to Trash

A file that didn't change still counts as updated, but its article stays as it was: its updated_at doesn't move and no webhook is sent.

Step 3: Sync on every push with GitHub Actions

In your repository on GitHub, open Settings → Secrets and variables → Actions and click New repository secret. Name the secret HELPCENTER_API_KEY and paste your key as its value.

Then save this workflow as .github/workflows/sync-docs.yml:

name: Sync docs to HelpCenter.io

on:
  push:
    branches: [main]
    paths:
      - 'docs/**'
      - 'scripts/sync-helpcenter.mjs'
      - '.github/workflows/sync-docs.yml'
  workflow_dispatch:

# One sync at a time: a second push waits for the first to finish.
concurrency:
  group: helpcenter-docs-sync
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  sync:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 24
      - run: node scripts/sync-helpcenter.mjs docs
        env:
          HELPCENTER_API_KEY: ${{ secrets.HELPCENTER_API_KEY }}

Commit both files and push them to main. From then on:

  • Every push to main that changes the docs folder, the script or the workflow runs the sync. You can also start it from the workflow's page in the Actions tab, with Run workflow.

  • Only one sync runs at a time. A push that arrives during a sync waits for it to finish, so two runs never write the same articles at once.

  • The job's GitHub token can only read the repository: the script needs nothing else.

  • If the API refuses a file, the run fails, and its log names the file.

Step 4: Delete or rename a file

Delete a file and push:

git rm docs/billing/refunds.md
git commit -m "Remove the refunds page"
git push

The next sync moves its article to the Trash:

Moved to Trash: docs/billing/refunds.md
2 files: 0 created, 2 updated, 0 failed, 1 moved to Trash

The article waits in the Trash, where you can restore it: see Delete and restore articles. If you add the file back later, the script creates a new article, and the old one stays in the Trash.

Renaming or moving a file changes its path, and so its external_id. The next sync creates a new article for the new path and moves the old article, with its views and ratings, to the Trash. To keep the article, give it the new path before you push the rename:

curl -X PATCH https://api.helpcenter.io/v1/articles/4711 \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "docs/billing/invoice-export.md"}'

Replace 4711 with the article's id, which you can find in GET /v1/articles: every article there shows its external_id. The sync then keeps updating the same article under its new path.

How it works

Creating and updating

The script sends the files to POST /v1/articles/bulk, 50 per request, which is the most the endpoint accepts. Each article carries its file's path, relative to the repository root, as its external_id. The first time HelpCenter.io sees a key, it creates an article; after that, the same key updates that article. That is what makes it safe to run the sync again, including after a run that stopped halfway.

This is the article the script sends for docs/billing/export-invoices.md, once the image is uploaded:

{
  "external_id": "docs/billing/export-invoices.md",
  "title": { "en": "Export your invoices" },
  "content": {
    "en": "You can download your invoices at any time.\n\n1. Open **Billing**, then **Invoices**.\n2. Click **Export**.\n\n![The Export button on the Invoices page](https://helpcenter-io.s3.amazonaws.com/uploads/acme/aJvlShdEfG5ygrpQcwvxZaTVWm9aUjZxKQ8m3Oyp.png)\n\n| Format | What you get |\n| --- | --- |\n| PDF | One file per invoice |\n| CSV | One row per invoice |\n"
  },
  "content_format": "markdown",
  "published": true,
  "metadata": { "description": { "en": "Download every invoice as a PDF or CSV file." } },
  "slug": { "en": "export-invoices" },
  "category_id": 340
}

The response lists each article as created, updated or error. Import articles in bulk explains that response and its error codes, and Articles lists every field.

Images

For each image with a relative path, the script computes the file's SHA-256 checksum and asks GET /v1/images?checksum=… whether the help center already has it. Only an image it doesn't find is uploaded, with POST /v1/images, so later runs don't upload unchanged images again. Images can be PNG, JPEG, GIF or WebP files of up to 10 MB. SVG isn't accepted: the script stops with image type before it writes anything.

Deleting

Once every file is sent, the script walks GET /v1/export page by page and reads every article's external_id. An article whose key starts with the folder path, docs/, and ends in .md, but whose file no longer exists, is moved to the Trash with DELETE /v1/articles/{articleId}. Articles you write in the dashboard have no external_id, so the sync never touches them. If two repositories sync into one help center, give their folders different names.

Checks before anything is written

The script reads and checks every file before it sends a single request. A missing title, a slug with capital letters or spaces, a description over 160 characters or a missing image stops the run with a list, and nothing changes in your help center:

Fix these files first:
docs/drafts/sso.md: slug: use lowercase letters, digits and hyphens
docs/drafts/sso.md: description: 160 characters at most
docs/drafts/sso.md: image not found: ../images/sso-settings.png
docs/drafts/untitled.md: no title: add "title:" to the front matter or start with "# Title"

It also stops when the folder holds no .md files, or lies outside the current directory, so a wrong path can't move every synced article to the Trash.

Rate limits

HelpCenter.io counts bulk requests, image uploads and export pages from one IP address together. A bulk request is refused once that count reaches 20 in a minute, an export page at 30 and an image upload at 60. Each key can also make 300 requests a minute, of which 120 can be writes, such as creating a category or deleting an article. A sync of a few hundred files needs a handful of bulk requests. For the details, see Rate limits.

When the API answers 429 Too Many Requests anyway, the script waits the number of seconds in the Retry-After header, then sends the request again, up to seven times. Without the header, it waits 2, 4, 8 seconds, and so on.

Good to know

  • The files are the source of truth. The sync sends every file on every run, so an edit made in the dashboard to a synced article's title, body, description or published state, or to a slug the file sets, is replaced the next time it runs. Make the change in the file instead.

  • Links from one file to another, such as [Refunds](refunds.md), are sent as they are, so they don't work in the help center. Link to the other article's help center address instead, such as https://acme.helpcenter.io/content/how-refunds-work.

  • The script changes the live articles, with no review step. To keep an article from readers while you work on it, set published: false.

  • Changes made through the API appear in an article's history without a person's name, and team members who watch the article aren't emailed about them.

  • The script writes your default language only. If you extend it to translations, know that a translation written through the API stays hidden from readers, who get a not-found page in that language, until it's released: see Release translations and change your default language.

Troubleshooting

Set HELPCENTER_API_KEY to a Read & write API key. The environment variable is empty. In GitHub Actions, check that the repository has a secret named exactly HELPCENTER_API_KEY.

Read the help center: HTTP 401 {"status":"unauthorized"} The key is wrong or was deleted. Create a new key and update the secret.

HTTP 403 with "code":"insufficient_scope" The key is Read only. The sync writes, so it needs a Read & write key.

A line that starts with a file path and an error code. The API refused that file, and the line says why. The other files were synced. Fix the file and push again.

Rate limited on POST /articles/bulk, retrying in 31 s Something else sent bulk requests, image uploads or export requests from the same IP address in the same minute. The script waits and carries on by itself.

The old address shows a not-found page after you changed slug. Changing a slug through the API doesn't redirect the old address. Add a redirect for it: see Redirect old links to the right page.

You removed category, but the article stayed in its category. An update that names no category leaves the article where it is. Name another category in the front matter, or move the article in the dashboard.

An edit you made in the dashboard is gone. The sync replaced it with the file's content. Make the change in the file.

external_id_busy errors. Two syncs ran at the same time and tried to write the same article. The workflow's concurrency setting prevents this; if you also run the script somewhere else, don't run both at once. The next run goes through.

Node.js 18 prints ExperimentalWarning: buffer.File is an experimental feature. Node.js prints it when the script uploads an image, and the upload still works. Node.js 24, which the workflow uses, doesn't print it.

Was this article helpful?