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 });
}
items?T[]
The items on this page.
T[]cursor?string
Opaque token for the next page. Absent on the last page.
stringFive 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.