# Pagination

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

| 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**:

```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"
```

| 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`](./19-events.md), 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.
