# Pagination

_Category: Getting started_

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](https://developers.helpcenter.io/content/export-api).

## 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](https://developers.helpcenter.io/content/rate-limits).
- To pick up only what changed since your last run, add `updated_since` to `GET /v1/articles`, in UTC.

## Related

- [Requests and responses](https://developers.helpcenter.io/content/requests-responses-and-conventions)
- [Rate limits](https://developers.helpcenter.io/content/rate-limits)
- [Articles](https://developers.helpcenter.io/content/articles-api)
- [Export](https://developers.helpcenter.io/content/export-api)
