Guide
Time data
Work time is Kelomo's statutory bookkeeping: an employer must be able to show what each person worked, and who asserted it. The API therefore has two write surfaces — the supervisor-side one that imports hours for other people, and the self-service one where a member records their own — and a confirmation boundary that neither of them may write across.
The objects
- A work day is one member on one calendar date. Its frame (start, end, break) is derived from its entries, never sent directly.
-
A work entry is a row inside that day: an entry type plus either a time
span (
startMinutes/endMinutes, minutes from midnight) or a duration, with optional project / cost-centre allocations. Read the available types fromGET /v1/entry-types— they are per-organisation and versioned. -
A work day has a status:
draft→confirmed→approved→exported.
Importing time for a workforce
The key route is PUT /v1/team/members/{membershipId}/work-days/{date}/entries.
It writes another person's day, so it needs a key scoped to
worktime.supervise, authority over that member, and the organisation's
“supervisor may act on behalf” setting switched on. The request replaces the day's entries
in full.
curl -X PUT \
https://api.kelomo.fi/v1/team/members/6f1c…/work-days/2026-08-03/entries \
-H "Authorization: Bearer kelomo_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"entries": [
{ "entryTypeId": "b2a1…", "startMinutes": 480, "endMinutes": 720 },
{ "entryTypeId": "b2a1…", "startMinutes": 750, "endMinutes": 990 }
]
}'
# 200 — the day as stored: derived frame 08:00–16:30, 30 min break, 450 min work.
Membership ids come from GET /v1/members. A day that has no rows yet is created
by the first write; sending an empty entries array clears it back to a blank
draft.
// One member, one week. The day write is a FULL REPLACEMENT, so this whole
// loop is safe to re-run after any failure — it converges on the same rows.
async function importWeek(membershipId, days, apiKey) {
for (const { date, entries } of days) {
const response = await fetch(
`https://api.kelomo.fi/v1/team/members/${membershipId}/work-days/${date}/entries`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ entries }),
},
);
if (response.status === 409) {
// day_confirmed_locked: the day is past the confirmation boundary.
// Reopen it (confirmed) or correct it (approved) before writing again.
const problem = await response.json();
throw new Error(`${date}: ${problem.code}`);
}
if (!response.ok) throw new Error(`${date}: HTTP ${response.status}`);
}
}
There are two neighbours of this route. PUT …/work-days/{date}/day-type marks
a whole day as one day type (a public holiday, a training day) instead of entries. And a
member's own client — a mobile app, a kiosk you build — records their hours through
PUT /v1/me/work-days/{date}/entries or the punch clock
(POST /v1/me/punch/clock-in, …/break-start,
…/break-end, …/set-allocation, …/clock-out) with the
member's own personal key. Clocking out materialises a draft day; confirmation
stays a separate, deliberate act.
A shared wall terminal is not this surface. Punch devices have their own realm: a device is paired, holds its own credential and posts through the device endpoint, so a lost tablet is revoked without touching anybody's user key.
Draft versus confirmed — the boundary that never bends
A draft day is working material: rewrite it as often as you like, the last
write wins, nothing is recorded but who touched it last. The moment a day is
confirmed it becomes an assertion about what a person actually worked, and
from there it is never edited in place. Every later change is a lifecycle action
that leaves an append-only event behind: POST …/work-days/{date}/reopen
takes a confirmed day back to draft, POST /v1/work-days/{id}/return (or
/return-batch) sends it back to the employee with a reason, and once a day is
approved or already exported to payroll the only route is
POST /v1/work-days/{id}/correct: it requires a reason, returns the day to
draft, reverses the balance movements the approval made with counter-entries rather than
deletions, and lets the day rise into the next payroll batch with a correction marker while
the batch already delivered keeps its original snapshot. Writing entries onto a day past the
boundary does not overwrite it — it fails with
409 day_confirmed_locked. That refusal is the feature: an approved record that
could be silently rewritten would be worthless as evidence.
| Day status | Entry write | Way back to editable |
|---|---|---|
draft | Allowed, full replacement | — |
confirmed | 409 day_confirmed_locked | reopen / return / return-batch |
approved | 409 day_confirmed_locked | unapprove (not yet exported) or correct |
exported | 409 day_confirmed_locked | correct — the delivered batch is never rewritten |
Two further refusals are worth handling explicitly: a day inside a locked payroll period
answers 409 period_locked (lift the lock first), and a day whose hours are
already on an invoice answers 409 day_invoiced (cancel the invoice batch
first).
Confirming a period, and the range caps
Confirmation is per day, but an importer rarely wants a call per day. Two shapes cover a period:
- By date range —
POST /v1/team/members/{membershipId}/work-days/confirm-rangeand its mirror…/reopen-range. Maximum window: 31 days; a longer span is refused with422 range_too_long. Days that cannot act (already confirmed, an empty day off) are skipped, never failed, and counted inskippedCount. - By explicit ids —
POST /v1/work-days/approve-batchandPOST /v1/work-days/return-batch, at most 500 ids per request. A day that can no longer act comes back inskippedwith a machine-readable code instead of failing the whole batch.
curl -X POST \
https://api.kelomo.fi/v1/team/members/6f1c…/work-days/confirm-range \
-H "Authorization: Bearer kelomo_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "2026-08-03", "to": "2026-08-07" }'
# 201
# { "actedCount": 5, "skippedCount": 0 }
Reads are bounded too: GET /v1/me/work-days and
GET /v1/team/members/{membershipId}/work-days accept a window of at most
62 days. Work-day ids for the id-keyed actions come from the day detail
(GET /v1/team/members/{membershipId}/work-days/{date} returns
workDayId once the day is confirmed), from
GET /v1/approvals, or from the lifecycle events below.
Reading time back
GET /v1/work-days is the organisation-wide register: every stored work day in an
inclusive from–to window (at most 366 days), cursor-paginated. It is
what a payroll run, an invoicing feed or a data warehouse reads — one request per page rather
than one per employee. The key needs worktime.supervise or
payroll.run; an admin or payroll key sees the whole organisation, a manager's key
sees their own reports.
curl -G https://api.kelomo.fi/v1/work-days \
-H "Authorization: Bearer kelomo_YOUR_KEY" \
-d from=2026-08-01 -d to=2026-08-31 -d limit=100
# 200
# { "items": [ { "id": "9c3d…", "membershipId": "6f1c…", "workDate": "2026-08-03",
# "status": "approved", "startMinutes": 480, "endMinutes": 990,
# "breakMinutes": 30, "workedMinutes": 450, "creditedMinutes": 0,
# "dayTypeId": null, "expectedTargetMinutes": 450,
# "exportBatchId": null, "updatedAt": "2026-08-06T06:12:44.081Z" } ],
# "pageInfo": { "nextCursor": "eyJ…", "hasMore": true } }
Add updatedSince and the list narrows to what changed and orders by
(updatedAt, id), so a nightly mirror resumes from its own watermark instead of
re-reading the month. Deletions never appear in a list — read those from
GET /v1/integration-events, below.
// A payroll pull, or a nightly mirror. ONE request per page — not one per
// employee — and after the first sweep, only what changed.
async function pullMonth(from, to, since, apiKey) {
const rows = [];
let cursor = null;
do {
const query = new URLSearchParams({ from, to, limit: '100' });
if (since) query.set('updatedSince', since); // same value on EVERY page
if (cursor) query.set('cursor', cursor);
const response = await fetch(
`https://api.kelomo.fi/v1/work-days?${query}`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const page = await response.json();
rows.push(...page.items); // upsert by id — re-delivery is expected
cursor = page.pageInfo.nextCursor;
} while (cursor);
// The next run's watermark: the largest updatedAt this sweep saw.
const watermark = rows.reduce((max, row) => (row.updatedAt > max ? row.updatedAt : max), since ?? '');
return { rows, watermark };
}
The register carries the stored day: the frame, the worked and credited
minutes, the locked target, the status and the export batch. It does not carry the calculated
day — applied credit, balance terms, the deviation against the model — because that depends on
a working-time model an external system does not hold. Read
GET /v1/team/members/{membershipId}/work-days/{date} when you need the
interpretation rather than the record.
Events: one per day, always
Every transition emits workday.confirmed, workday.returned,
workday.approved, workday.corrected or
workday.unapproved — readable from
webhooks or
GET /v1/integration-events. A range or batch action emits
one event per day, not one per request: five confirmed days are five
events. A mirror can therefore act on each day without re-reading the week.
GET /v1/integration-events?event=workday.confirmed
{
"items": [
{ "id": "…", "event": "workday.confirmed",
"payload": { "workDayId": "9c3d…", "membershipId": "6f1c…",
"workDate": "2026-08-05", "confirmedAt": "2026-08-06T06:12:44.081Z" } },
{ "id": "…", "event": "workday.confirmed",
"payload": { "workDayId": "7a1e…", "membershipId": "6f1c…",
"workDate": "2026-08-04", "confirmedAt": "2026-08-06T06:12:43.902Z" } }
],
"pageInfo": { "nextCursor": null }
} Payloads carry identifiers, the work date and a timestamp — never hours, names or notes. Re- fetch the day when you need its content.
Retrying safely
- The day write is naturally idempotent.
PUT …/entriesreplaces the day in full, so sending the same body twice leaves exactly the same rows — a retried import cannot duplicate hours, and needs noIdempotency-Key. PassexpectedUpdatedAt(from the day you read) when you want the write to fail with409 day_changedinstead of overwriting an edit somebody made meanwhile. - Lifecycle actions are guarded state transitions. Confirm, reopen, return,
approve, unapprove and correct each check the day's current status inside the transaction,
so a replay cannot apply twice — it answers a
409naming the state (day_already_confirmed,day_not_correctable, …). The header is therefore optional here; send one anyway on range and batch calls if you want a lost response replayed with its original counts instead of a second call reportingactedCount: 0because everything was already done. - Punches dedupe on their own id. Generate a
clientPunchId(UUID) once per tap and resend it on every retry: a punch that already landed returns the live session state instead of opening a second one, so a lost clock-in response can never double-punch.occurredAtmay back-date a queued offline punch by up to 60 days.
Scopes at a glance
| What you are doing | Key scope |
|---|---|
| Importing / confirming / approving other people's days | worktime.supervise |
| Recording or punching the key owner's own time | none — any personal key reaches /v1/me/* |
| Reading the integration-event log | integrations.manage |