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:
| Param | Type | Default | Behavior |
|---|---|---|---|
limit | integer | 50 | Page size. Clamped to the range [1, 200] — a value below 1 becomes 1, a value above 200 becomes 200. |
offset | integer | 0 | Number 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
}nextCursoris 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. hasMoretells you whether to continue;nextCursoris only meaningful whilehasMoreistrue(it is nullable and absent on the last page).totalis still the overall count matching your filters, but you drive the loop offhasMore/nextCursor, not off comparing an offset tototal.
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
}| Field | Type | Description |
|---|---|---|
code | string | Stable, machine-readable error identifier (e.g. not_found, insufficient_permission). Safe to branch on in code. |
message | string | Human-readable explanation. Meant for logs and debugging, not for switch statements. |
status | integer | The HTTP status code, mirrored in the body. |
Status codes
| Status | Meaning | When it happens |
|---|---|---|
400 Bad Request | Malformed input | A 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 Unauthorized | Missing or invalid credentials | No Authorization/X-Api-Key header, or the key is unrecognized, revoked, or expired. Special case: see below. |
403 Forbidden | Not allowed | The 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 Found | Resource doesn't exist in your workspace | The 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 Conflict | Request conflicts with current state | Reserved by the error contract; no endpoint currently produces it. |
422 Unprocessable Entity | Well-formed but semantically invalid | The request parses fine but violates a business rule — e.g. trying to change name/os/manufacturer on an agent-reported (automatic) server. |
5xx | Unexpected server error | Not 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 parsecode/message/statusout 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.
code | Typical status | Notes |
|---|---|---|
bad_request | 400 | Generic malformed-input default. |
invalid_uuid | 400 | A path or query value that should be a UUID couldn't be parsed. |
invalid_datetime | 400 | A date-time value isn't valid ISO-8601. |
unauthorized | 401 | Defined by the contract, but not reachable in practice — real authentication failures carry no JSON body. |
forbidden | 403 | The key isn't bound to any workspace (or another generic authorization failure). |
insufficient_permission | 403 | The key is bound to a workspace but lacks the required scope. |
not_found | 404 | The resource doesn't exist in the key's workspace. |
conflict | 409 | Reserved — not currently emitted by any endpoint. |
unprocessable_entity | 422 | The 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:
| Resource | Field |
|---|---|
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.