RaabtaHQ
Pagination

Pagination

Cursor pagination on every list endpoint: the limit bounds, the meta block, next_cursor and has_more, and why a cursor never skips or repeats a row.

Cursors, not offsets

Every list endpoint returns newest first and pages with an opaque cursor. Pass the previous page's next_cursor as ?cursor= to get the next page; when it is null you have everything.

The cursor encodes the position of the last row you saw, ordered by creation time with the id as a tiebreak. Because that pair is immutable and unique, a walk through a list is stable under concurrent inserts: a record created while you page appears at the front of a fresh walk, and a row you have seen is never repeated or skipped. Offset pagination cannot promise that, which is why it is not offered.

Cursors are opaque and specific to the endpoint and filters they came from. Do not construct one, and do not reuse one across endpoints. A malformed cursor returns 400 bad_request.

Page size

?limit= is 20 by default and at most 100. A value outside 1–100 returns 400 validation_error rather than being silently clamped — a clamped 1000 would hide a client bug behind a page that looks right.

The meta block

A list response is { data: [...], meta: { ... } }. The three fields of meta:

NameTypeDescription
next_cursorrequired
string | nullPass as `?cursor=` to fetch the next page; null on the last page
has_morerequired
booleanWhether another page exists
limitrequired
integerThe page size that was applied

has_more is known without a count query — the server fetches one row past the limit — so it is cheap and always accurate for the moment the page was built.

Example

GET /contacts with a page size and a search filter:

Request
curl "https://your-crm.example.com/api/v1/contacts?limit=20&q=ayesha" \
  -H "Authorization: Bearer $RAABTA_API_KEY"
Response · 200 OK
{
  "data": [
    {
      "id": "9d7e4c11-5a2b-4f7c-8e3d-2a1b0c9d8e7f",
      "name": "Ayesha Khan",
      "phone": "+971501234567",
      "email": "ayesha@example.com",
      "company": "Khan Trading LLC",
      "avatar_url": null,
      "tags": [
        {
          "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
          "name": "VIP",
          "color": "#10b981"
        }
      ],
      "custom_fields": [
        {
          "field_id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
          "name": "Source",
          "value": "Instagram ad"
        }
      ],
      "created_at": "2026-09-01T08:15:00.000Z",
      "updated_at": "2026-09-02T10:00:00.000Z"
    }
  ],
  "meta": {
    "next_cursor": null,
    "has_more": false,
    "limit": 20
  }
}

Walking every page:

Node
const url = new URL("https://your-crm.example.com/api/v1/contacts");
url.searchParams.set("limit", "100");

let cursor = null;
do {
  if (cursor) url.searchParams.set("cursor", cursor);
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.RAABTA_API_KEY}` },
  });
  const { data, meta } = await res.json();
  for (const row of data) handle(row);
  cursor = meta.next_cursor;
} while (cursor);

Filters (q, status, updated_since, …) are passed alongside the cursor on every page. Changing a filter mid-walk invalidates the cursor; start again from the first page.

Paginated endpoints

Every endpoint that returns a page. The rest return one object.

  • Lists are ordered by created_at descending, always. Conversations are not ordered by last activity, because that moves; use updated_since to find recent activity.