Pagination

View as Markdown

Most list endpoints (GET /charges, GET /refunds, GET /products, GET /subscriptions, GET /customers) return results in pages, newest first. Paging is cursor-based: you walk forward from the last item you saw. There is no page number and no total count: this keeps lists fast and consistent even across very large datasets. GET /events is the exception (page numbers, below).


The list shape#

Every list response looks the same:

json
{
  "object": "list",
  "data": [ { "id": "TXN-8ac4796a…", "object": "charge", .. }, { "id": "TXN-1f0c5a92…", .. } ],
  "has_more": true,
  "next_cursor": "eyJvIjoxMDB9"
}
  • data: the page of objects, newest first.
  • has_more: true if more results exist after this page.
  • next_cursor: the value to send as starting_after for the next page. Present only when has_more is true. The field is omitted entirely on the last page, so read it with that in mind rather than expecting null.

Walking forward#

Pass limit to size the page and starting_after to continue from where you stopped.

On cursor-paginated lists, starting_after takes the next_cursor from the previous response, not an object id. It is an opaque token, do not build it, parse it, or assume it contains an id. Passing the last object's id instead is the common mistake: the value is not rejected, it simply does not resolve to the position you meant, so you silently get the wrong page. GET /events is different: there starting_after is a 1-based page number and next_cursor is the next page number.

bash
# Page 1
curl "https://api.worthypayments.com/v1/charges?limit=20" \
  -H "x-api-key: sk_test_xxx"

# Page 2 — start after the cursor page 1 returned
curl "https://api.worthypayments.com/v1/charges?limit=20&starting_after=eyJvIjoxMDB9" \
  -H "x-api-key: sk_test_xxx"

Keep going until has_more is false.

js
async function listAllCharges() {
  const all = [];
  let cursor = null;
  do {
    const url = new URL("https://api.worthypayments.com/v1/charges");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("starting_after", cursor);
    const page = await fetch(url, { headers: { "x-api-key": "sk_test_xxx" } }).then(r => r.json());
    all.push(...page.data);
    // Follow the server's cursor — never the last item's id.
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);
  return all;
}

Parameters#

ParamTypeDefaultNotes
limitintegersee below1 to 100. Values above 100 are clamped; a non-numeric or 0 value falls back to the default rather than erroring.
starting_afterstring—The next_cursor from the previous page. Opaque.

Defaults differ by endpoint:

EndpointDefault limitMaxstarting_after
GET /charges, /refunds, /subscriptions, /customers, /products10100opaque next_cursor
GET /events201001-based page number
GET /fraud/blocklist100500query name is cursor, not starting_after

GET /webhook-endpoints and GET /customers/:id/payment_methods are not paginated. They return every row in one response, and has_more is always false.

Filters#

GET /charges also accepts:

ParamTypeNotes
statusstringFilter by transaction status (e.g. APPROVED, DECLINED, REFUNDED).

Filtering by creation time#

GET /charges and GET /customers accept a created window, in Unix seconds:

bash
curl -G "https://api.worthypayments.com/v1/charges" \
  -d "created[gte]=1733600000" \
  -d "created[lt]=1733686400" \
  -d limit=100 \
  -H "x-api-key: sk_live_xxx"
FormMeaning
created[gte] / created[lte]inclusive bound
created[gt] / created[lt]exclusive bound
created=1733600000exact match on that second

Send each bound once, and either created= or created[...], not both. A repeated bound or the two forms together is a 400 parameter_invalid, never a guess at which one you meant.

Seconds, not milliseconds. Date.now() is milliseconds; passed here it would land tens of thousands of years in the future and match nothing, so we reject it with a 400 rather than return a convincing empty page. Use Math.floor(Date.now() / 1000).

This filters when a record was CREATED, not when it last changed. A charge created last week and enriched an hour ago (an ARN landing, a settlement id) does not reappear in a created[gte]=yesterday window. For "what changed since T", use GET /v1/events, which is the change feed. /v1/disputes and /v1/pre_dispute_alerts are the exception: they take updated_since and are ordered oldest-first for exactly that job.

Unrecognized query parameters are silently dropped rather than rejected, so a made-up filter returns an unfiltered page that looks like a valid answer. Check spelling.

GET /refunds accepts charge to list refunds for one charge. GET /customers/:id/transactions lists the charges belonging to one buyer, in the same row shape as GET /charges.


Why no total count?#

Counting every matching row on a table with tens of millions of records is slow and the number is stale the instant you read it. Cursor paging is O(page size) regardless of how far in you are, and never skips or double-counts rows inserted while you page. If you need a count for a specific window, page through the list and stop when created falls outside the window. The results are ordered newest-first, so you can break early.