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
{
"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. |
# 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. 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/memberships | the membership row's updatedAt |
GET /v1/invoicing/invoices | the invoice's updatedAt |
GET /v1/quotes | the quote's updatedAt |
GET /v1/crm/deals | the deal's updatedAt |
GET /v1/crm/leads | the 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
updatedSinceis present the list is ordered by its change column ascending (updatedAt, thenid), overriding the endpoint's normal sort and anysort/directionparameter. 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. - 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
updatedAteach row carries is there so you can verify and recover; the run's own start time is the safer bookmark.
// 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.
# 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.