Les endpoints de liste de l’API publique Billabex utilisent une pagination par curseur. Ce guide explique le modèle, pourquoi il est conçu ainsi, et ce que votre client doit implémenter pour paginer sans risque et efficacement en production.
Vue d’ensemble
La pagination repose sur deux paramètres de requête :
first: le nombre d’éléments à renvoyerafter: le curseur opaque renvoyé par la page précédente
Chaque réponse paginée contient :
nodes: les éléments de la page courantepageInfo.endCursor: le curseur pour demander la page suivante
Il n’y a ni numéro de page, ni décalage, ni total.
Pourquoi un curseur ?
La pagination par curseur vise la cohérence et le passage à l’échelle. Elle évite les problèmes classiques de la pagination par décalage quand les données changent entre deux requêtes.
Ses avantages :
- une pagination stable même quand des éléments sont créés ou supprimés ;
- pas de parcours coûteux par décalage à grande échelle ;
- des performances prévisibles sur de gros volumes.
Choix de conception et conséquences
| Choix de conception | Pourquoi | Ce que vous devez faire |
|---|---|---|
Pagination par curseur (first + after) |
Des résultats cohérents quand les données changent | Enchaînez toujours les requêtes avec le dernier endCursor |
| Ni décalage ni numéro de page | Évite les parcours coûteux en base | Ne construisez pas d’interface autour d’un index de page |
Ni totalCount ni hasNextPage |
Des réponses plus petites et plus rapides | Arrêtez la pagination quand nodes.length === 0 |
| Ordre défini par l’endpoint | L’ordre dépend du stockage et des index | Ne présumez pas d’un tri global sans documentation |
Paramètres de requête
Paramètres de pagination :
| Paramètre | Type | Obligatoire | Remarques |
|---|---|---|---|
first |
entier | non | Doit valoir au moins 1 et au plus 100 |
after |
chaîne | non | Curseur opaque issu de pageInfo.endCursor |
filters |
chaîne | non | Conditions de filtrage, voir Filtrer une liste |
Toutes les listes plafonnent first à 100, et une valeur supérieure renvoie 400. Une page lit
une ligne par élément dans la projection : une taille de page non bornée transformerait donc un
appel unique en des milliers de lectures. Omettre first sur un appel qui porte un filtre ou un tri
renvoie 20 éléments. Un appel sans l’un ni l’autre conserve le comportement qu’il a toujours eu.
D’autres filtres obligatoires dépendent de l’endpoint :
- la plupart des listes exigent
organizationId; - les endpoints rattachés à un compte portent
accountIddans le chemin ; GET /organizationsn’exige pasorganizationId.
Reportez-vous toujours à la référence de l’API pour les exigences propres à chaque endpoint.
Filtrer une liste
Les listes filtrables prennent un unique paramètre filters. Les conditions sont séparées par ;,
chacune s’écrivant champ:opérateur ou champ:opérateur:valeur,valeur :
filters=status:isAnyOf:Overdue,Issued;accountName:contains:acme
Les règles du format :
- Les valeurs sont encodées en pourcent. C’est ce qui empêche une valeur contenant
;,,,:, une espace ou un accent d’être lue comme un séparateur. - Les conditions se combinent en
ET; plusieurs valeurs dans une même condition se combinent enOU. - Un champ ne porte qu’une seule condition. Un champ répété renvoie
400. - Les champs et les opérateurs proviennent d’une liste blanche par ressource. Un champ inconnu, ou un
opérateur que le champ n’accepte pas, renvoie
400avec le codeLIST_FILTER_INVALID. Rien n’est ignoré en silence : une faute de frappe ne peut donc jamais élargir une liste.
La webapp Billabex utilise la même chaîne dans son URL : un lien copié depuis une liste est donc un appel d’API valide, et l’inverse est vrai aussi.
Opérateurs
| Opérateur | Valeurs | S’applique à |
|---|---|---|
contains, startsWith, equals |
une | texte |
is, isNot |
une | énumération, texte |
isAnyOf |
plusieurs | énumération, texte |
isTrue, isFalse |
aucune | booléen |
on, before, after |
une | date |
between |
deux | date, nombre |
greaterThan, lessThan |
une | nombre |
isEmpty, isNotEmpty |
aucune | tout champ nullable |
containsAny, containsAll, containsNone |
plusieurs | champs de liste, par exemple les tags |
Quelques points de sémantique à connaître :
- La comparaison de texte ignore la casse et les accents.
%et_sont des caractères littéraux, jamais des jokers. - Les dates s’écrivent
AAAA-MM-JJet se comparent par jour entier.oncouvre la journée complète,beforeest strictement avant,afterstrictement après, etbetweeninclut les deux bornes. - Sur un champ texte, une chaîne vide compte comme vide pour
isEmpty. isTrueetisFalsene correspondent jamais à une valeur nulle.
Exemples
Texte, sur un nom de compte :
GET [baseURL]/api/public/v1/accounts?organizationId=ORG_ID&filters=accountName%3Acontains%3Aacme
Énumération, sur plusieurs statuts de facture à la fois :
GET [baseURL]/api/public/v1/invoices?organizationId=ORG_ID&filters=status%3AisAnyOf%3AOverdue%2CIssued
Plage de dates, sur la date d’émission d’un avoir :
GET [baseURL]/api/public/v1/credit-notes?organizationId=ORG_ID&filters=issuedDate%3Abetween%3A2026-01-01%2C2026-03-31
Montant, avec une seconde condition combinée en ET :
GET [baseURL]/api/public/v1/invoices?organizationId=ORG_ID&filters=remainingBalance%3AgreaterThan%3A1000%3Bstatus%3Ais%3AOverdue
Construction du paramètre en JavaScript, où URLSearchParams fait l’encodage pour vous :
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);
Une valeur qui contient elle-même un séparateur doit être encodée séparément avant d’être jointe,
avec encodeURIComponent.
Limites
| Limite | Valeur |
|---|---|
| Conditions par requête | 20 |
| Valeurs par condition | 50 |
| Caractères par valeur | 200 |
Caractères de filters |
8000 |
Champs filtrables
Ils dépendent de la ressource. La liste à jour de chaque endpoint se trouve dans la
référence de l’API, dans la description de son paramètre filters.
| Endpoint | Champs |
|---|---|
/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 |
Pour les comptes, collectionStatus accepte Open, Settled, WrittenOff ou NoInvoice. C’est
un filtre dérivé, qui n’est pas ajouté à chaque élément de la liste. Lisez la même valeur sur
GET /accounts/{accountId}/dunning quand vous avez besoin du statut d’un compte précis.
channel dit par où un document est entré dans Billabex, et ce n’est jamais la source de son
compte. sourceId est l’identifiant que le document porte dans l’outil dont il vient, unique par
organisation, et le moyen fiable de le retrouver après une réponse perdue. Voyez
Sources de compte.
Deux listes conçues pour le filtrage
Deux endpoints existent pour répondre à une question à l’échelle d’une organisation plutôt que compte par compte. Les endpoints rattachés à un compte ou à un canal qu’ils complètent restent inchangés.
GET /communications renvoie les communications entrantes et sortantes dans une même liste, déjà
jointes à leur compte et à leur contact. Il prend organizationId, éventuellement accountId,
filters, sortBy (communicationAt, accountFullName, contactFullName, status),
sortOrder, first et after. Il exige le scope communications:read ou dunning:manage.
GET [baseURL]/api/public/v1/communications?organizationId=ORG_ID&filters=direction%3Ais%3AIncoming%3Bdate%3Aafter%3A2026-07-31&sortBy=communicationAt&sortOrder=desc
Utilisez-le plutôt que de paginer séparément les listes entrantes et sortantes pour les fusionner côté client.
Une communication survit au compte auquel elle appartenait. Quand ce compte a été supprimé, la ligne
porte encore son accountId et son accountName, et accountDeletedAt donne l’instant de la
suppression. Traitez un accountDeletedAt non nul comme « ne pas résoudre ce compte » :
GET /accounts/{accountId} répond 404. Il vaut null sur tout compte encore présent. Les listes de
communications rattachées à un compte ou à un canal portent le même fait sous forme du booléen
accountDeleted, qui est tout ce que leurs agrégats enregistrent.
GET /account-tasks/search renvoie les tâches de toute une organisation, filtrées, triées et
paginées. Voyez Tâches de compte.
Curseurs et filtres
Un curseur est lié aux filtres et à l’ordre de tri de l’appel qui l’a produit. Il est renvoyé sous
la forme versionnée v2:<empreinte>:<page>, où l’empreinte représente cet ensemble exact de
conditions et ce tri.
- Renvoyer un curseur tel quel, avec les mêmes
filters,sortByetsortOrder, donne la page suivante. - Le renvoyer avec d’autres filtres ou un autre tri donne
400avec le codeLIST_CURSOR_INVALID. Abandonnezafteret relisez la liste depuis sa première page. - Les curseurs émis avant l’existence du filtrage continuent de fonctionner sur les appels qui ne portent ni filtre ni tri. Rien ne les rattachait à un jeu de résultats à l’époque, ils ne peuvent donc pas être honorés sur un appel filtré.
- Un curseur pris sur un autre endpoint renvoie
400avec le même codeLIST_CURSOR_INVALID, plutôt que de répondre discrètement la première page. Seules les listes de comptes, de factures et d’avoirs acceptent encore les curseurs opaques qu’elles ont émis avant le filtrage, et seulement sur un appel sans filtre ni tri.
Réordonner les conditions, ou les valeurs d’une même condition, ne change pas l’empreinte : seul leur contenu compte.
Contrat de réponse
Exemple :
{
"nodes": [{ "id": "..." }, { "id": "..." }],
"pageInfo": {
"endCursor": "eyJpZCI6IjEyM2U0NTY3LWU4OWItMTJkMy1hNDU2LTQyNjYxNDE3NDAwMCJ9"
}
}
Trois comportements à comprendre :
endCursorest un jeton de continuation, pas un signal qu’il reste des données ;- la dernière page non vide peut tout de même renvoyer un
endCursor; - une page vide (
nodes.length === 0) est la seule condition d’arrêt fiable.
Le parcours de base
Première requête :
GET [baseURL]/api/public/v1/accounts?organizationId=YOUR_ORG_ID&first=50
Authorization: Bearer YOUR_ACCESS_TOKEN
Requête suivante, avec le curseur précédent :
GET [baseURL]/api/public/v1/accounts?organizationId=YOUR_ORG_ID&first=50&after=PREVIOUS_END_CURSOR
Authorization: Bearer YOUR_ACCESS_TOKEN
Répétez jusqu’à obtenir une page vide.
Motif client recommandé
L’exemple ci-dessous montre une boucle de pagination sûre, prête pour la production.
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}`,
},
});
// Facultatif : s'articuler avec la limitation de débit
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;
}
Choisir une taille de page (first)
Il n’existe pas de valeur optimale universelle.
first influe directement sur :
- la taille de la réponse ;
- la latence ;
- la mémoire consommée côté client ;
- la consommation du quota de débit.
Valeurs de départ recommandées :
20à50pour un parcours piloté par une interface ;50à100pour un traitement par lots côté serveur.
Ajustez selon la taille réelle des charges utiles et votre trafic.
Interaction avec la limitation de débit
La pagination fait vite monter le volume de requêtes.
Bonnes pratiques :
- préférez peu de grandes pages à beaucoup de petites ;
- combinez la pagination avec une file de requêtes par jeton ;
- surveillez les en-têtes
X-RateLimit-Remaining; - évitez de paginer en parallèle avec le même jeton d’accès.
Le guide des limites de débit entre dans le détail.
Erreurs fréquentes
- Traiter un curseur comme un identifiant lisible ou stable
- Réutiliser un curseur après avoir changé les filtres ou le tri, ce qui renvoie désormais
400 - Mélanger des curseurs de différents endpoints
- Croire qu’un
endCursorabsent signifie « plus de données » - Demander de très grandes pages par défaut
Endpoints paginés de l’API publique
Parmi les plus courants :
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
Consultez toujours la référence de l’API pour la liste qui fait foi.
Pour aller plus loin
- Démarrage : le parcours d’intégration de bout en bout
- Authentification OAuth : cycle de vie du jeton et scopes
- Limites de débit : quota par jeton et comportement de reprise
- Référence de l’API : schémas et exemples des endpoints
Support
Une question sur la pagination ? Écrivez-nous via le formulaire de contact.