Pagination
Frame's list endpoints — GET /v1/transfers, GET /v1/customers, GET /v1/invoices, etc. — paginate with page numbers, not cursors. Pass a page and per_page query parameter, and the response carries a pagination metadata block telling you what page you're on and whether there's more.
The shape
curl --request GET \
--url "https://api.framepayments.com/v1/invoices?page=2&per_page=25" \
--header "Authorization: Bearer $FRAME_SECRET_KEY"
Response:
{
"data": [
{ "id": "in_...", ... },
...
],
"meta": {
"page": 2,
"url": "/v1/invoices",
"has_more": true,
"prev": 1,
"next": 3
}
}
The meta block carries the pagination signals:
page— the current page number (1-indexed).url— the base URL of the resource.has_more— boolean.truewhen more pages exist after this one.prev— the previous page number, ornullwhen on the first page.next— the next page number, ornullwhen there's nothing further.
Defaults and limits
| Parameter | Default | Max |
|---|---|---|
per_page | 10 | 100 |
page | 1 | unbounded (returns empty data past the last page) |
Passing per_page above 100 doesn't error — Frame silently clamps to 100. If you need to enumerate all records on a high-volume resource, set per_page=100 to minimize roundtrips, then walk pages until has_more: false.
Walking all pages
The basic pattern:
async function listAllInvoices(filters) {
const results = [];
let page = 1;
while (true) {
const resp = await frame.invoices.list({
...filters,
page,
per_page: 100,
});
results.push(...resp.data);
if (!resp.meta.has_more) break;
page = resp.meta.next;
}
return results;
}
The loop exits cleanly when has_more flips false. Don't rely on data.length < per_page as the termination signal — Frame's has_more is authoritative (it's computed via pagy_countless, which doesn't materialize the full count).
Cursor caveat
Most modern payment APIs use cursor-based pagination — opaque tokens that point at a specific position, stable across inserts. Frame V1 uses page numbers, which has a tradeoff: if the underlying data set changes between requests (new records inserted, old ones updated), your page-2 might overlap with what you saw on page-1, or skip records.
For high-volume + frequently-mutating data, this can produce subtle correctness issues during enumeration. Two mitigations:
- Order by a stable, monotonically-increasing field (e.g.,
created_atasc with a fixedcreated_beforefilter that locks the upper bound). - Accept the imperfection for use cases where occasional overlap or skip doesn't matter (reporting, monitoring, customer dashboards that refresh on demand).
A cursor surface is on the V2 roadmap, but until then page-based is the working model.
Filtering + pagination together
Filters and pagination compose. Most list endpoints accept domain-specific filters alongside page / per_page:
curl --request GET \
--url "https://api.framepayments.com/v1/invoices?status=paid&customer=cus_123&page=1&per_page=50" \
--header "Authorization: Bearer $FRAME_SECRET_KEY"
Filter parameters are resource-specific — see each endpoint's reference for the supported set. Pagination behavior is identical regardless of which filters apply.
What's not in the meta block
A few things the meta block doesn't include:
- Total count.
pagy_countlessdeliberately skips counting the full result set (expensive on large tables); there's nototal: Nfield. - Direct cursor handle. No
next_cursor/prev_cursor— only page numbers. - Last-page number. Without a total, Frame doesn't know the last page until you walk to it. Use
has_more: falseas the terminator.
If you need a total for a customer-facing UI ("showing 1-25 of N"), maintain a count on your side as records come in, or accept "many" / "1-25" as the display approximation.
Gotchas
Symptom: per_page=500 returns 100 items, not 500. Why: Frame silently clamps to the max of 100. Fix: this is intentional. To enumerate large sets, walk pages with per_page=100.
Symptom: page 2 returns records you already saw on page 1. Why: new records were inserted between requests, shifting the page boundaries. Fix: order by a stable field (created_at asc) with a fixed created_before upper bound to lock the snapshot; enumerate within that window.
Symptom: you wrote a loop that terminates on data.length < per_page and it exits prematurely. Why: Frame's pagination doesn't guarantee full pages for non-terminal pages in all cases; has_more is the authoritative signal. Fix: terminate on !response.meta.has_more.
Symptom: you can't find a total field in the response. Why: Frame uses pagy_countless which doesn't materialize a full count. Fix: if you need a total, maintain one on your side, or call a dedicated reporting endpoint where one exists.
Symptom: requesting page=9999 returns an empty data array but no error. Why: pages past the last one return empty rather than 404. Fix: check data is non-empty (or has_more: false on a prior request) rather than expecting an error.
Reference
Pagination metadata appears on the response of every list endpoint. See GET/v1/invoices for a representative shape.