Best Practices

Pagination & Cursors

Understand Billabex cursor pagination, why it is designed this way, and what your integration should implement.

Billabex public API list endpoints use cursor-based pagination.
This guide explains how the model works, why it is designed this way, and what your client should implement to paginate safely and efficiently in production.

Overview

Pagination relies on two query parameters:

  • first – number of items to return
  • after – opaque cursor returned by the previous page

Each paginated response includes:

  • nodes – items for the current page
  • pageInfo.endCursor – cursor to request the next page

There is no page number, offset, or total count.

Why Cursor-Based Pagination?

Cursor pagination is designed for consistency and scalability.

It avoids common problems of offset-based pagination when data changes between requests.

Key benefits:

  • Stable pagination when items are created or deleted
  • No expensive offset scans at scale
  • Predictable performance on large datasets

Design Choices and Integration Impact

Design choiceWhy this choiceWhat you should do
Cursor pagination (first + after)Consistent results when data changesAlways chain requests using the latest endCursor
No offset or page numberAvoids expensive database scansDo not build UX around page indexes
No totalCount or hasNextPageSmaller, faster responsesStop pagination when nodes.length === 0
Endpoint-defined orderingOrdering depends on storage/indexDo not assume a global sort unless documented

Request Parameters

Core pagination parameters:

ParameterTypeRequiredNotes
firstintegerNoMust be >= 1
afterstringNoOpaque cursor from pageInfo.endCursor

Additional required filters depend on the endpoint:

  • Most list endpoints require organizationId
  • Account-scoped endpoints include accountId in the path
  • GET /organizations does not require organizationId

Always refer to the API Reference for endpoint-specific requirements.

Response Contract

Example response:

{
  "nodes": [{ "id": "..." }, { "id": "..." }],
  "pageInfo": {
    "endCursor": "eyJpZCI6IjEyM2U0NTY3LWU4OWItMTJkMy1hNDU2LTQyNjYxNDE3NDAwMCJ9"
  }
}

Important behaviors to understand:

  • endCursor is a continuation token, not a signal that more data exists
  • The last non-empty page may still return an endCursor
  • An empty page (nodes.length === 0) is the only reliable stop condition

Basic Pagination Flow

First request:

GET [baseURL]/api/public/v1/accounts?organizationId=YOUR_ORG_ID&first=50
Authorization: Bearer YOUR_ACCESS_TOKEN

Next request (using the previous cursor):

GET [baseURL]/api/public/v1/accounts?organizationId=YOUR_ORG_ID&first=50&after=PREVIOUS_END_CURSOR
Authorization: Bearer YOUR_ACCESS_TOKEN

Repeat until an empty page is returned.

The example below demonstrates a safe production-ready pagination loop.

async function fetchAllAccounts({ baseUrl, accessToken, organizationId }) {
  const all = [];
  let after;

  while (true) {
    const url = new URL(`${baseUrl}/api/public/v1/accounts`);
    url.searchParams.set('organizationId', organizationId);
    url.searchParams.set('first', '50');
    if (after) url.searchParams.set('after', after);

    const response = await fetch(url.toString(), {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });

    // Optional: integrate with rate-limiting behavior
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get('Retry-After') || '1');
      await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
      continue;
    }

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();
    all.push(...data.nodes);

    if (!Array.isArray(data.nodes) || data.nodes.length === 0) {
      break;
    }

    after = data.pageInfo?.endCursor;
    if (!after) {
      break;
    }
  }

  return all;
}

Choosing a Page Size (first)

There is no universal optimal value.

first directly impacts:

  • Response size
  • Latency
  • Client memory usage
  • Rate-limit consumption

Recommended starting values:

  • 2050 for UI-driven flows
  • 50100 for backend batch jobs

Adjust based on payload size and real traffic patterns.

Interaction with Rate Limiting

Pagination can increase request volume quickly.

Best practices:

  • Prefer fewer, larger pages over many small ones
  • Combine pagination with a per-token request queue
  • Watch X-RateLimit-Remaining headers
  • Avoid parallel pagination with the same access token

See the Rate Limiting guide for details.

Common Mistakes

  • Treating cursors as readable or stable identifiers
  • Reusing a cursor after changing filters
  • Mixing cursors from different endpoints
  • Assuming a missing endCursor means “no more data”
  • Requesting very large page sizes by default

Paginated Endpoints (Public API)

Common paginated endpoints include:

  • GET /organizations
  • GET /accounts
  • GET /invoices
  • GET /credit-notes
  • GET /emails
  • GET /incoming-emails
  • GET /outgoing-emails
  • GET /incoming-email-communications
  • GET /outgoing-email-communications
  • GET /customer-outstanding-balances
  • GET /accounts/:accountId/incoming-email-communications
  • GET /accounts/:accountId/outgoing-email-communications

Always consult the API Reference for the definitive list.

Next Steps

Support

Questions about pagination behavior?
Contact us via the website contact form.