Developers

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

A paginated response
{
  "items": [
    { "id": "…", "…": "…" }
  ],
  "pageInfo": {
    "nextCursor": "eyJrIjoiMjAyNi0wOC0wNCIsImlkIjoi…",
    "hasMore": true
  }
}
FieldMeaning
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

ParameterTypeNotes
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

EndpointRows inpageInfoDefaultMaximum
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
curl
# 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

Node.js
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.

EndpointFilters on
GET /v1/customersthe customer record's updatedAt
GET /v1/crm/leadsthe lead's updatedAt
GET /v1/crm/dealsthe 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/quotesthe quote's updatedAt
GET /v1/invoicing/invoicesthe invoice's updatedAt
GET /v1/membershipsthe 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/ordersthe 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/reportsthe work report's updatedAt
GET /v1/service/sitesthe site's updatedAt
GET /v1/service/assetsthe equipment record's updatedAt
GET /v1/service/agreementsthe maintenance agreement's updatedAt
GET /v1/service/occurrencesthe 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-swapsthe 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/batchesthe 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 updatedSince is present the list is ordered by its change column ascending (updatedAt, then id), overriding the endpoint's normal sort, any sort/direction parameter, and the order a lens of its own would impose — GET /v1/crm/activities pages by createdAt under state=open and by doneAt under state=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. updatedSince picks the set; cursor and limit walk it. Send the same updatedSince on every page of one sweep — it is part of the query the cursor was minted against.
  • The next watermark is the largest updatedAt you received. Every row a sweepable list returns carries updatedAt for exactly this reason. Keep the maximum across the whole sweep and send it as the next run's updatedSince: 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, omit updatedSince entirely for a full backfill.
  • The sweep ends. Follow pageInfo.nextCursor until hasMore is 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.
Node.js — resumable sync
// 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.

The companion read
# 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/to window with a documented maximum span instead of a cursor; the reference marks these per operation.