Getting started

Pagination

Export
Download Markdown Use with AI

List endpoints return their results a page at a time. Most take page and limit, two take per_page, the export moves forward with a cursor, and a few return everything at once. This page shows how each list pages, and gives you loops that fetch every page.

Pages and limits

Ask for a page with page, starting at 1, and set its size with limit:

curl "https://api.helpcenter.io/v1/articles?limit=100&page=2" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json"

The meta block describes the whole list. Here the help center has 114 articles, so the second page holds the last 14 (trimmed to one):

{
  "status": "success",
  "articles": [
    {
      "id": 638,
      "title": {"en": "Release notes: version 3.26"},
      "updated_at": "2026-09-30 08:19:42"
    }
  ],
  "meta": {"page": 2, "per_page": 100, "total_pages": 2, "items_count": 114}
}

Field

Meaning

page

The page you asked for.

per_page

The page size: your limit, or the endpoint's default.

total_pages

How many pages the list has at this page size. It is at least 1, even when the list is empty.

items_count

How many items the whole list has, across all pages.

Keep asking for the next page until page reaches total_pages. A page past the end isn't an error, it's an empty list:

{
  "status": "success",
  "articles": [],
  "meta": {"page": 3, "per_page": 100, "total_pages": 2, "items_count": 114}
}

A page size above the maximum is refused rather than lowered: GET /v1/articles answers 400, and categories, images, comments, analytics and the export answer 422. Change sets and versions are the exception: they lower a larger value to their maximum. On GET /v1/articles:

{
  "status": "validation_error",
  "errors": {
    "limit": ["The limit field must not be greater than 100."]
  }
}

Fetch every page

These programs fetch every article, 100 at a time, and wait whenever the API answers 429 Too Many Requests. They work the same way for the other lists that take page and limit: change the path, the item key (articles) and the page size.

// Fetch every article, 100 per page, pausing whenever the API answers 429.
// Node.js 18 or later, no dependencies. Run: node all-articles.js
const API = 'https://api.helpcenter.io/v1';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function get(path) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(API + path, {
      headers: { apikey: process.env.HELPCENTER_API_KEY, Accept: 'application/json' },
    });
    if (res.status === 429 && attempt <= 5) {
      // Wait as long as Retry-After says (seconds), or back off if it is missing.
      const seconds = Number(res.headers.get('retry-after')) || 2 ** attempt;
      await sleep(seconds * 1000);
      continue;
    }
    if (!res.ok) {
      throw new Error(`GET ${path} failed with ${res.status}: ${await res.text()}`);
    }
    return res.json();
  }
}

async function fetchAllArticles() {
  const articles = [];
  for (let page = 1; ; page++) {
    const body = await get(`/articles?limit=100&page=${page}`);
    articles.push(...body.articles);
    if (page >= body.meta.total_pages) return articles;
  }
}

fetchAllArticles()
  .then((articles) => {
    // Pages are computed per request, so an article edited mid-walk can show up twice.
    const unique = new Map(articles.map((article) => [article.id, article]));
    console.log(`Fetched ${unique.size} articles.`);
  })
  .catch((err) => {
    console.error(err.message);
    process.exit(1);
  });
# Fetch every article, 100 per page, pausing whenever the API answers 429.
# Python 3.8 or later, standard library only. Run: python3 all_articles.py
import json
import os
import time
import urllib.error
import urllib.request

API = "https://api.helpcenter.io/v1"


def get(path):
    headers = {"apikey": os.environ["HELPCENTER_API_KEY"], "Accept": "application/json"}
    for attempt in range(1, 7):
        request = urllib.request.Request(API + path, headers=headers)
        try:
            with urllib.request.urlopen(request) as response:
                return json.load(response)
        except urllib.error.HTTPError as err:
            if err.code != 429 or attempt == 6:
                detail = err.read().decode()
                raise RuntimeError(f"GET {path} failed with {err.code}: {detail}")
            # Wait as long as Retry-After says (seconds), or back off if it is missing.
            retry_after = err.headers.get("Retry-After", "")
            time.sleep(int(retry_after) if retry_after.isdigit() else 2 ** attempt)


def fetch_all_articles():
    articles, page = [], 1
    while True:
        body = get(f"/articles?limit=100&page={page}")
        articles.extend(body["articles"])
        if page >= body["meta"]["total_pages"]:
            return articles
        page += 1


if __name__ == "__main__":
    articles = fetch_all_articles()
    # Pages are computed per request, so an article edited mid-walk can show up twice.
    unique = {article["id"]: article for article in articles}
    print(f"Fetched {len(unique)} articles.")
<?php
// Fetch every article, 100 per page, pausing whenever the API answers 429.
// PHP 8 or later with the curl extension. Run: php all-articles.php

