# Pagination

Every list endpoint in the SendOps API uses **opaque cursor pagination** with the standard `Link` header. Offsets, page numbers, and totals are deliberately not exposed.

## The contract

A list request accepts two query parameters:

| Parameter | Default | Max  | Meaning                                                             |
|-----------|---------|------|---------------------------------------------------------------------|
| `limit`   | 50      | 200  | How many items to return.                                           |
| `cursor`  | _none_  | _n/a_| Opaque value from the previous response's `Link: ...; rel="next"`.  |

The response includes:

- The items in the response body as `{ "data": [ ... ] }`.
- A `Link` header carrying the `next` cursor, if more data exists.
- (Optionally) `page_info.has_more` and `page_info.next_cursor` in the body for clients that prefer not to parse headers.

## A complete iteration



The response headers include:

<CodeBlock lang="http" code={`HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.sendops.dev/v1/messages?limit=50&cursor=eyJvZmZzZXQiOjUwfQ>; rel="next"

{
  "data": [ /* 50 message records */ ],
  "page_info": {
    "has_more": true,
    "next_cursor": "eyJvZmZzZXQiOjUwfQ"
  }
}`} />

To advance, **follow the URL from the `Link` header verbatim**. The cursor is opaque — its internal shape may change over time, between endpoints, or in response to filter parameters. Treat it as a string token, never decode or modify it.

When the response has no `Link: ...; rel="next"` header (and `page_info.has_more` is `false`), you've reached the end.

<CodeBlock lang="javascript" code={`async function* iterateAll(initialPath) {
  let url = new URL(initialPath, "https://api.sendops.dev").toString()
  while (url) {
    const res = await fetch(url, {
      headers: { Authorization: \`Bearer \${process.env.SENDOPS_API_KEY}\` },
    })
    if (!res.ok) throw new Error(\`status \${res.status}\`)
    const body = await res.json()
    yield* body.data
    const link = parseLinkHeader(res.headers.get("Link"))
    url = link.next ?? null
  }
}

function parseLinkHeader(h) {
  if (!h) return {}
  return Object.fromEntries(
    h.split(",").map(p => {
      const [u, rel] = p.trim().split(";")
      return [rel.split("=")[1].replace(/"/g, ""), u.slice(1, -1)]
    })
  )
}`} />

## Why no offset?

Two reasons:

1. **Correctness under churn.** Lists are sorted by `created_at` descending. New items arrive constantly. Offset 100 in two consecutive requests can refer to different items. Cursors anchor to a specific position in the list so you never see the same row twice or skip a row between pages.
2. **Performance.** Offset pagination requires the database to walk past every skipped row. Cursors translate to direct index lookups — page 1 and page 100 cost the same.

If you're tempted to ask "what page am I on?" — the answer is "doesn't matter; ask for the next page until there isn't one."

## Why no total count?

Total counts are expensive on large tables and they'd lie anyway under churn. The accurate question to ask is "is there more after this page?" — and `Link` / `page_info.has_more` answers it precisely.


  Use the reporting endpoints. `GET /v1/reports/deliverability` returns aggregate counts (sent, delivered, bounced, complained) for a time range. Those numbers are computed off the analytics store and are designed for counting; list endpoints aren't.


## Filter parameters and cursors

The cursor encodes the position **within the current filter set**. If you change a filter between requests, the previous cursor is meaningless — start over with no cursor.



## Errors specific to pagination

- A malformed cursor returns `422 Unprocessable Entity` with `type: validation_failed`. Drop the cursor and start from the first page.
- An expired cursor (rare — only happens if a backing store changes shape during a migration) returns the same error class. Same response: drop it and start over.

## Practical tips

- **Always check for `has_more`** (or the `Link` header) before assuming you're done. A response with fewer items than `limit` does not by itself mean the list is exhausted.
- **Use the largest `limit` you reasonably can.** Round-trip latency dominates time-to-completion for large lists; bigger pages = fewer round trips.
- **Don't store cursors long-term.** They are valid for the current iteration only. If you're checkpointing a long-running export, record the last item's `id` and `created_at` and use those as filter inputs on resume.



<ApiSidePanel
  requestTabs={[
    {
      label: 'cURL',
      lang: 'bash',
      code: `# First page
curl -s -D - 'https://api.sendops.dev/v1/messages?limit=50' \\
  -H "Authorization: Bearer sk_live_..."

# Next page — cursor from the previous Link header
curl 'https://api.sendops.dev/v1/messages?limit=50&cursor=eyJvZmZzZXQiOjUwfQ'`,
    },
  ]}
  responseCode={`HTTP/1.1 200 OK
Link: <https://api.sendops.dev/v1/messages?cursor=eyJvZmZzZXQiOjUwfQ>; rel="next"

{
  "data": [ /* 50 messages */ ],
  "page_info": {
    "has_more": true,
    "next_cursor": "eyJvZmZzZXQiOjUwfQ"
  }
}`}
  responseLang="http"
/>