Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

Pagination

The Page model, opaque provider-bound cursors, the listAll async iterators, limit clamping per provider, and same-origin cursor safety.

Every list endpoint in revenue-sdk is paginated the same way, regardless of how the underlying provider does it. You choose between fetching one page at a time with an opaque cursor, or letting the client follow the cursors for you with an async iterator.

list — one page at a time

list returns a Page<T>: the current page’s items plus an optional opaque cursor. Pass the cursor back to fetch the next page. When cursor is undefined, you’ve reached the end.

const { items, cursor } = await client.products.list({ limit: 50 });

if (cursor) {
  const next = await client.products.list({ limit: 50, cursor });
}
PropType
items?T[]

The items on this page.

TypeT[]
cursor?string

Opaque token for the next page. Absent on the last page.

Typestring

Five resources are paginated: products, customers, subscriptions, licenseKeys, and orders.

listAll — an async iterator

listAll returns an AsyncGenerator that transparently follows cursors, yielding one item at a time across all pages. It takes the same parameters as list minus cursor:

for await (const subscription of client.subscriptions.listAll({ customerId })) {
  console.log(subscription.id, subscription.status);
}

for await (const order of client.orders.listAll({ customerId })) {
  console.log(order.id, order.createdAt, order.amount);
}

Because it is lazy, you can break out early and no further pages are fetched:

for await (const customer of client.customers.listAll()) {
  if (customer.email === target) {
    match = customer;
    break; // no more requests
  }
}

The client’s single bounded retry applies to every page fetch, not just the first — and because a page fetch is a read, that covers a transport failure or a 5xx as well as a rate limit. The wait honors the signal you passed, so aborting an iteration interrupts it immediately instead of sleeping out the delay first.

Cursors are opaque and provider-bound

A cursor is a base64url-encoded envelope that carries the provider name plus whatever that provider needs to resume — a page number, a starting_after ID, or a next-page URL. Treat it as a black box.

// Don't do any of this.
Number(cursor) + 1;
JSON.parse(atob(cursor));

Because the provider name is baked in, a cursor cannot be moved between providers. Handing a Stripe cursor to a Polar client throws RevenueError with code validation (“cursor from another provider”), as does a malformed or truncated cursor.

Same-origin cursor safety

Paddle pages by returning a full “next page” URL, which the cursor carries. Before following such a cursor, the SDK checks that the URL points at the same origin as the configured base URL. A cursor that resolves elsewhere is rejected rather than fetched, so a forged cursor can never redirect an authenticated request — and your API key with it — to an attacker-controlled host.

This makes cursors safe to round-trip through your own storage or a query string.

Limits

limit is a hint for the page size and must be a positive integer. The client rejects 0, a negative number, a fraction, and NaN with a RevenueError of code validation instead of putting a meaningless value on the wire. There is no upper bound to reject against: a limit above the provider’s maximum is clamped to it silently.

Provider Default Maximum
Polar 10 100
Lemon Squeezy 10 100
Stripe 10 100
Paddle 10 200, but 30 on orders.list
Dodo Payments 10 100
Testing fixed 2 fixed 2
// Clamped to 100 on Polar; no error is thrown.
const { items } = await client.products.list({ limit: 1000 });

orders.list on Lemon Squeezy is the one call that pages across two collections, so its pages are not globally chronological — see Lemon Squeezy.

Pagination is one of several places the five APIs pull apart — see how the Stripe, Polar, Lemon Squeezy, Paddle, and Dodo Payments APIs differ for the wider comparison.

Last updated on August 8, 2026

Was this page helpful?