Pagination
View as MarkdownMost 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:
{
"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:trueif more results exist after this page.next_cursor: the value to send asstarting_afterfor the next page. Present only whenhas_moreistrue. The field is omitted entirely on the last page, so read it with that in mind rather than expectingnull.
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.
# 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.
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#
| Param | Type | Default | Notes |
|---|---|---|---|
limit | integer | see below | 1 to 100. Values above 100 are clamped; a non-numeric or 0 value falls back to the default rather than erroring. |
starting_after | string | — | The next_cursor from the previous page. Opaque. |
Defaults differ by endpoint:
| Endpoint | Default limit | Max | starting_after |
|---|---|---|---|
GET /charges, /refunds, /subscriptions, /customers, /products | 10 | 100 | opaque next_cursor |
GET /events | 20 | 100 | 1-based page number |
GET /fraud/blocklist | 100 | 500 | query 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:
| Param | Type | Notes |
|---|---|---|
status | string | Filter by transaction status (e.g. APPROVED, DECLINED, REFUNDED). |
Filtering by creation time#
GET /charges and GET /customers accept a created window, in Unix seconds:
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"
| Form | Meaning |
|---|---|
created[gte] / created[lte] | inclusive bound |
created[gt] / created[lt] | exclusive bound |
created=1733600000 | exact 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.
