Developers

Guide

Provisioning people

Your HR system owns the roster. This is how it keeps Kelomo in step: create people, invite them, maintain their unit and employment window, and offboard them — all under the Members tag, all with an ordinary API key.

The lifecycle

OperationScopeWhat it does
POST /v1/memberships members.manage Create one person. Requires displayName + role; email is optional.
POST /v1/memberships/bulk members.manage Create up to 500 people in one request, with a per-row outcome.
POST /v1/memberships/{id}/invite members.manage Send or resend the activation link.
PATCH /v1/memberships/{id} members.manage Role, supervisor, worktime model, primary unit, employment window.
POST /v1/memberships/{id}/units org.admin Add a further planning unit so the person can be rostered there.
DELETE /v1/memberships/{id}/units/{unitId} org.admin Close a unit assignment (as of today; history is kept).
GET · PUT /v1/memberships/{id}/employment reports.team · hr.manage Employment terms. The PUT is a full replace — read first.
POST · DELETE /v1/memberships/{id}/termination hr.manage Record or cancel the leaving date and its coded reason.
POST /v1/memberships/{id}/deactivate members.manage Offboard: history kept, sign-in blocked, sessions ended.
POST /v1/memberships/{id}/reactivate members.manage Bring a member back (a rehire, or an offboarding done in error).

A members.manage key covers day-to-day provisioning on its own. Multi-unit rostering and the employment terms are deliberately admin/HR surfaces, so a leaked provisioning key cannot rewrite the organisation — mint a second, narrower key for those steps rather than widening the first.

Create a person

displayName and role are the only required fields. email is optional: leave it out for a device-only worker — a punch-clock user who never signs in — and Kelomo mints a synthetic, never-deliverable address and sends no invite. Give a phone so SMS can still reach them.

A new person with an address is created and invited in one call, and the one-time activation URL comes back in the response — you never need a second request to start onboarding.

curl
curl -X POST https://api.kelomo.fi/v1/memberships \
  -H "Authorization: Bearer kelomo_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0d4f6c2a-8b1e-4e6a-9c3d-2f5a7b9c1d3e" \
  -d '{
        "displayName": "Aino Virtanen",
        "email": "aino.virtanen@example.fi",
        "role": "employee"
      }'

# 201
# {
#   "id": "6f1c…",
#   "invitation": { "inviteUrl": "https://app.kelomo.fi/activate/…", "emailed": true,
#                   "expiresAt": "2026-09-15T09:00:00.000Z" },
#   "moduleGrants": { "granted": [], "seatLimited": [] }
# }

Retries never create a second person

POST /v1/memberships requires an Idempotency-Key header when called with an API key; without one it fails fast with 400 idempotency_key_required. Re-send the same key and the original 201 is replayed — same id, same invite URL — so a request that timed out halfway is safe to retry blindly.

Creating an address that is already on the roster is 409 already_member, whichever key you use. That refusal is correct, but it does not tell you the id of the person you may have just created — which is exactly why the header is mandatory here. Hold one key per logical hire, not per HTTP attempt.

The migration on-ramp

POST /v1/memberships/bulk takes 1–500 rows per request and always answers 200: each row is created independently, so one bad row never fails the batch. Every row comes back as invited, created, skipped (already a member) or error with a machine-readable code.

Rows may name their supervisor by e-mail and their unit, worktime model and employer by name or registry code — resolved server-side against both the existing roster and the other rows of the same request, so the order of your export file does not matter and you do not have to look ids up first. Per-row locale means a mixed-language crew gets invites each in their own language.

No Idempotency-Key is needed: re-posting a corrected file skips the rows that already landed and retries only the ones that failed.

curl
curl -X POST https://api.kelomo.fi/v1/memberships/bulk \
  -H "Authorization: Bearer kelomo_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "rows": [
          { "displayName": "Aino Virtanen", "email": "aino@example.fi", "role": "employee",
            "unitName": "Kotihoito pohjoinen", "managerEmail": "esihenkilo@example.fi",
            "employmentStartDate": "2026-09-01", "locale": "fi" },
          { "displayName": "Karl Nyström", "email": "karl@example.fi", "role": "employee",
            "unitName": "Kotihoito pohjoinen", "locale": "sv" }
        ]
      }'

# 200 — always. One row per input row:
# { "results": [
#     { "email": "aino@example.fi", "status": "invited", "membershipId": "6f1c…", "code": null },
#     { "email": "karl@example.fi", "status": "error",   "membershipId": null,   "code": "manager_not_found" }
# ] }

Maintain and terminate

