---
title: "Pagination & Filtering"
description: "Understand Billabex cursor pagination and the filters parameter, and what your integration should implement."
canonical: https://developer.billabex.com/en/guides/pagination/
lang: en
alternate: https://developer.billabex.com/fr/guides/pagination/
last-updated: 2026-10-16
---

# Pagination & Filtering

> Understand Billabex cursor pagination and the filters parameter, and what your integration should implement.

Source: https://developer.billabex.com/en/guides/pagination/
Language: English (en)
French version: https://developer.billabex.com/fr/guides/pagination/

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.

### Customer references in lists

`GET /accounts` returns the organization's own customer reference as `reference`.
`GET /communications` returns it as `accountReference`, alongside `accountName`.
In MCP, `list-accounts` exposes `reference`, while `list-invoices` and `list-communications`
expose `accountReference`. These fields are `null` when no reference is available and identify
the customer without fetching account details. They differ from `source.sourceId`, the customer's
identifier in the connector.

## 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](#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.

The three email lists override that historical behaviour: `GET /emails`,
`GET /incoming-emails` and `GET /outgoing-emails` always return at most `20` items
when `first` is omitted, even without filters or sorting. To read the entire history,
continue with `after=pageInfo.endCursor` until a response has an empty `nodes` array.

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](/en/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.
- `isAnyOf` works on a text field only where that field's own allowlist offers
  it. On invoices and credit notes, `number` and `sourceId` take it, so one
  condition names several documents; `accountName` does not.
- A document number is compared on its canonical form: punctuation, spacing and
  case are dropped on both sides, so `FA-2026/1` and `fa 2026 1` are the same
  number. A value left with nothing after that, a stray `-` in a pasted list, is
  skipped rather than applied, since the values of one condition combine with
  `OR`. A condition whose values are all skipped matches nothing.

### Examples

Text, on an account name:

```http
GET https://next.billabex.com/api/public/v1/accounts?organizationId=ORG_ID&filters=accountName%3Acontains%3Aacme
```

Enum, on several invoice statuses at once:

```http
GET https://next.billabex.com/api/public/v1/invoices?organizationId=ORG_ID&filters=status%3AisAnyOf%3AOverdue%2CIssued
```

Several invoice numbers at once, instead of one call per number:

```http
GET https://next.billabex.com/api/public/v1/invoices?organizationId=ORG_ID&filters=number%3AisAnyOf%3AFA-2026%2F1%2CFA-2026%2F2%2CFA-2026%2F3
```

Values are separated by `,`, so a `,` inside a value is percent-encoded as
`%2C`, and so is the `/` of a number. Up to 50 values per condition.

Date range, on a credit note issue date:

```http
GET https://next.billabex.com/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`:

```http
GET https://next.billabex.com/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:

```javascript
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](/en/api-reference/), in the description of its `filters` parameter.

| Endpoint                | Fields                                                                                                                                                                                                                            |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/accounts`             | `accountName`, `tags`, `dunningPaused`, `collectionStatus`, `followUpStatus`, `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`       | `type`, `direction`, `status`, `deliveryStatus`, `accountName`, `contactName`, `subject`, `date`                                                                                                                                  |
| `/account-tasks/search` | `title`, `accountName`, `status`, `type`, `tags`, `balance`, `createdAt`, `updatedAt`                                                                                                                                             |

For `/communications`, the `type` filter supports `is`, `isNot` and `isAnyOf` with `IncomingEmail`, `OutgoingEmail`, `OutgoingSms`, `OutgoingTrackedLetter` and `OutgoingRegisteredLetter`. For both email types: `type:isAnyOf:IncomingEmail,OutgoingEmail`.

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.

`followUpStatus` filters on the follow-up state the webapp shows: `Active` (open balance,
reminders running), `Paused` (reminders suspended, including an account paused while it owes
nothing, ahead of its next invoice), `NothingToCollect` (`Settled` or `NoInvoice`, not
suspended) and `WrittenOff`. It combines these states in one condition, for example
`followUpStatus:isAnyOf:Paused,WrittenOff`, which two conditions on `collectionStatus` and
`dunningPaused`, joined by AND, cannot express.

`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](/en/guides/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.

```http
GET https://next.billabex.com/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](/en/guides/account-tasks/#search-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:

```json
{
  "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:

```http
GET https://next.billabex.com/api/public/v1/accounts?organizationId=YOUR_ORG_ID&first=50
Authorization: Bearer YOUR_ACCESS_TOKEN
```

Next request (using the previous cursor):

```http
GET https://next.billabex.com/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.

## Recommended Client Pattern

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

```javascript
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](/en/guides/rate-limiting/) 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

- **[Getting Started](/en/guides/getting-started/)** - End-to-end integration flow
- **[OAuth Authentication](/en/guides/oauth/)** - Token lifecycle and scopes
- **[Rate Limiting](/en/guides/rate-limiting/)** - Token quota and retry behavior
- **[API Reference](/en/api-reference/)** - Endpoint schemas and examples

## Support

Questions about pagination behavior?  
Contact us via the [website contact form](https://billabex.com).

---

Billabex developer portal. OpenAPI specification: https://developer.billabex.com/openapi.json.
Agent instructions: https://developer.billabex.com/llms.txt. Complete documentation: https://developer.billabex.com/llms-full.txt.
