Home › Documentation › API › Pagination

Pagination

Three fields and two parameters. limit and cursor go in; next_cursor and open_total come back.

The parameters

ParameterWhereValue
limitGET /work, GET /activity, GET /events1–200. Default 50. Enforced, not clamped: a value outside the range is brought into it, so limit=0 behaves as 1 and limit=5000 behaves as 200.
cursorGET /workAn opaque string from a previous response's next_cursor. Do not construct one, do not decode one, do not store one for later use.
statusGET /workopen, in_progress or waiting.
kindGET /workA work kind. Get the valid values for a desk from GET /desk.
arrangementGET /workAn arrangement id, to narrow the queue to one arrangement.

The response

{
  "data": {
    "items": [ /* up to limit work items */ ],
    "next_cursor": "aWQ6NTg",
    "open_total": 34
  }
}
FieldMeaning
itemsThis page. May be empty while later pages still exist, if a filter removes everything in the window.
next_cursorA string when there is more, and null when there is not. Null is the only reliable end-of-list signal.
open_totalThe size of the whole open queue before filtering and paging. It does not change as you page, and it does not reflect status, kind or arrangement.

open_total is not the number of results. Ask for /work?status=waiting&limit=5 on a desk with six waiting rows and thirty-four open ones and you get five items with open_total: 34. The field answers "how much work is on this desk", which is a different question from "how many rows match". There is no count of matching rows in version 1. If a client needs one, it has to page to the end and count.

Paging

Pass the cursor from one response into the next request. Stop when next_cursor is null.

# First page
curl -s "https://outcomedev.com/api/v1/work?limit=50" \
  -H "Authorization: Bearer $OD_KEY"

# Next page - the cursor is the exact string that came back
curl -s "https://outcomedev.com/api/v1/work?limit=50&cursor=aWQ6NTg" \
  -H "Authorization: Bearer $OD_KEY"

A cursor that cannot be read — a truncated one, a hand-made one — is not an error. The list restarts from the beginning, and the response is a valid first page. That is a decision rather than an oversight: a bad cursor produces a correct, if repetitive, answer rather than a failure a client would have to handle. The practical risk is an infinite loop, so a client should stop on a repeated page rather than trusting the cursor blindly.

What is stable, and what is not

Being precise about this is worth more than a promise that cannot be kept. Work is changing while it is being read: rows close, priorities move, new rows arrive.

ThingStable?
The meaning of limit, cursor, next_cursor and open_totalYes. These do what is described here.
The internal contents of a cursorNo. Opaque. It is not an id, not an offset, and not a promise about what the next page contains.
A cursor surviving a later sessionNo. Use one within the paging loop that received it. Do not persist one.
The order of rows across pagesNo. See below.
Every row appearing exactly once across a full walkNo. A row can move position between two page requests and be missed or seen twice.
An item's fieldsMostly. A field can be added without a version change, so ignore keys you do not know.

The ordering is the screen's ordering, not a guarantee

GET /work returns rows in the order the product's own screen shows them: overdue first, then by priority, then by due date. That is a presentation decision, made so that somebody opening the desk at nine in the morning does not have to decide what to work on. It is not a stable sort and it is not a contract.

Concretely, a client should not: assume two consecutive pages are globally sorted, page through the queue and treat the concatenation as a snapshot, or use a row's position as an identifier. If a specific order is needed, sort in the client after fetching.

The queue is read to a fixed depth

There is one honest limit worth stating, because it would otherwise be discovered as a missing row. The queue behind /work is read to a fixed depth before limit and cursor are applied. On a desk with an exceptionally large open queue, the tail beyond that depth is not returned and no cursor reaches it.

In practice this is far beyond any real desk's open work. It is mentioned because a silent truncation is exactly the kind of thing a caller should not have to discover, and because the mitigation is simple: narrow the queue with status, kind or arrangement rather than reading it whole. A filtered read is a smaller queue, and a smaller queue pages completely.

Which endpoints page

EndpointPagingOrder
GET /worklimit and cursor, plus next_cursor and open_totalThe screen's working order
GET /activitylimit only. No cursor, no totalNewest first
GET /eventslimit only. No cursor, no totalNewest first
GET /arrangementsNone. Returns everythingMost in need of attention first
GET /unitsNone. Returns everythingThe desk's own order
GET /connections, /domains, /permissions, /watchesNone. Small, fixed-size sets—

/activity and /events are the two that catch people out: they take a limit, so they look pageable, and there is no cursor. Asking for the next page means raising limit, and once the limit is at 200 there is no further page. For a full record, read /activity regularly and keep what you need, or ask support@outcomedev.com about the volume.

A complete walk, in JavaScript

The loop that is safe to write: follow the cursor, stop on null, and stop if a page repeats.

const BASE = 'https://outcomedev.com/api/v1'
const headers = { Authorization: `Bearer ${process.env.OUTCOMEDEV_API_KEY}` }

async function allOpenWork({ status, kind } = {}) {
  const out = []
  const seenPages = new Set()
  let cursor = null

  for (;;) {
    const url = new URL(`${BASE}/work`)
    url.searchParams.set('limit', '200')
    if (status) url.searchParams.set('status', status)
    if (kind) url.searchParams.set('kind', kind)
    if (cursor) url.searchParams.set('cursor', cursor)

    const res = await fetch(url, { headers })
    if (!res.ok) throw new Error(`List failed with ${res.status}`)

    const { data } = await res.json()
    const fingerprint = data.items.map((i) => i.id).join(',')

    // A cursor that cannot be read restarts the list. Stop rather than loop.
    if (fingerprint && seenPages.has(fingerprint)) break
    if (fingerprint) seenPages.add(fingerprint)

    out.push(...data.items)
    cursor = data.next_cursor
    if (!cursor) break
  }

  return out
}

The fingerprint check is there for one reason. A client that follows next_cursor without it will loop for ever if a cursor ever fails to decode, because the service will keep answering with the first page and a fresh cursor. That is a property of the API worth coding against, not a defect to report.

  • Work — the row being paged, and the ordering in context.
  • Idempotency — the same care about retries, applied to writes.
  • Errors — and why a bad cursor is not one.