Pagination

Search Documentation

Search across all developer documentation

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:

ParameterDefaultMaxMeaning
limit50200How many items to return.
cursornonen/aOpaque 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

# First page
curl -s -D - 'https://api.sendops.dev/v1/messages?limit=50' \
-H "Authorization: Bearer sk_live_..."

The response headers include:

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.

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.

If you really need a count

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.

# First page filtered by channel
curl 'https://api.sendops.dev/v1/messages?channel=transactional&limit=50'

# Next page — same filter, plus the cursor
curl 'https://api.sendops.dev/v1/messages?channel=transactional&limit=50&cursor=...'

# If you switch channels you MUST drop the cursor:
curl 'https://api.sendops.dev/v1/messages?channel=marketing&limit=50'

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.