Best Practices10 min

Pagination & Filtering

Understand Billabex cursor pagination and the filters parameter, 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 choice Why this choice What you should do
Cursor pagination (first + after) Consistent results when data changes Always chain requests using the latest endCursor
No offset or page number Avoids expensive database scans Do not build UX around page indexes
No totalCount or hasNextPage Smaller, faster responses Stop pagination when nodes.length === 0
Endpoint-defined ordering Ordering depends on storage/index Do not assume a global sort unless documented

Request Parameters

Core pagination parameters:

Parameter Type Required Notes
first integer No Must be >= 1 and at most 100
after string No Opaque cursor from pageInfo.endCursor
filters string No Filter conditions, see Filtering a List

Every list caps first at 100, and a larger value returns 400. A page reads one row per item from the projection, so an unbounded page size would turn a single call into thousands of reads. Omitting first on a call that carries a filter or a sort returns 20 items. A call with neither keeps the behaviour it always had.

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.

Filtering a List

Filterable lists take a single filters query parameter. Conditions are joined by ;, each written field:operator or field:operator:value,value:

filters=status:isAnyOf:Overdue,Issued;accountName:contains:acme

Rules of the format:

  • Values are percent-encoded. This is what keeps a value containing ;, ,, :, a space or an accent from being read as a separator.
  • Conditions combine with AND; several values inside one condition combine with OR.
  • A field carries at most one condition. A repeated field returns 400.
  • Fields and operators come from a per-resource allowlist. An unknown field, or an operator the field does not accept, returns 400 with the code LIST_FILTER_INVALID. Nothing is silently ignored, so a typo can never widen a list.

The same string is used by the Billabex web app in its URL, so a link copied from a list is a valid API call, and the reverse holds too.

Operators

Operator Values Applies to
contains, startsWith, equals one text
is, isNot one enum, text
isAnyOf many enum, text
isTrue, isFalse none boolean
on, before, after one date
between two date, number
greaterThan, lessThan one number
isEmpty, isNotEmpty none any nullable field
containsAny, containsAll, containsNone many list fields, e.g. tags

Semantics worth knowing:

  • Text comparison ignores case and accents. % and _ are literal characters, never wildcards.
  • Dates are written YYYY-MM-DD and compared by whole day. on covers the whole day, before is strictly before it, after is strictly after it, and between includes both bounds.
  • On a text field, an empty string counts as empty for isEmpty.
  • isTrue and isFalse never match a null value.

Examples

Text, on an account name:

GET [baseURL]/api/public/v1/accounts?organizationId=ORG_ID&filters=accountName%3Acontains%3Aacme

Enum, on several invoice statuses at once:

GET [baseURL]/api/public/v1/invoices?organizationId=ORG_ID&filters=status%3AisAnyOf%3AOverdue%2CIssued

Date range, on a credit note issue date:

GET [baseURL]/api/public/v1/credit-notes?organizationId=ORG_ID&filters=issuedDate%3Abetween%3A2026-01-01%2C2026-03-31

Amount, plus a second condition combined with AND:

GET [baseURL]/api/public/v1/invoices?organizationId=ORG_ID&filters=remainingBalance%3AgreaterThan%3A1000%3Bstatus%3Ais%3AOverdue

Building the parameter in JavaScript, where URLSearchParams does the encoding for you:

const filters = [
  'status:isAnyOf:Overdue,Issued',
  'accountName:contains:acme',
].join(';');

const url = new URL(`${baseUrl}/api/public/v1/invoices`);
url.searchParams.set('organizationId', organizationId);
url.searchParams.set('filters', filters);

A value that itself contains a separator must be encoded on its own before being joined, with encodeURIComponent.

Limits

Limit Value
Conditions per request 20
Values per condition 50
Characters per value 200
Characters of filters 8000

Filterable Fields

Fields depend on the resource. The current list of each endpoint is in the API Reference, in the description of its filters parameter.

Endpoint Fields
/accounts accountName, tags, dunningPaused, collectionStatus, daysOverdue, overdueInvoiceCount, overdueBalance, remainingBalance, hasPaymentSchedule, paymentMethod, collectiveProceeding, source
/invoices number, accountName, tags, status, dueDate, paymentSchedule, paymentMethod, totalAmount, remainingBalance, channel, sourceId
/credit-notes number, accountName, tags, status, issuedDate, paymentMethod, totalAmount, remainingAmount, channel, sourceId
/communications direction, status, deliveryStatus, accountName, contactName, subject, date
/account-tasks/search title, accountName, status, type, tags, balance, createdAt, updatedAt

For accounts, collectionStatus accepts Open, Settled, WrittenOff or NoInvoice. It is a derived filter and is not added to each account item. Read the same value from GET /accounts/{accountId}/dunning when you need the status of one account.

channel is where a document entered Billabex, and it is never the source of its account. sourceId is the identifier the document carries in the tool it came from, unique per organization, and the reliable way to look one up again after a lost response. See Sources.

Two Lists Built for Filtering

Two endpoints exist to answer a question across a whole organization instead of account by account. The account-scoped and channel-scoped endpoints they complement are unchanged.

GET /communications returns incoming and outgoing communications in one list, already joined with their account and contact. It takes organizationId, optionally accountId, filters, sortBy (communicationAt, accountFullName, contactFullName, status), sortOrder, first and after. It needs the communications:read or dunning:manage scope.

GET [baseURL]/api/public/v1/communications?organizationId=ORG_ID&filters=direction%3Ais%3AIncoming%3Bdate%3Aafter%3A2026-07-31&sortBy=communicationAt&sortOrder=desc

Use it instead of paging the incoming and outgoing lists separately and merging them client side.

A communication outlives the account it belonged to. When that account has been deleted, the row still carries its accountId and accountName, and accountDeletedAt holds the instant of the deletion. Treat a non-null accountDeletedAt as “do not resolve this account”: GET /accounts/{accountId} answers 404. It is null on every account still there. The account-scoped and channel-scoped communication lists carry the same fact as the boolean accountDeleted, which is all their aggregate records.

GET /account-tasks/search returns the tasks of a whole organization, filtered, sorted and paginated. See Account Tasks.

Cursors and Filters

A cursor is bound to the filters and the sort order of the call that produced it. It is returned in the versioned form v2:<fingerprint>:<page>, where the fingerprint stands for that exact set of conditions and that sort.

  • Passing a cursor back unchanged, with the same filters, sortBy and sortOrder, returns the next page.
  • Passing it back with different filters or a different sort returns 400 with the code LIST_CURSOR_INVALID. Drop after and read the list from its first page again.
  • Cursors issued before filtering existed keep working on calls that carry no filter and no sort. Nothing pinned them to a result set back then, so they cannot be honoured on a filtered call.
  • A cursor taken from another endpoint returns 400 with the same LIST_CURSOR_INVALID code rather than quietly answering the first page. Only the accounts, invoices and credit notes lists still accept the opaque cursors they issued before filtering existed, and only on an unfiltered, unsorted call.

Reordering conditions, or the values inside one condition, does not change the fingerprint: only their content does.

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:

  • 20 to 50 for UI-driven flows
  • 50 to 100 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 or sorting, which now returns 400
  • 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 /account-tasks/search
  • GET /communications
  • 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.