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 |
|---|---|
| The page you asked for. |
| The page size: your |
| How many pages the list has at this page size. It is at least |
| 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 |
|---|---|---|---|
|
| 100, up to 100 | Most recently updated first. |
|
| 100, up to 100, over at most 15 matches | Best match first. |
|
| 100, up to 100 | No guaranteed order. Sort by |
|
| 50, up to 100 | Newest first. |
|
| 25, up to 100 | Newest first; |
|
| 25, up to 100 | By |
|
| 25, up to 100 (a larger value is lowered to 100) | Most recently updated first. |
|
| 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 |
|---|---|---|
| Every comment thread of the article, with replies nested |
|
| Every open team-note thread, and resolved ones with |
|
| The newest 20 versions across all languages, or up to 100 with | No |
| The top 20 entries of each list, or up to 50 with |
|
| Every help center the credential reaches; every webhook the credential can manage |
|
| Every interface text |
|
| Every theme in the gallery | No |
| The newest 50 published versions of the design, newest first | No |
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_sincetoGET /v1/articles, in UTC.