The employment windowemploymentStartDate and employmentEndDate — lives on PATCH /v1/memberships/{id} alongside role, supervisor, worktime model and primary unit. It is a partial update: only the fields you send change, and null clears one. A unit change is effective-dated from today and never rewrites which unit the person belonged to last month.

Employment terms (job title, employment and pay type, payroll number, insurers) are a separate record and the PUT is a full replace: read GET …/employment, change what you mean to change, send the whole object back. Fixed-term employment requires written grounds (422 fixed_term_requires_grounds).

curl
# Resend the activation link (a new single-use URL; the old one stops working)
curl -X POST https://api.kelomo.fi/v1/memberships/$ID/invite \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "locale": "fi" }'

# Update: role, supervisor, unit and the EMPLOYMENT WINDOW. Partial — only the
# fields you send change.
curl -X PATCH https://api.kelomo.fi/v1/memberships/$ID \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "planningUnitId": "8a2e…", "employmentStartDate": "2026-09-01",
        "employmentEndDate": null }'

# Roster a person in a SECOND unit (org.admin scope)
curl -X POST https://api.kelomo.fi/v1/memberships/$ID/units \
  -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "planningUnitId": "b91d…" }'

# Employment terms — full REPLACE, so GET first (hr.manage scope, HR module)
curl https://api.kelomo.fi/v1/memberships/$ID/employment -H "Authorization: Bearer $ADMIN_KEY"
curl -X PUT https://api.kelomo.fi/v1/memberships/$ID/employment \
  -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "jobTitle": "Lähihoitaja", "employmentType": "permanent", "payType": "monthly" }'

# Record the leaving date (hr.manage scope, HR module)
curl -X POST https://api.kelomo.fi/v1/memberships/$ID/termination \
  -H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
  -d '{ "endDate": "2026-12-31", "reasonCode": "resignation", "startOffboarding": true }'

Offboarding tells you what it broke

POST …/deactivate keeps all bookkeeping history, blocks sign-in and ends the member's live sessions. It is never refused because of API keys — offboarding must always be possible. Instead the response lists the service keys that person owned in orphanedServiceKeys: those keys execute with their owner's authority and stop authenticating (401 api_key_owner_inactive) the moment the owner does.

curl
curl -X POST https://api.kelomo.fi/v1/memberships/$ID/deactivate \
  -H "Authorization: Bearer $KEY"

# 200 — and it is never refused because of an API key
# {
#   "ok": true,
#   "orphanedServiceKeys": [
#     { "id": "c3b7…", "name": "Nightly report export", "lastUsedAt": "2026-09-12T02:00:04.000Z" }
#   ]
# }

Reassign them, or the integrations behind them go dark. Personal keys are deliberately not listed — dying with their owner is what a personal key is for. Deactivation is idempotent, and POST …/reactivate reverses it; service keys start working again by themselves, because the owner's status is checked per request.

Keeping a two-way sync honest

Every write above emits an event you can subscribe to or poll from GET /v1/integration-events:

EventFired by
member.createdcreate, and each created bulk row
member.invitedevery activation link that goes out
member.updated update, unit add/remove, employment terms, reactivate, termination cancelled
employment.terminateda recorded leaving date
member.deactivatedoffboarding

Payloads carry identifiers and timestamps only — never names, addresses, roles, reasons or notes. Re-fetch the record when you get one. The updatedAt on member.updated is the row's own sync anchor, so it doubles as the updatedSince cursor for an incremental sweep of GET /v1/memberships.

What is not available over the API

Three groups of writes are deliberately absent, and will stay that way:

  • Sensitive personal fields — national identity number, bank account, home address, next of kin. They are neither readable nor writable through the public API. An integration that needs to move them is moving them between two HR systems, and Kelomo is not the right hop; the fields are edited by a named person in the app, where the access is audited to that person.
  • Versioned payroll configuration — dated employment versions, flexible-work periods, part-time percentages, capability grants. These carry validity ranges that past payroll runs were calculated from, so a careless write does not fail loudly: it silently changes what last spring's weeks are worth. Correcting the terms in effect today is supported (PUT …/employment); rewriting history is not.
  • GDPR erasure — anonymisation and password resets are administrative acts with a named human behind them, not something an integration should be able to trigger. Working-time records also carry a statutory retention period, so erasure is a decision, not a delete.

Skills, attributes, employee documents, exit interviews and written statements of terms are likewise app surfaces for now. If your integration genuinely needs one of them, tell us what the flow looks like — the public surface grows deliberately, and the reference is the current truth.