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
| Operation | Scope | What 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 -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 -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 window — employmentStartDate 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).
# 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 -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:
| Event | Fired by |
|---|---|
member.created | create, and each created bulk row |
member.invited | every activation link that goes out |
member.updated | update, unit add/remove, employment terms, reactivate, termination cancelled |
employment.terminated | a recorded leaving date |
member.deactivated | offboarding |
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.