---
title: "Pagination"
description: "One page, every page, or one item at a time, on every list that pages."
url: "https://openemail.uk/docs/ruby/pagination"
area: "Ruby"
category: "Getting started"
---

# Pagination

One page, every page, or one item at a time, on every list that pages.

## list, list_all and iterate

Every list that pages has three methods. `list` fetches one page and returns an `OpenEmail::Page`. `list_all` follows the cursor through every page and returns one Array. `iterate` walks the same pages one item at a time: it yields each item to a block, or returns an Enumerator when you give it none. All three take the list’s filters, `limit:`, `cursor:` and `api_key:`.

**three_ways.rb**

```
page = client.emails.list(status: "failed", limit: 50)
page.items.each { |email| puts "#{email[:id]} #{email[:lastError]}" }

failures = client.emails.list_all(status: "failed")

client.emails.iterate(status: "failed") do |email|
  puts email[:id]
end

puts failures.size, page.has_more?
```

The same three names repeat wherever a namespace has more than one list, named after the list they walk: `list_events`, `list_all_events` and `iterate_events` on `emails`, `list_deliveries`, `list_all_deliveries` and `iterate_deliveries` on `webhooks`, and so on.

## OpenEmail::Page

- `items` (Array<Hash>): The rows of this page, lifted out of the API’s `data` envelope, each a Hash with Symbol keys. Empty when the page holds nothing.
- `has_more?` (Boolean): Whether another page follows. `has_more` without the question mark reads the same value. When the API sends no `hasMore`, it is true exactly when there is a `next_cursor`.
- `next_cursor` (String or nil): What to pass back as `cursor:` for the next page, and nil on the last one.

A page is a Ruby Data object, so it is frozen, compares by value, and turns into a Hash with `to_h`.

## A block or an Enumerator

Given a block, `iterate` walks every page now and yields each item to it. Without one it returns an Enumerator and fetches nothing until you consume it. Either way it asks for the next page only once every item of the current one has been yielded, so anything that stops early stops the requests too: `first(10)` reads only as many pages as ten items need, `find` stops at the match, and `break` in a block ends the walk.

**enumerator.rb**

```
latest = client.emails.iterate(status: "failed", limit: 100).first(10)

invoice = client.emails.iterate(status: "failed").find do |email|
  email.dig(:tags, :invoice) == "inv_2026_09_4192"
end

from_api = client.emails.iterate(status: "bounced").lazy.select { |email| email[:source] == "api" }.first(5)

p latest.size, invoice&.fetch(:id), from_api.map { |email| email[:id] }
```

An Enumerable method that needs every item, such as `select`, `map` or `count` called straight on the Enumerator, reads every page before it returns, the way `list_all` does. Put `lazy` in front to chain them and still stop early.

> An Enumerator starts its walk again each time it is consumed, so calling `first(10)` on the same one twice fetches the first page twice. Keep the result, not the Enumerator, when you need it again.

## Resuming from a cursor

A cursor is opaque. Keep the `next_cursor` of the last page you read and pass it back as `cursor:` to carry on from there, in a later request or in another process. `list_all` and `iterate` take `cursor:` too, and start their walk after it.

**resume.rb**

```
first_page = client.emails.list(status: "failed", limit: 25)
saved = first_page.next_cursor

if saved
  rest = client.emails.list_all(status: "failed", cursor: saved)
  puts rest.size
end
```

> A cursor belongs to the list and the filters it came from, so send the same filters with it. One the list cannot place is refused with `invalid_cursor`, and the answer then is to start again without one.

## limit:

`limit:` is the size of each page, not a total. On `list` it is how many rows come back. On `list_all` and `iterate` it is how many each request asks for, so a larger value means fewer round trips for the same rows. Each list has its own range and default, most often 1 to 100 with 25 when you send none, and a value outside the range is refused rather than clamped. The page for each list gives its range.

## When a walk stops

- When a page says `has_more?` is false.
- When a page carries no `next_cursor`, since a page that claims more while naming no cursor would loop for ever.
- When the API hands back the cursor it was just given, for the same reason.

Each page is a GET, so it is retried on its own like any read before anything raises. A failure that survives the retries raises out of `list_all`, and the items already fetched are discarded. In `iterate` the items of the earlier pages have already been yielded by then, so make what the block does safe to run twice, or page with `list` and keep each `next_cursor` so a second attempt can start where the first one stopped.

## Threads and drafts

`threads.list` and `drafts.list`, with their `list_all` and `iterate`, page with the API’s `pageToken` and `nextPageToken` rather than a cursor. The gem hides the difference: pass the token as `cursor:` and read it from `next_cursor`.

**page_token.rb**

```
page = client.threads.list(folder: "inbox", limit: 50)
later = client.threads.list(folder: "inbox", limit: 50, cursor: page.next_cursor) if page.has_more?

p page.items.size, later&.items&.size
```

> The server offers a token whenever a page comes back full, so `has_more?` can be true on what turns out to be the last page, and the next call then returns no items.

## Pages that carry more

A few lists answer with more than rows, and return a Data object of their own in place of `OpenEmail::Page`.

| Method | Returns | What it adds |
| --- | --- | --- |
| `addresses.list` | `OpenEmail::AddressBookPage` | `addresses` in place of `items`, plus `unrestricted` and `domains`, with `has_more?` and `next_cursor`. |
| `addresses.list_all` | `OpenEmail::AddressBook` | Every address in `addresses`, with `unrestricted` and `domains` as the last page reported them. It is the one `list_all` that returns the whole address book rather than an Array. `addresses.iterate` yields the addresses alone. |
| `contacts.list_people` | `OpenEmail::PeoplePage` | `seen`, false when the key cannot read the addresses seen in mail. `list_all_people` and `iterate_people` return the people alone. |
| `temp_mail.list_messages` | `OpenEmail::TempMessagesPage` | `expires_at`, when the inbox runs out. `list_all_messages` and `iterate_messages` return the messages alone. |
| `templates.list_sends` | `OpenEmail::TemplateSends` | Paged by number rather than by cursor: `items`, `total`, `page` and `page_size`. Ask for the next page with `page:`. |
| `emails.send_batch` | `OpenEmail::BatchResult` | Not a page: `items`, one for each message you sent, with the `sent` and `failed` counts. |

## Lists that return the API’s Hash

Some lists page by offset, by page number or by a numeric cursor of their own, and return the parsed body as it came, a Hash with `data`, rather than an `OpenEmail::Page`. They have no `list_all` or `iterate`, so you page them yourself.

| Method | Pages with | What comes back |
| --- | --- | --- |
| `exports.list` | `limit:` and `offset:` | `data`, `total` and `hasMore`. |
| `imports.list_failures` | `after:` and `limit:` | `data`, and `nextCursor`, an Integer to pass back as `after:` that is nil on the last page. |
| `subscriptions.list` and `subscriptions.list_domains` | `limit:` and `offset:` | `data`, `total`, `counts` and `hasMore`. |
| `billing.list_invoices` | `page:` and `limit:` | `data`, `total`, `page`, `limit`, `hasMore` and `metered`. |

**offset_paging.rb**

```
offset = 0

loop do
  batch = client.subscriptions.list(status: "active", limit: 50, offset:)
  batch[:data].each { |row| puts "#{row[:senderEmail]} #{row[:total]}" }

  break unless batch[:hasMore]

  offset += batch[:data].size
end
```

> A list that is not paged at all, such as `languages.list`, `labels.list_colors` or `roles.list_permissions`, returns an Array straight away.
