Pagination

Every list method on PlugipayClient returns a cursor-paginated page. This page covers the response shape, how to iterate manually, and a couple of patterns for consuming a full result set.

If you'd like to know how the underlying API does it, see API pagination.

The list response shape

Unlike single-resource methods (which return the resource directly), list methods return a small envelope:

{
  data: T[];
  cursor: string | null;
  hasMore: boolean;
}
Field Type Notes
data T[] The page of results — up to limit items.
cursor string | null Pass back to get the next page. null once you've reached the end.
hasMore boolean true if more results exist past this page.

Concretely, listing customers:

const page = await plugipay.customers.list({ limit: 50 });

page.data;      // → Customer[]
page.cursor;    // → "cur_01HX..." or null
page.hasMore;   // → true or false

The shape is the same for every resource — customers, plans, checkoutSessions, invoices, subscriptions, payouts, refunds, receipts, ledger, events.

Why a different shape from the API envelope? The HTTP API puts cursor info in meta.page. The SDK reshapes that to a flat { data, cursor, hasMore } so you don't have to reach into meta on every page. Single-resource methods don't have this shape — they just return the resource.

Page parameters

Each list method takes a small parameter bag. The pagination-related options are consistent across resources:

Param Type Default Notes
limit number 50 Items per page. Maximum 100.
cursor string none Opaque token from a previous page.cursor.
order 'asc' | 'desc' resource-specific Some lists (plans, ledger, events) support direction.

Plus per-resource filters — see each resource's reference for what it accepts.

Example with a filter:

const page = await plugipay.invoices.list({
  status: 'paid',
  customerId: 'cus_01H...',
  limit: 100,
});

The cursor encodes the filter set. Don't change filters between pages — mint a new request without a cursor instead.

Iterating manually

The simplest pattern: loop until hasMore is false.

async function listAllCustomers() {
  const all = [];
  let cursor: string | undefined = undefined;

  do {
    const page = await plugipay.customers.list({ limit: 100, cursor });
    all.push(...page.data);
    cursor = page.cursor ?? undefined;
  } while (cursor);

  return all;
}

A generic helper across any list-shaped resource:

type Lister<T> = (params: { limit?: number; cursor?: string }) => Promise<{
  data: T[];
  cursor: string | null;
  hasMore: boolean;
}>;

async function listAll<T>(lister: Lister<T>, params: Record<string, unknown> = {}): Promise<T[]> {
  const all: T[] = [];
  let cursor: string | undefined = undefined;

  while (true) {
    const page = await lister({ ...params, limit: 100, cursor });
    all.push(...page.data);
    if (!page.hasMore) break;
    cursor = page.cursor ?? undefined;
  }
  return all;
}

// Usage:
const allCustomers = await listAll((p) => plugipay.customers.list(p));
const allPaidInvoices = await listAll((p) => plugipay.invoices.list(p), { status: 'paid' });

Async iterator pattern

If you'd rather stream the pages (so you can process items as they arrive without holding them all in memory), wrap the loop in an async generator:

async function* iterateCustomers(params: Parameters<typeof plugipay.customers.list>[0] = {}) {
  let cursor: string | undefined = undefined;

  while (true) {
    const page = await plugipay.customers.list({ ...params, limit: 100, cursor });
    for (const item of page.data) {
      yield item;
    }
    if (!page.hasMore) break;
    cursor = page.cursor ?? undefined;
  }
}

// Usage:
for await (const customer of iterateCustomers()) {
  await processCustomer(customer);
}

This is the right pattern for big exports — processing tens of thousands of invoices, building a CSV, exporting to a data warehouse. You never load more than 100 items in memory at once.

Use limit: 100 for iteration, lower for UI. The maximum page size keeps the round-trip count low. For an interactive dashboard list, limit: 25 or limit: 50 matches how many rows you'd render anyway.

A note on listAll helpers

The SDK does not currently ship a built-in customers.listAll() helper — only the list() method that returns a single page. Roll your own with the snippet above (it's ~10 lines) or use the generic helper.

We're considering adding a built-in across all resources in a future minor version. If you'd find it useful, let us know on GitHub.

Stable ordering

By default, list responses come back newest first (createdAt descending). Cursor pagination preserves order across pages — you won't see duplicates or skip items even if new records are created mid-iteration.

A few endpoints accept order: 'asc' | 'desc':

  • plans.list
  • ledger.list
  • events.list

The rest are fixed-newest-first. To backfill chronologically (warehouse export), asc is the right choice on supported endpoints; for the rest, iterate descending and reverse at the end.

Cursor lifetime

Cursors are valid for 24 hours after issuance. A stale cursor returns invalid_cursor (which the SDK surfaces as a PlugipayError). Restart the iteration without a cursor.

For long-running export jobs:

  • Don't pause for hours between pages. Process the page, persist what you've got, then immediately request the next page.
  • If you need to pause longer, save the last-seen createdAt and restart with a since filter (where supported) rather than holding a cursor.

Filtering and pagination together

Filters and cursors compose. Once you've passed a filter on the first call, the cursor includes that filter — you don't need to re-pass it:

let cursor: string | undefined;
const filter = { status: 'paid', customerId: 'cus_01H...' };

const firstPage = await plugipay.invoices.list({ ...filter, limit: 100 });
const secondPage = await plugipay.invoices.list({ ...filter, limit: 100, cursor: firstPage.cursor ?? undefined });
// Equivalent: the cursor encodes the same filter

If you do pass mismatched filters with a cursor, you'll get invalid_cursor — the server cross-checks.

Total counts

The SDK doesn't expose total counts. Counting is expensive on large tables, and the count changes between pages anyway. To estimate or audit a count:

  • Iterate with limit: 100 and tally as you go.
  • Use the Reports endpoints (plugipay.reports.pnl, plugipay.reports.cashFlow) for aggregated totals.
  • For an exact count, the API has CSV /export endpoints on some resources — not currently wrapped by the SDK, but reachable via the low-level request() if needed.

Common pitfalls

Forgetting ?? undefined on cursor

The SDK returns cursor: string | null (matching what the API surfaces). Most loops want string | undefined (because list takes an optional cursor in its params). Convert with cursor ?? undefined — passing null will encode literally as ?cursor=null, which the server rejects.

Changing filters mid-iteration

The cursor encodes the filter set. Don't change status, customerId, etc. between pages. Mint a new request from scratch.

Holding a cursor across hours

24h limit. Treat cursors as session-local, not as a durable bookmark.

Next

Plugipay — Payments that don't tax your success