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 returnafter- opaque cursor returned by the previous page
Each paginated response includes:
nodes- items for the current pagepageInfo.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
accountIdin the path GET /organizationsdoes not requireorganizationId
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 withOR. - 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
400with the codeLIST_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-DDand compared by whole day.oncovers the whole day,beforeis strictly before it,afteris strictly after it, andbetweenincludes both bounds. - On a text field, an empty string counts as empty for
isEmpty. isTrueandisFalsenever 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,sortByandsortOrder, returns the next page. - Passing it back with different filters or a different sort returns
400with the codeLIST_CURSOR_INVALID. Dropafterand 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
400with the sameLIST_CURSOR_INVALIDcode 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:
endCursoris 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.
Recommended Client Pattern
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:
20to50for UI-driven flows50to100for 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-Remainingheaders - 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
endCursormeans “no more data” - Requesting very large page sizes by default
Paginated Endpoints (Public API)
Common paginated endpoints include:
GET /organizationsGET /accountsGET /invoicesGET /credit-notesGET /account-tasks/searchGET /communicationsGET /emailsGET /incoming-emailsGET /outgoing-emailsGET /incoming-email-communicationsGET /outgoing-email-communicationsGET /customer-outstanding-balancesGET /accounts/:accountId/incoming-email-communicationsGET /accounts/:accountId/outgoing-email-communications
Always consult the API Reference for the definitive list.
Next Steps
- Getting Started - End-to-end integration flow
- OAuth Authentication - Token lifecycle and scopes
- Rate Limiting - Token quota and retry behavior
- API Reference - Endpoint schemas and examples
Support
Questions about pagination behavior?
Contact us via the website contact form.