Developers

Guide

Pagination

Every public list endpoint uses cursor (keyset) pagination with one shared envelope. There are no page numbers or offsets — results stay stable even while rows are inserted between requests.

The envelope

Every 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.
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. Cost then tracks how much moved, not how big the tenant is.

EndpointFilters on
GET /v1/customersthe customer record's updatedAt
GET /v1/membershipsthe membership row's updatedAt
GET /v1/invoicing/invoicesthe invoice's updatedAt
GET /v1/quotesthe quote's updatedAt
GET /v1/crm/dealsthe deal's updatedAt
GET /v1/crm/leadsthe lead's updatedAt
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)

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 and any sort/direction parameter. 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.
  • Take the next watermark from the clock before the sweep, not from the last row. Rows written while the sweep is in flight then land in the next run. The updatedAt each row carries is there so you can verify and recover; the run's own start time is the safer bookmark.
Node.js — resumable sync
// A resumable sync: updatedSince picks the set, the cursor walks it.
async function sync(path, apiKey, since, upsert) {
  // Take the watermark for the NEXT run before the sweep starts, not after.
  // Anything written while this run is in flight then falls into the next one.
  const startedAt = new Date().toISOString();

  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);
    cursor = page.pageInfo.nextCursor;
  } while (cursor);

  return startedAt; // 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.