Pagination, errors and polling

The pagination envelopes, the uniform error contract, and how to poll for changes without webhooks

This page covers the cross-cutting contracts shared by every endpoint: pagination, the uniform error shape, and — since the API has no webhooks — how to poll for changes.

Offset pagination

Almost every list endpoint uses the same offset-pagination contract, with two query parameters:

ParamTypeDefaultBehavior
limitinteger50Page size. Clamped to the range [1, 200] — a value below 1 becomes 1, a value above 200 becomes 200.
offsetinteger0Number of items to skip. Floored at 0 — a negative value becomes 0.

Out-of-range values are silently clamped, never rejected. ?limit=10000 doesn't 400 — it's treated as limit=200. The hard ceiling is always 200 items per call.

A paginated list response looks like this:

{
  "data": [ ],
  "pagination": {
    "limit": 50,
    "offset": 0,
    "total": 342
  }
}
  • data — the page of items.
  • pagination.limit / pagination.offset — echo back the effective (post-clamp) values that were applied.
  • pagination.total — the total number of items matching the query, across all pages. Use it to know when to stop paging.

To iterate all pages, increment offset by limit on each call until offset >= total:

async function listAll(path, apiKey, limit = 200) {
  const items = [];
  let offset = 0;
  let total = Infinity;
 
  while (offset < total) {
    const url = new URL(`https://app.kabeen.io/public/v1${path}`);
    url.searchParams.set("limit", limit);
    url.searchParams.set("offset", offset);
 
    const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
    const body = await res.json();
 
    items.push(...body.data);
    total = body.pagination.total;
    offset += limit;
  }
 
  return items;
}

Exception: application catalog — page-number pagination

GET /application-catalog searches the global, workspace-independent reference catalog, which paginates by page number rather than offset/limit:

{
  "data": [ ],
  "total": 1204,
  "page": 1
}

Request the next page with ?page=2, ?page=3, etc. (1-based, default 1). There is no limit query param for this endpoint — the page size is fixed by the catalog service. Stop once data comes back empty.

Exception: audit log — cursor pagination

GET /audit-log is append-only and high-volume, so it uses keyset (cursor) pagination instead of offset, newest-first:

{
  "data": [ ],
  "total": 58213,
  "nextCursor": "2026-07-21T09:12:03.441Z,3f9c...",
  "hasMore": true
}
  • nextCursor is an opaque token — treat it as a black box, don't parse or construct it yourself.
  • Pass it back as ?cursor=... to get the next page.
  • hasMore tells you whether to continue; nextCursor is only meaningful while hasMore is true (it is nullable and absent on the last page).
  • total is still the overall count matching your filters, but you drive the loop off hasMore/nextCursor, not off comparing an offset to total.

Exception: sub-resource lists — bare arrays

Some sub-resource endpoints return a plain JSON array with no envelope at all — no data wrapper, no pagination. These represent small, bounded collections attached to a single parent resource. Examples: GET /applications/{id}/owners, GET /applications/{id}/tags, GET /applications/{id}/teams, GET /applications/{id}/comments, GET /servers/{id}/owners, GET /servers/{id}/tags, GET /networks/{id}/routers, GET /routers/{id}/networks, GET /routers/{id}/tags. A call to one of these returns [ ... ] directly.

Error handling

Every error response raised by the API's own request handling — every 400, 403, 404, 409, 422, and 5xx, on every endpoint — has the same JSON shape:

{
  "code": "not_found",
  "message": "server not found",
  "status": 404
}
FieldTypeDescription
codestringStable, machine-readable error identifier (e.g. not_found, insufficient_permission). Safe to branch on in code.
messagestringHuman-readable explanation. Meant for logs and debugging, not for switch statements.
statusintegerThe HTTP status code, mirrored in the body.

Status codes

