SonarSend API Getting started Lists & pagination

Lists & pagination

Every endpoint that returns a collection returns the same object. Learn it once and it applies everywhere.

{
  "data": [ { "id": "…" }, { "id": "…" } ],
  "next_cursor": null,
  "has_more": false
}
FieldTypeMeaning
dataarrayThe rows. Empty when nothing matches — never null, and never a 404.
next_cursorstring | nullOpaque resume point. Pass it back as cursor. Null when there is no continuation — the last page, or a collection that isn’t paginated.
has_morebooleanWhether another page exists.
totalintegerTotal matching rows. Only on offset-paged endpoints — see below.

data is always an array and the envelope is always an object, so a client can be written once against this shape. Treat next_cursor as opaque: it is not a timestamp or an ID, its encoding is not part of the contract, and constructing one yourself will break.

Not every collection is paginated#

Small collections — sending identities, email types, stages, custom fields — return everything in one response, with next_cursor: null and has_more: false. Those fields are not padding: they are the truthful answer (“there is nothing after this”), and they mean an endpoint can gain real pagination later without changing shape a second time.

Write the loop anyway. Code that stops when has_more is false is correct today on every endpoint and stays correct if one of them starts paginating.

Two paging mechanisms#

Most paginated endpoints are cursor-paged: pass the previous response’s next_cursor back as cursor. Cursors are stable under inserts — a row added while you are paging will not shift the window and cause a row to repeat or be skipped, which is exactly what page-number paging does under a live write load.

A few are offset-paged, taking limit and offset. Those also return total, since the count is already known. Cursor-paged endpoints deliberately omit total rather than pay for a second full scan to produce it.

You can tell which is which from the endpoint’s parameters in the reference. The envelope does not change either way.

Paging to the end#

CURSOR=""
while : ; do
  RESP=$(curl -s "$BASE/api/contacts?limit=200${CURSOR:+&cursor=$CURSOR}" \
    -H "X-API-Key: $API_KEY")

  echo "$RESP" | jq -c '.data[]' >> contacts.ndjson

  [ "$(echo "$RESP" | jq -r '.has_more')" = "true" ] || break
  CURSOR=$(echo "$RESP" | jq -r '.next_cursor')
done

Two things worth doing:

  • Keep the sort stable across pages. A cursor encodes the sort it was created under, so changing sort or dir mid-run is rejected rather than silently re-ordering.
  • For a full read-out of contacts, use an export instead. One export produces one CSV; paging the same audience can cost thousands of requests against your rate-limit budget.

Unknown fields#

Responses gain fields over time. A client must ignore fields it does not recognise rather than reject the response — that is the compatibility posture the whole API is written to, and it is what lets new data ship without a breaking change.