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. true when more pages exist after this one.
  • prev — the previous page number, or null when on the first page.
  • next — the next page number, or null when there's nothing further.

Defaults and limits

ParameterDefaultMax
per_page10100
page1unbounded (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_at asc with a fixed created_before filter 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_countless deliberately skips counting the full result set (expensive on large tables); there's no total: N field.
  • 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: false as 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.