StatusMeaningWhen it happens
400 Bad RequestMalformed inputA path or query value can't be parsed — an unparseable UUID, an unknown enum/filter value, an invalid ISO-8601 date-time, or a missing/blank required field.
401 UnauthorizedMissing or invalid credentialsNo Authorization/X-Api-Key header, or the key is unrecognized, revoked, or expired. Special case: see below.
403 ForbiddenNot allowedThe key doesn't carry the permission scope the endpoint requires (insufficient_permission), or the key isn't bound to any workspace at all (forbidden).
404 Not FoundResource doesn't exist in your workspaceThe id doesn't exist, or it belongs to a different workspace. The API never distinguishes the two — both return the same 404, so a key can't use error responses to enumerate resources it doesn't own.
409 ConflictRequest conflicts with current stateReserved by the error contract; no endpoint currently produces it.
422 Unprocessable EntityWell-formed but semantically invalidThe request parses fine but violates a business rule — e.g. trying to change name/os/manufacturer on an agent-reported (automatic) server.
5xxUnexpected server errorNot part of the normal control flow — treat as transient and retry with backoff.

The 401 special case

Every status above comes back as the uniform JSON body — except 401. Authentication is enforced before the request reaches the API's own error handling, so a real 401 comes back as:

  • Status 401, empty body (no JSON — don't try to parse code/message/status out of it).
  • A WWW-Authenticate: Bearer realm="kbine", error="invalid_token" response header.

If your client always expects a JSON error body, special-case 401 (or just branch on status alone).

400 and 422 overlap in spirit but not in intent: 400 means the API couldn't even parse what you sent; 422 means it parsed fine but the meaning is invalid given the current state of the resource.

Known code values

New codes may be added in future releases — always have a default case.

codeTypical statusNotes
bad_request400Generic malformed-input default.
invalid_uuid400A path or query value that should be a UUID couldn't be parsed.
invalid_datetime400A date-time value isn't valid ISO-8601.
unauthorized401Defined by the contract, but not reachable in practice — real authentication failures carry no JSON body.
forbidden403The key isn't bound to any workspace (or another generic authorization failure).
insufficient_permission403The key is bound to a workspace but lacks the required scope.
not_found404The resource doesn't exist in the key's workspace.
conflict409Reserved — not currently emitted by any endpoint.
unprocessable_entity422The request is well-formed but semantically invalid for the target resource.

Best practices: check the HTTP status first, branch on code (never message), log message for diagnosis, and fall back on status for any code you don't recognize.

No webhooks — poll instead

The API does not offer webhooks, event subscriptions, or any push/callback mechanism in this release. It is request/response (pull) only. Two complementary approaches cover almost every "detect a change" use case:

1. Poll the audit log (closest thing to a change feed)

GET /public/v1/audit-log (scope audit_log:read) is the best approximation of an event feed the API has today. It returns workspace audit events, newest first, and supports date-range filtering (from/to), actor filtering (actorId), category/action filtering (category, repeatable actions), resource filtering (resourceType, resourceId), free-text search (query), and cursor pagination (limit max 200 + cursor).

Poll it on an interval, walking forward with nextCursor while hasMore is true, and persist the last cursor (or the timestamp of the last event you processed) between runs:

cursor = load_last_cursor()
loop:
  page = GET /audit-log?limit=200&cursor={cursor}
  for event in page.data:
    handle(event)
  if page.nextCursor:
    cursor = page.nextCursor
    save_cursor(cursor)
  if not page.hasMore:
    sleep(poll_interval)

2. Diff timestamps on resources

Most resources carry a timestamp you can compare against a previous poll:

ResourceField
Applications (GET /applications/{id})updatedAt
Data objects (GET /data, GET /data/{id})updatedAt
Contracts (GET /contracts)updatedAt
Teams (GET /teams, GET /teams/{id})updatedAt
Announcements (GET /announcements)updatedAt
Discovered applications (GET /discovered-applications)lastUpdate
Servers (GET /servers, GET /servers/{id})lastCheckTime

This is coarser than the audit log — it tells you that something changed, not what or who — but it's simple and works even for resources without a dedicated audit category.

Efficient polling

  • Respect the page-size cap (200) and request the largest page your interval can comfortably process.
  • Pick a sane interval. A 1–5 minute interval is a reasonable default for most integrations.
  • Always persist your cursor/timestamp before acting on the page, so a crash or restart resumes rather than reprocesses or skips.
  • Prefer the audit log when you need "what changed" — it's actor-aware, action-aware, and resource-aware.