Developers

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 from GET /v1/entry-types — they are per-organisation and versioned.
  • A work day has a status: draftconfirmedapprovedexported.

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

Node.js — import a week
// 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 statusEntry writeWay 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 rangePOST /v1/team/members/{membershipId}/work-days/confirm-range and its mirror …/reopen-range. Maximum window: 31 days; a longer span is refused with 422 range_too_long. Days that cannot act (already confirmed, an empty day off) are skipped, never failed, and counted in skippedCount.
  • By explicit idsPOST /v1/work-days/approve-batch and POST /v1/work-days/return-batch, at most 500 ids per request. A day that can no longer act comes back in skipped with a machine-readable code instead of failing the whole batch.
curl
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 fromto 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
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.

Node.js — pull a month, then only the changes
// 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.

Integration-event log
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 …/entries replaces the day in full, so sending the same body twice leaves exactly the same rows — a retried import cannot duplicate hours, and needs no Idempotency-Key. Pass expectedUpdatedAt (from the day you read) when you want the write to fail with 409 day_changed instead 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 409 naming 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 reporting actedCount: 0 because 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. occurredAt may back-date a queued offline punch by up to 60 days.

Scopes at a glance

What you are doingKey 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