function get(string $path): array
{
    for ($attempt = 1; ; $attempt++) {
        $retryAfter = 0;
        $ch = curl_init('https://api.helpcenter.io/v1' . $path);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'apikey: ' . getenv('HELPCENTER_API_KEY'),
                'Accept: application/json',
            ],
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
                if (stripos($line, 'Retry-After:') === 0) {
                    $retryAfter = (int) trim(substr($line, 12));
                }
                return strlen($line);
            },
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

        if ($status === 429 && $attempt <= 5) {
            // Wait as long as Retry-After says (seconds), or back off if it is missing.
            sleep($retryAfter > 0 ? $retryAfter : 2 ** $attempt);
            continue;
        }
        if ($raw === false || $status >= 400) {
            throw new RuntimeException("GET $path failed with $status: $raw");
        }
        return json_decode($raw, true);
    }
}

function fetch_all_articles(): array
{
    $articles = [];
    for ($page = 1; ; $page++) {
        $body = get("/articles?limit=100&page=$page");
        array_push($articles, ...$body['articles']);
        if ($page >= $body['meta']['total_pages']) {
            return $articles;
        }
    }
}

// Pages are computed per request, so an article edited mid-walk can show up twice.
$unique = array_column(fetch_all_articles(), null, 'id');
echo 'Fetched ' . count($unique) . " articles.\n";

Each page is computed when you ask for it. Articles come newest-updated first, so an article that someone edits while you page moves to the first page: your walk can miss it or see another article twice. That's why the programs keep one copy per id. When you need every article exactly once, walk the export instead.

Every list endpoint

Endpoint

Paging parameters

Default and maximum page size

Order

GET /v1/articles

page, limit

100, up to 100

Most recently updated first. order_by=views sorts by views, and order_type=asc reverses either order.

GET /v1/articles?search=

page, limit

100, up to 100, over at most 15 matches

Best match first.

GET /v1/categories

page, limit

100, up to 100

No guaranteed order. Sort by parent and position yourself.

GET /v1/images

page, limit

50, up to 100

Newest first.

GET /v1/comments

page, limit

25, up to 100

Newest first; order=asc for oldest first. meta adds status_counts.

GET /v1/analytics/content

page, per_page

25, up to 100

By sort: most viewed first by default.

GET /v1/change-sets

page, per_page

25, up to 100 (a larger value is lowered to 100)

Most recently updated first. meta has another shape: see below.

GET /v1/export

cursor, limit

100, up to 200

By id. See below.

Search is the exception to watch: GET /v1/articles?search= returns at most 15 matches in total, whatever limit and page say, and only published articles that anyone can read. With 22 matching articles, limit=10&page=2 returns the last 5 of those 15, and meta.items_count is 15.

Change sets

GET /v1/change-sets reports its pages with different names, and takes per_page instead of limit:

{
  "status": "success",
  "change_sets": [],
  "meta": {"current_page": 1, "last_page": 1, "total": 0}
}

Stop when current_page reaches last_page. total is the number of change sets.

Lists that return everything at once

Endpoint

What you get

Counts in meta

GET /v1/articles/{id}/comments

Every comment thread of the article, with replies nested

threads_count, comments_count

GET /v1/articles/{id}/notes

Every open team-note thread, and resolved ones with include_resolved=1

threads_count, open_count, includes_resolved

GET /v1/articles/{id}/versions

The newest 20 versions across all languages, or up to 100 with limit (a larger value is lowered to 100)

No meta

GET /v1/analytics/searches

The top 20 entries of each list, or up to 50 with limit

limit, lists_are_top_n

GET /v1/sites, GET /v1/webhooks

Every help center the credential reaches; every webhook the credential can manage

items_count

GET /v1/translations

Every interface text

items_count, overridden_count, default_language, site_languages

GET /v1/template/themes

Every theme in the gallery

No meta

GET /v1/template/versions

The newest 50 published versions of the design, newest first

No meta

Walk the export with its cursor

GET /v1/export returns every category and every article, drafts included, in id order. Instead of pages it uses a cursor: start at cursor=0, then send the cursor.next of each response until cursor.has_more is false. Categories come only with the first page. Every page also carries a snapshot block about the help center, trimmed here along with the lists:

curl "https://api.helpcenter.io/v1/export?limit=100&cursor=0" \
  -H "apikey: $HELPCENTER_API_KEY" \
  -H "Accept: application/json"

The first page:

{
  "status": "success",
  "snapshot": {"generated_at": "2026-09-30T08:23:14+00:00"},
  "categories": [
    {
      "id": 293,
      "name": {"de": "Erste Schritte", "en": "Getting started"},
      "updated_at": "2026-09-30 08:19:36"
    }
  ],
  "articles": [
    {
      "id": 521,
      "title": {"en": "Download your invoices"},
      "updated_at": "2026-09-30 08:19:58"
    }
  ],
  "cursor": {"next": 708, "has_more": true}
}

The next request sends cursor=708, and the last page ends the walk:

