# Pagination, filtering, sorting

Core API v2 paginates by **cursor only**. `page` and `pageSize` are not part of the v2 contract, and v2 also changes filtering semantics and the set of list endpoints.

Coming from `/v1/...` or the OMS API? [Upgrading from v1 to v2](https://docs.odus.com/docs/guides/upgrading/v1-to-v2.md) lists every difference, and the v1 guides are one switch away in the version picker in the top bar.

## Pagination

v2 has one pagination strategy: **cursor pagination**. It uses opaque cursors to page through results and stays fast and consistent on large, frequently changing datasets, where offset pagination can skip or repeat rows as data shifts underneath a page number.

Because there is only one strategy, `limit` is no longer an opt-in switch — it is simply the page size. Send no pagination parameters at all and you get the first page with the default size:

```
GET /v2/payments

GET /v2/payments?limit=25
```

`limit` defaults to 10 and is capped at 100. Send `limit=0` to return no items at all, which is how you ask for a count on its own — see [Counting the matches](#counting-the-matches).

Every response carries `pagination.mode: "cursor"`.

`page` and `pageSize` are rejected, not ignored

A v2 request carrying `page` or `pageSize` is answered with `400` and an `errors` entry naming the parameter. They are not silently dropped: `?page=5` would otherwise return the *first* page's rows while looking like the fifth, which is a wrong answer you have no way to detect.

### What you give up, and what to send instead

Cursor pagination has no jump-to-page. v2 responses carry no `pageCount`, and there is no way to ask for "page 5".

* **To walk a whole result set**, follow `nextCursor` until `hasNextPage` is `false`.
* **To show a result count**, send `withTotal=true` — see [Counting the matches](#counting-the-matches).
* **To show which rows you are on** ("21–40"), keep a running total of the rows you have already received. Add `data.length` to it each time you step forward and subtract the page you leave behind when you step back. Do not multiply the page number by `limit` — `limit` may change between pages, so the two disagree as soon as it does.

### Counting the matches

Send `withTotal=true` and the response's `pagination` gains `totalCount`, the number of items your filters match:

```
GET /v2/payments?limit=20&status[eq]=cancelled&withTotal=true
```

```
{

  "pagination": {

    "mode": "cursor",

    "pageSize": 20,

    "hasNextPage": true,

    "hasPreviousPage": false,

    "nextCursor": "eyJzIjoiY3JlYXRlZEF0IiwiZCI6ImFzYyIsIm4iOiJmb3J3YXJkIn0",

    "prevCursor": null,

    "totalCount": 139,

    "totalCountIsExact": true

  },

  "data": [

    /* Array of items */

  ]

}
```

Three things to know before you show that number:

**Check `totalCountIsExact` first.** Counting stops at 10,000. When more items match, `totalCount` holds 10,000 and `totalCountIsExact` is `false` — the real figure is higher and unknown, so show it as a bound like "over 10,000" rather than as a total.

**The count ignores `cursor`.** It describes everything your filters match, not what is left ahead of you, so it does not change as you page. Request it once when the filters change rather than on every page.

**Omit it when you do not need it.** A request without `withTotal` does no counting at all.

To fetch the count on its own — when the filters change but you already have the rows, or when you want the count and the page to load independently — pair it with `limit=0`:

```
GET /v2/payments?limit=0&status[eq]=cancelled&withTotal=true
```

That returns an empty `data` array and no cursors, because a response with no rows is not a page you can navigate from.

Narrow the list before counting

If a count feels slow on a large dataset, add a date range alongside your other filters.

### Paging

On the first request, send `limit` and omit `cursor`:

```
GET /v2/payments?limit=10
```

To fetch another page, pass the returned `nextCursor` or `prevCursor` value back in `cursor`, keeping the same `sortBy` and `sortDirection`:

```
GET /v2/payments?limit=10&sortBy=createdAt&sortDirection=desc&cursor=eyJzIjoiY3JlYXRlZEF0IiwiZCI6ImRlc2MiLCJuIjoiZm9yd2FyZCJ9
```

A cursor encodes the sort field and direction it was issued for, and a request whose `sortBy` or `sortDirection` disagrees is rejected. `limit` is not part of the cursor — you may change it between pages, and it simply changes the size of the next page. Cursors are opaque; do not construct or modify them.

An invalid `limit` falls back to the default page size. `0` is valid and means "no items"; a negative number is not.

## Sorting

Sort with `sortBy` and `sortDirection`:

```
GET /v2/payments?sortBy=createdAt&sortDirection=desc
```

Each endpoint supports its own `sortBy` fields. The v2 field set is listed in [Supported v2 endpoints](#supported-v2-endpoints).

## Filtering

Filter criteria use LHS bracket notation:

```
GET /v2/payments?amount[gt]=100&status[eq]=succeeded
```

The v2 filter operator set is:

| Data type            | Operators                                         |
| -------------------- | ------------------------------------------------- |
| Boolean              | `eq`, `ne`                                        |
| Number               | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` |
| String               | `eq`, `ne`, `like`, `ilike`, `in`, `nin`          |
| String (exact match) | `eq`, `ne`, `in`, `nin`                           |
| Date                 | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` |
| JSON metadata values | `eq`, `ne`, `like`, `in`, `nin`                   |

`contains` is not in this table because it depends on the field rather than on the data type: it applies only to a field that holds several values, such as `supportedPaymentMethods`. This is the same in v1 and v2. The API reference lists it on those fields, and sending it on another field the endpoint publishes is answered with `400`, naming the field.

A field marked **String (exact match)** in [Supported v2 endpoints](#supported-v2-endpoints) is matched against its whole value: an ID, a reference to another object, a field that holds one of a fixed set of values such as `status`, `currency` or `type`, or card digits such as `card.lastFour`. These accept `eq`, `ne`, `in` and `nin`. `like` or `ilike` on one of them is answered with `400`, naming the field. To match several values, list them with `in`.

The processor ID filters, `processorId` on [Transactions](#transactions) and `latestTransaction.processorId` on [Payments](#payments), accept only `eq` and `in`. Send the whole processor ID, in the case the processor returned it. `url` on [Logs](#logs) also accepts only `eq` and `in`: send the whole URL as the log entry shows it, query string included. Any other operator on these fields is answered with `400`, naming the field.

### Filtering by a range of card BINs

`paymentMethod.card.bin` on [Payments](#payments) and on the payment summary report ([`GET /v2/reports/payment-summary`](https://docs.odus.com/docs/api/core/v2/reporting-controller-get-payment-summary-report)) also accepts `gte` and `lte`, so you can select every payment whose card BIN is inside a range:

```
GET /v2/payments?paymentMethod.card.bin[gte]=468800&paymentMethod.card.bin[lte]=468899
```

A BIN is the first 4 to 8 digits of a card number, so the range compares the card numbers each BIN starts, not the text. A bound stands for every card number that starts with it: `gte=4688` and `lte=4688` together select every BIN that begins with `4688`, whether it is 4, 6 or 8 digits long. You can send either bound on its own.

A payment matches only when every card number its BIN starts is inside the range. When your bounds have more digits than a payment's BIN, that payment can fall on both sides of a bound, so it is left out. For example, a payment with BIN `468839` is not returned for `gte=46883950` and `lte=46883999`. Each payment returns its BIN in `paymentMethod.card.bin`, so you can see how many digits it has. To include such payments, use bounds with no more digits than the BIN.

`eq`, `ne`, `in` and `nin` still match the whole BIN exactly. A bound that is not 1 to 8 digits, a `gte` above the `lte`, and `gt` or `lt` on this field are answered with `400`, naming the field.

### Case sensitivity

String filters are case-sensitive in v2 unless you explicitly use `ilike`. For example, `?status[eq]=Succeeded` can match `succeeded` in v1, but in v2 it returns an empty page if the stored value is lowercase. Send an exact-match field such as `status` in the case the API returns it. On a text field, use `ilike` when you want case-insensitive matching, for example `?name[ilike]=acme`.

Email addresses are the exception. ODUS stores `customer.email` and the Blacklist `email` in lowercase, and lowercases the value you filter by, so `?email[eq]=Foo@Example.com` and `?email[eq]=foo@example.com` return the same customer. Use `eq` for an email lookup.

The `like` operator is also different in v2: it is a prefix match (`value%`), not the v1 substring match (`%value%`).

Silent filter changes

A case mismatch or a v1-style substring `like` query does not return an error in v2. It simply returns fewer rows, often an empty page.

### Using `in` and `nin`

Pass array values by repeating the query parameter:

```
GET /v2/payments?status[in]=succeeded&status[in]=pending
```

Comma notation (for example, `status[in]=succeeded,pending`) is parsed literally and is not supported.

### Using `$and` and `$or`

The structural `$and` and `$or` operators remain available in v2. They are query-string structures, so they are not listed as scalar OpenAPI parameters.

`$or` is supported only at the root level, and one `$or` group cannot be nested inside another. It *can* be combined with other root filter criteria: those are applied alongside the group with logical `AND`. Conditions inside one `$or` group are also combined with logical `AND`.

```
GET /v2/payments?$or[0][status][eq]=succeeded&$or[0][isCaptured][eq]=true&$or[1][status][eq]=succeeded&$or[1][isReversed][eq]=true&$or[1][isCaptured][eq]=true
```

Use `$and` when multiple `contains` conditions must all be true:

```
GET /v2/gateway-profiles?$and[0][supportedPaymentMethods][contains]=applepay&$and[1][supportedPaymentMethods][contains]=card
```

### JSON metadata filtering

JSON fields, such as `metadata`, support filtering by key with bracket notation. Metadata value filters are case-sensitive.

```
GET /v2/payments?metadata[field1][eq]=value
```

When filtering metadata fields with `ne`, `nin`, `like`, `in`, or comparison operators, entities without that metadata key are excluded due to SQL NULL semantics. To include missing keys, use `$or`:

```
GET /v2/payments?$or[0][metadata][field1][ne]=test&$or[1][metadata][field1][eq]=null
```

## Supported v2 endpoints

The Users, Jobs, and Job Items list endpoints that appear in the v1 guide are not served by v2.

### Accounts

Endpoint: [`GET /v2/accounts`](https://docs.odus.com/docs/api/core/v2/account-controller-find-all-paginated)

Sortable fields: `id`, `name`, `createdAt`

Filterable fields:

| Field       | Data Type            |
| ----------- | -------------------- |
| `id`        | String (exact match) |
| `name`      | String               |
| `createdAt` | Date                 |

### Cascades

Endpoint: [`GET /v2/cascades`](https://docs.odus.com/docs/api/core/v2/cascade-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `active`, `name`

Filterable fields:

| Field       | Data Type            |
| ----------- | -------------------- |
| `id`        | String (exact match) |
| `name`      | String               |
| `active`    | Boolean              |
| `algorithm` | String (exact match) |
| `createdAt` | Date                 |
| `updatedAt` | Date                 |

### Customers

Endpoint: [`GET /v2/customers`](https://docs.odus.com/docs/api/core/v2/customer-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`

Filterable fields:

| Field            | Data Type            |
| ---------------- | -------------------- |
| `id`             | String (exact match) |
| `createdAt`      | Date                 |
| `updatedAt`      | Date                 |
| `email`          | String               |
| `name`           | String               |
| `phoneNumber`    | String               |
| `dateOfBirth`    | Date                 |
| `restrictCit`    | Boolean              |
| `restrictMit`    | Boolean              |
| `restrictReason` | String               |
| `metadata`       | JSON                 |

### Gateway Operations

Endpoint: [`GET /v2/gateway-operations`](https://docs.odus.com/docs/api/core/v2/gateway-operation-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `type`, `status`

Filterable fields:

| Field         | Data Type            |
| ------------- | -------------------- |
| `id`          | String (exact match) |
| `createdAt`   | Date                 |
| `updatedAt`   | Date                 |
| `type`        | String (exact match) |
| `status`      | String (exact match) |
| `transaction` | String (exact match) |

### Gateway Profiles

Endpoint: [`GET /v2/gateway-profiles`](https://docs.odus.com/docs/api/core/v2/gateway-profile-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`

Filterable fields:

| Field                     | Data Type            |
| ------------------------- | -------------------- |
| `id`                      | String (exact match) |
| `createdAt`               | Date                 |
| `updatedAt`               | Date                 |
| `title`                   | String               |
| `integration`             | String (exact match) |
| `active`                  | Boolean              |
| `supportedPaymentMethods` | String               |

### Invoices

Endpoint: [`GET /v2/invoices`](https://docs.odus.com/docs/api/core/v2/invoice-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `status`

Filterable fields:

| Field          | Data Type            |
| -------------- | -------------------- |
| `id`           | String (exact match) |
| `createdAt`    | Date                 |
| `updatedAt`    | Date                 |
| `status`       | String (exact match) |
| `currency`     | String (exact match) |
| `amount`       | Number               |
| `voidedReason` | String               |
| `periodStart`  | Date                 |
| `periodEnd`    | Date                 |
| `payment`      | String (exact match) |
| `number`       | String               |
| `subscription` | String (exact match) |

### Logs

Endpoint: [`GET /v2/logs`](https://docs.odus.com/docs/api/core/v2/request-log-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`

Filterable fields:

| Field        | Data Type                   |
| ------------ | --------------------------- |
| `id`         | String (exact match)        |
| `createdAt`  | Date                        |
| `updatedAt`  | Date                        |
| `requestId`  | String (exact match)        |
| `url`        | String (`eq` and `in` only) |
| `method`     | String (exact match)        |
| `clientIp`   | String (exact match)        |
| `statusCode` | Number                      |
| `initiator`  | String (exact match)        |

### Payment Methods

Endpoint: [`GET /v2/payment-methods`](https://docs.odus.com/docs/api/core/v2/payment-method-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `isDefault`, `customer`

Filterable fields:

| Field           | Data Type            |
| --------------- | -------------------- |
| `id`            | String (exact match) |
| `createdAt`     | Date                 |
| `updatedAt`     | Date                 |
| `isDefault`     | Boolean              |
| `customer`      | String (exact match) |
| `card.lastFour` | String (exact match) |
| `card.brand`    | String (exact match) |
| `card.expYear`  | String (exact match) |
| `card.expMonth` | String (exact match) |

### Payments

Endpoint: [`GET /v2/payments`](https://docs.odus.com/docs/api/core/v2/payment-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `amount`, `status`, `currency`

Filterable fields:

| Field                                        | Data Type                                                                    |
| -------------------------------------------- | ---------------------------------------------------------------------------- |
| `id`                                         | String (exact match)                                                         |
| `createdAt`                                  | Date                                                                         |
| `updatedAt`                                  | Date                                                                         |
| `amount`                                     | Number                                                                       |
| `status`                                     | String (exact match)                                                         |
| `displayStatus`                              | String (exact match)                                                         |
| `currency`                                   | String (exact match)                                                         |
| `amountCaptured`                             | Number                                                                       |
| `amountReversed`                             | Number                                                                       |
| `captureMethod`                              | String (exact match)                                                         |
| `captureDelay`                               | Number                                                                       |
| `captureAt`                                  | Date                                                                         |
| `statementDescriptor`                        | String                                                                       |
| `isCaptured`                                 | Boolean                                                                      |
| `isReversed`                                 | Boolean                                                                      |
| `isFullyReversed`                            | Boolean                                                                      |
| `isChargebacked`                             | Boolean                                                                      |
| `isRedirectRequired`                         | Boolean                                                                      |
| `initiator`                                  | String (exact match)                                                         |
| `offSessionType`                             | String (exact match)                                                         |
| `latestTransaction`                          | String (exact match)                                                         |
| `latestTransaction.processorId`              | String (`eq` and `in` only)                                                  |
| `latestTransaction.gatewayProfile.id`        | String (exact match)                                                         |
| `latestTransaction.gatewayProfile.title`     | String                                                                       |
| `customer`                                   | String (exact match)                                                         |
| `customer.email`                             | String                                                                       |
| `paymentMethod`                              | String (exact match)                                                         |
| `paymentMethod.type`                         | String (exact match)                                                         |
| `paymentMethod.card.lastFour`                | String (exact match)                                                         |
| `paymentMethod.card.bin`                     | String (exact match), plus a [BIN range](#filtering-by-a-range-of-card-bins) |
| `paymentMethod.card.brand`                   | String (exact match)                                                         |
| `paymentMethod.card.expYear`                 | String (exact match)                                                         |
| `paymentMethod.card.expMonth`                | String (exact match)                                                         |
| `paymentMethodOptions.card.gatewayProfile`   | String (exact match)                                                         |
| `paymentMethodOptions.card.cascade`          | String (exact match)                                                         |
| `paymentMethodOptions.card.mode`             | String (exact match)                                                         |
| `paymentMethodOptions.paypal.gatewayProfile` | String (exact match)                                                         |
| `product.name`                               | String                                                                       |
| `metadata`                                   | JSON                                                                         |
| `product`                                    | String (exact match)                                                         |
| `latestDeclineType`                          | String (exact match)                                                         |

### Prices

Endpoint: [`GET /v2/prices`](https://docs.odus.com/docs/api/core/v2/price-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `amount`, `type`

Filterable fields:

| Field          | Data Type            |
| -------------- | -------------------- |
| `id`           | String (exact match) |
| `createdAt`    | Date                 |
| `updatedAt`    | Date                 |
| `amount`       | Number               |
| `currency`     | String (exact match) |
| `type`         | String (exact match) |
| `name`         | String               |
| `product`      | String (exact match) |
| `product.name` | String               |
| `active`       | Boolean              |
| `metadata`     | JSON                 |

### Products

Endpoint: [`GET /v2/products`](https://docs.odus.com/docs/api/core/v2/product-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `name`, `active`

Filterable fields:

| Field       | Data Type            |
| ----------- | -------------------- |
| `id`        | String (exact match) |
| `createdAt` | Date                 |
| `updatedAt` | Date                 |
| `name`      | String               |
| `active`    | Boolean              |
| `shippable` | Boolean              |
| `category`  | String (exact match) |
| `sku`       | String               |
| `metadata`  | JSON                 |

### Secret Keys

Endpoint: [`GET /v2/secret-keys`](https://docs.odus.com/docs/api/core/v2/secret-key-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`

Filterable fields:

| Field       | Data Type            |
| ----------- | -------------------- |
| `id`        | String (exact match) |
| `createdAt` | Date                 |
| `updatedAt` | Date                 |
| `active`    | Boolean              |

### Subscriptions

Endpoint: [`GET /v2/subscriptions`](https://docs.odus.com/docs/api/core/v2/subscription-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `status`, `currentPeriodStart`, `currentPeriodEnd`, `startDate`, `endDate`

Filterable fields:

| Field                           | Data Type            |
| ------------------------------- | -------------------- |
| `id`                            | String (exact match) |
| `createdAt`                     | Date                 |
| `updatedAt`                     | Date                 |
| `status`                        | String (exact match) |
| `startDate`                     | Date                 |
| `endDate`                       | Date                 |
| `autoBillingEnabled`            | Boolean              |
| `autoBillingDisabledAt`         | Date                 |
| `autoBillingDisabledReason`     | String (exact match) |
| `cancelAtPeriodEnd`             | Boolean              |
| `cancelAtCycle`                 | Number               |
| `currentPeriodStart`            | Date                 |
| `currentPeriodEnd`              | Date                 |
| `currentCycle`                  | Number               |
| `metadata`                      | JSON                 |
| `customer`                      | String (exact match) |
| `customer.email`                | String               |
| `price`                         | String (exact match) |
| `price.product`                 | String (exact match) |
| `setupInvoice.payment`          | String (exact match) |
| `price.product.name`            | String               |
| `upcomingInvoice.periodStart`   | Date                 |
| `invoices.payment.retry.nextAt` | Date                 |

### Transactions

Endpoint: [`GET /v2/transactions`](https://docs.odus.com/docs/api/core/v2/transaction-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`, `amount`, `status`

Filterable fields:

| Field                      | Data Type                   |
| -------------------------- | --------------------------- |
| `id`                       | String (exact match)        |
| `createdAt`                | Date                        |
| `updatedAt`                | Date                        |
| `amount`                   | Number                      |
| `currency`                 | String (exact match)        |
| `status`                   | String (exact match)        |
| `processorId`              | String (`eq` and `in` only) |
| `paymentMethod`            | String (exact match)        |
| `payment`                  | String (exact match)        |
| `gatewayProfile`           | String (exact match)        |
| `customer`                 | String (exact match)        |
| `execution`                | String (exact match)        |
| `paymentMethod.card.bin`   | String (exact match)        |
| `paymentMethod.card.brand` | String (exact match)        |

### Webhook Subscribers

Endpoint: [`GET /v2/webhook-subscribers`](https://docs.odus.com/docs/api/core/v2/webhook-subscriber-controller-find-all-paginated)

Sortable fields: `id`, `createdAt`, `updatedAt`

Filterable fields:

| Field       | Data Type            |
| ----------- | -------------------- |
| `id`        | String (exact match) |
| `createdAt` | Date                 |
| `updatedAt` | Date                 |

### Webhook deliveries

Endpoint: [`GET /v2/webhook-deliveries`](https://docs.odus.com/docs/api/core/v2/webhook-controller-find-all-deliveries-paginated)

Sortable fields: `id`, `createdAt`, `firstDispatchedAt`, `lastDispatchedAt`, `abandonedAt`, `attempts`

Filterable fields:

| Field                  | Data Type            |
| ---------------------- | -------------------- |
| `id`                   | String (exact match) |
| `createdAt`            | Date                 |
| `firstDispatchedAt`    | Date                 |
| `lastDispatchedAt`     | Date                 |
| `abandonedAt`          | Date                 |
| `success`              | Boolean              |
| `attempts`             | Number               |
| `latestResponseStatus` | Number               |
| `event.type`           | String (exact match) |
| `event`                | String (exact match) |
| `subscriber`           | String (exact match) |
