Bonnes pratiques10 min

Pagination et filtres

Comprendre la pagination par curseur de Billabex et le paramètre filters, et ce que votre intégration doit implémenter.

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 à renvoyer
  • after : le curseur opaque renvoyé par la page précédente

Chaque réponse paginée contient :

  • nodes : les éléments de la page courante
  • pageInfo.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 accountId dans le chemin ;
  • GET /organizations n’exige pas organizationId.

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 en OU.
  • 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 400 avec le code LIST_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-JJ et se comparent par jour entier. on couvre la journée complète, before est strictement avant, after strictement après, et between inclut les deux bornes.
  • Sur un champ texte, une chaîne vide compte comme vide pour isEmpty.
  • isTrue et isFalse ne 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, sortBy et sortOrder, donne la page suivante.
  • Le renvoyer avec d’autres filtres ou un autre tri donne 400 avec le code LIST_CURSOR_INVALID. Abandonnez after et 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 400 avec le même code LIST_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 :

  • endCursor est 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 à 50 pour un parcours piloté par une interface ;
  • 50 à 100 pour 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 endCursor absent 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 /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

Consultez toujours la référence de l’API pour la liste qui fait foi.

Pour aller plus loin

Support

Une question sur la pagination ? Écrivez-nous via le formulaire de contact.