{
  "status": "success",
  "snapshot": {"generated_at": "2026-09-30T08:23:14+00:00"},
  "categories": [],
  "articles": [
    {
      "id": 709,
      "title": {"en": "Release notes: version 3.97"},
      "updated_at": "2026-09-30 08:19:43"
    }
  ],
  "cursor": {"next": null, "has_more": false}
}

These programs walk the whole export:

// Walk the whole export with its cursor: every category, then every article in id order.
// Node.js 18 or later, no dependencies. Run: node export-all.js
const API = 'https://api.helpcenter.io/v1';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function get(path) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(API + path, {
      headers: { apikey: process.env.HELPCENTER_API_KEY, Accept: 'application/json' },
    });
    if (res.status === 429 && attempt <= 5) {
      const seconds = Number(res.headers.get('retry-after')) || 2 ** attempt;
      await sleep(seconds * 1000);
      continue;
    }
    if (!res.ok) {
      throw new Error(`GET ${path} failed with ${res.status}: ${await res.text()}`);
    }
    return res.json();
  }
}

async function exportAll() {
  const categories = [];
  const articles = [];
  let cursor = 0;
  for (;;) {
    const body = await get(`/export?limit=100&cursor=${cursor}`);
    categories.push(...body.categories); // filled on the first page only
    articles.push(...body.articles);
    if (!body.cursor.has_more) return { categories, articles };
    cursor = body.cursor.next;
  }
}

exportAll()
  .then(({ categories, articles }) => {
    console.log(`Exported ${categories.length} categories and ${articles.length} articles.`);
  })
  .catch((err) => {
    console.error(err.message);
    process.exit(1);
  });
# Walk the whole export with its cursor: every category, then every article in id order.
# Python 3.8 or later, standard library only. Run: python3 export_all.py
import json
import os
import time
import urllib.error
import urllib.request

API = "https://api.helpcenter.io/v1"


def get(path):
    headers = {"apikey": os.environ["HELPCENTER_API_KEY"], "Accept": "application/json"}
    for attempt in range(1, 7):
        request = urllib.request.Request(API + path, headers=headers)
        try:
            with urllib.request.urlopen(request) as response:
                return json.load(response)
        except urllib.error.HTTPError as err:
            if err.code != 429 or attempt == 6:
                detail = err.read().decode()
                raise RuntimeError(f"GET {path} failed with {err.code}: {detail}")
            retry_after = err.headers.get("Retry-After", "")
            time.sleep(int(retry_after) if retry_after.isdigit() else 2 ** attempt)


def export_all():
    categories, articles, cursor = [], [], 0
    while True:
        body = get(f"/export?limit=100&cursor={cursor}")
        categories.extend(body["categories"])  # filled on the first page only
        articles.extend(body["articles"])
        if not body["cursor"]["has_more"]:
            return categories, articles
        cursor = body["cursor"]["next"]


if __name__ == "__main__":
    categories, articles = export_all()
    print(f"Exported {len(categories)} categories and {len(articles)} articles.")
<?php
// Walk the whole export with its cursor: every category, then every article in id order.
// PHP 8 or later with the curl extension. Run: php export-all.php

function get(string $path): array
{
    for ($attempt = 1; ; $attempt++) {
        $retryAfter = 0;
        $ch = curl_init('https://api.helpcenter.io/v1' . $path);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'apikey: ' . getenv('HELPCENTER_API_KEY'),
                'Accept: application/json',
            ],
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
                if (stripos($line, 'Retry-After:') === 0) {
                    $retryAfter = (int) trim(substr($line, 12));
                }
                return strlen($line);
            },
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

        if ($status === 429 && $attempt <= 5) {
            sleep($retryAfter > 0 ? $retryAfter : 2 ** $attempt);
            continue;
        }
        if ($raw === false || $status >= 400) {
            throw new RuntimeException("GET $path failed with $status: $raw");
        }
        return json_decode($raw, true);
    }
}

$categories = [];
$articles = [];
$cursor = 0;
while (true) {
    $body = get("/export?limit=100&cursor=$cursor");
    array_push($categories, ...$body['categories']); // filled on the first page only
    array_push($articles, ...$body['articles']);
    if (!$body['cursor']['has_more']) {
        break;
    }
    $cursor = $body['cursor']['next'];
}

printf("Exported %d categories and %d articles.\n", count($categories), count($articles));

Each page reads the help center as it is at that moment. Articles created during the walk appear if their id is higher than your cursor, and edits to articles you already have don't reach you. The export leaves out the Trash. For everything the export includes, see Export.

Page sizes and rate limits

  • Every page is one request against your rate limits, so ask for the largest page an endpoint allows.

  • The export has a limit of its own: 30 requests a minute from one IP address, shared with bulk imports and image uploads. See Rate limits.

  • To pick up only what changed since your last run, add updated_since to GET /v1/articles, in UTC.

Was this article helpful?