Guide
Pagination
A list endpoint that takes a cursor parameter — 52 of them —
uses cursor (keyset) pagination. There are no page numbers or offsets — results stay stable
even while rows are inserted between requests. A list without a cursor
parameter returns its whole, bounded set in one response.
The envelope
{
"items": [
{ "id": "…", "…": "…" }
],
"pageInfo": {
"nextCursor": "eyJrIjoiMjAyNi0wOC0wNCIsImlkIjoi…",
"hasMore": true
}
} | Field | Meaning |
|---|---|
items | The page of results. |
pageInfo.nextCursor | Opaque cursor for the next page, or null when the list is done. |
pageInfo.hasMore | True when at least one more page exists. |
Request parameters
| Parameter | Type | Notes |
|---|---|---|
cursor | string | Omit on the first request; echo back the previous response's
pageInfo.nextCursor afterwards. |
limit | integer | Page size. Defaults to 50, capped at 100. |
Lists with another envelope or page size
| Endpoint | Rows in | pageInfo | Default | Maximum |
|---|---|---|---|---|
GET /v1/api-keys | keys | yes | 50 | 100 |
GET /v1/me/service/assets/{id}/maintenance-log | entries | yes | 25 | 100 |
GET /v1/me/service/catalog | items | yes | — | 500 |
GET /v1/oauth-grants | grants | yes | 50 | 100 |
GET /v1/payroll-locks | locks | yes | 50 | 100 |
GET /v1/planning-units | units | yes | 50 | 100 |
GET /v1/projects/{id}/tasks | statuses, columns, dependencies, milestones, fields | no | 50 | 100 |
GET /v1/service/assets/{id}/maintenance-log | entries | yes | 25 | 100 |
# First page (limit defaults to 50, max 100)
curl "https://api.kelomo.fi/v1/integration-events?limit=100" \
-H "Authorization: Bearer kelomo_YOUR_KEY"
# Next page: echo pageInfo.nextCursor back
curl "https://api.kelomo.fi/v1/integration-events?limit=100&cursor=eyJrIjoi…" \
-H "Authorization: Bearer kelomo_YOUR_KEY" Walking a whole list
async function* listAll(path, apiKey) {
let cursor;
do {
const url = new URL(`https://api.kelomo.fi${path}`);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const page = await response.json();
yield* page.items;
cursor = page.pageInfo.nextCursor;
} while (cursor);
}
for await (const event of listAll('/v1/integration-events', process.env.KELOMO_API_KEY)) {
console.log(event.id, event.event);
} Incremental sync
If you keep a copy of Kelomo data — a warehouse, a BI extract, an ERP mirror — you should not
re-read the whole list every night. The lists below accept
updatedSince, an RFC 3339 instant, and return only the rows that changed at or
after it; the event log takes the same instant as since. Cost then tracks how
much moved, not how big the tenant is.
| Endpoint | Filters on |
|---|---|
GET /v1/customers | the customer record's updatedAt |
GET /v1/crm/leads | the lead's updatedAt |
GET /v1/crm/deals | the deal's updatedAt |
GET /v1/crm/activities | the activity's updatedAt. The touch log is the list that grows
without bound — every call, email, meeting and note ever logged. state
still narrows the set, but a sweep pages by the change column in both lenses (see
the ordering rule below) |
GET /v1/quotes | the quote's updatedAt |
GET /v1/invoicing/invoices | the invoice's updatedAt |
GET /v1/memberships | the membership row's updatedAt |
GET /v1/work-days | the stored work-day row's updatedAt. The from–to
window stays required (max 366 days): the filter narrows a window, it does not
replace one |
GET /v1/projects | the project record's updatedAt (the register behind the
project.* events) |
GET /v1/projects/{id}/tasks/page | the task's updatedAt (spans archived tasks) |
GET /v1/service/orders | the work order's updatedAt |
GET /v1/service/visits | the visit's updatedAt. GET /v1/me/service/visits, the
technician's own list, takes the same filter |
GET /v1/service/reports | the work report's updatedAt |
GET /v1/service/sites | the site's updatedAt |
GET /v1/service/assets | the equipment record's updatedAt |
GET /v1/service/agreements | the maintenance agreement's updatedAt |
GET /v1/service/occurrences | the obligation's updatedAt |
GET /v1/inventory/stock | the stock row's updatedAt (one row per part and location) |
GET /v1/me/trips, GET /v1/team/trips | the travel claim's updatedAt |
GET /v1/me/expenses, GET /v1/team/expenses | the expense claim's updatedAt |
GET /v1/open-shifts | the open shift's updatedAt. A claim is its own row and arrives as an
open_shift.* event |
GET /v1/shift-swaps | the swap request's updatedAt |
GET /v1/dimensions/{id}/values | the tracking target's own updatedAt. Renaming a parent does not move a
child's stamp |
GET /v1/export-batches | the payroll batch record's updatedAt (creation, cancellation). Delivery
outcomes arrive as export_batch.* events |
GET /v1/billing-export/batches | the billing batch's updatedAt |
GET /v1/integration-events | since, not updatedSince: the event's record time
(createdAt). The log is append-only and keeps 90 days |
What the filter promises
- It is inclusive. The comparison is
updatedAt >= updatedSince, so a row stamped exactly at your watermark comes back again. That is deliberate: an exclusive boundary would silently drop every sibling written in the same millisecond as the watermark row, and nothing in the response would tell you it happened. Re-delivery you can see and de-duplicate; loss you cannot. - De-duplicate by
id. Upsert, never append. Besides the boundary above, a row edited while you are paging is re-delivered on a later page. - It changes the ordering. While
updatedSinceis present the list is ordered by its change column ascending (updatedAt, thenid), overriding the endpoint's normal sort, anysort/directionparameter, and the order a lens of its own would impose —GET /v1/crm/activitiespages bycreatedAtunderstate=openand bydoneAtunderstate=done, and by the change column under both while a sweep is running. This is what makes the sweep safe: ordered by anything else, a row edited mid-sweep could move to a position your cursor has already passed and be missed entirely. - It composes with the cursor, it does not replace it.
updatedSincepicks the set;cursorandlimitwalk it. Send the sameupdatedSinceon every page of one sweep — it is part of the query the cursor was minted against. - The next watermark is the largest
updatedAtyou received. Every row a sweepable list returns carriesupdatedAtfor exactly this reason. Keep the maximum across the whole sweep and send it as the next run'supdatedSince: it is the server's own stamp, so a client clock that runs fast cannot skip a row written while the sweep was in flight, and the inclusive boundary means the worst case is re-reading the last row rather than missing the next one. On the first run, omitupdatedSinceentirely for a full backfill. - The sweep ends. Follow
pageInfo.nextCursoruntilhasMoreis false — that is the whole termination condition, and a sweep that keeps returning the same rows is a bug on our side, not something to guard against with a page cap. Report it.
// A resumable sync: updatedSince picks the set, the cursor walks it.
async function sync(path, apiKey, since, upsert) {
// The next run's watermark is the largest updatedAt this sweep received —
// a server-side stamp, so it cannot drift the way your own clock can.
let watermark = since ?? '';
let cursor;
do {
const url = new URL(`https://api.kelomo.fi${path}`);
url.searchParams.set('limit', '100');
if (since) url.searchParams.set('updatedSince', since);
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const page = await response.json();
// Upsert by id — the inclusive boundary and mid-sweep edits both mean the
// same row can arrive twice. Never append blindly.
for (const row of page.items) {
upsert(row.id, row);
if (row.updatedAt > watermark) watermark = row.updatedAt;
}
cursor = page.pageInfo.nextCursor;
} while (cursor);
return watermark; // store this; pass it as `since` next time
}
// First run: omit `since` for a full backfill. Every run after that resumes.
let since = await loadWatermark();
since = await sync('/v1/customers', process.env.KELOMO_API_KEY, since, upsertCustomer);
await saveWatermark(since); Deletions are not in the list
A deleted row answers no query, so updatedSince can never report one. Your mirror
would keep it forever. Pair the sweep with the tenant's integration-event log, which carries
the *.deleted facts (the same truth webhooks fan out) and pages exactly like every
other list.
# Deletions never appear in a list — a row that is gone answers no query.
# Read them from the same event log the webhooks fan out, paged the same way.
curl "https://api.kelomo.fi/v1/integration-events?limit=100" \
-H "Authorization: Bearer kelomo_YOUR_KEY" See Webhooks for the event catalogue and for receiving the same facts as a push instead of a poll.
Why not every list has it
updatedSince is only offered where the underlying row has a real change timestamp
and the endpoint is a flat, resumable page. It is deliberately absent from
GET /v1/invoicing/payments (a payment row is append-only and has no
updatedAt; what changes is its allocations) and from
GET /v1/projects/roster (a computed period report, not a record list — its money
and hour figures move without the project record being touched). Mirror those through the
event log instead of a filter that would quietly under-report. The project register
is a different endpoint and does have the filter: sweep GET /v1/projects, not the
roster.
Rules of the road
- The cursor is opaque. It is a base64url-encoded keyset anchor — never parse, build or modify one; only echo back what the previous response returned.
- A cursor belongs to the query that produced it. Changing filters mid-walk invalidates the cursor — start over without it.
-
Some date-ranged read endpoints (for example report generation) take an explicit
from/towindow with a documented maximum span instead of a cursor; the reference marks these per operation.