Looma API
Introduction
The Looma API reads and stages changes to a company's HR records: people, organisation, time off, time, payroll, recruiting, expenses, documents and objectives. Every request is scoped to the company that issued the credential.
| Base URL | https://api.looma.hr/api/v1 |
| Format | JSON in and out, UTF-8 |
| Contract | OpenAPI 3.1 document, also at https://api.looma.hr/api/v1/openapi.json |
| Version | 1.0.0 |
- A company administrator creates an API token in Settings → Configuration → Integrations → API tokens, with only the scopes the integration needs.
- Keep the token in a secret store or an environment variable, never in source control.
- Send it as a bearer token. The company comes from the token, so there is no company parameter.
A field the company's policy hides from integrations is blank and named in hiddenFields.
GET /api/v1/people
curl "https://api.looma.hr/api/v1/people?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": true,
"items": [
{
"countryCode": "SE",
"department": "People",
"employeeNumber": "E-1001",
"firstName": "Alex",
"id": "p-1001",
"jobTitle": "People Partner",
"lastName": "Nguyen",
"legalEntity": "Northwind Sweden AB",
"location": "Stockholm",
"revision": 1,
"startDate": "2024-03-01",
"status": "active",
"workEmail": "alex.nguyen@example.test",
"workerType": "employee"
}
],
"nextCursor": "RS0xMDAxH3AtMTAwMQ"
}Writes
Write endpoints stage a hire, an employment change, a reporting-line change or a pay change. Each write runs the same checks and the company's configured approval flow as the equivalent form in Looma, and answers 202 Accepted with the staged transaction. The change applies when that flow completes.
Send an Idempotency-Key on every write so a retry cannot create a second change.
POST /api/v1/people/{person}/manager
curl -X POST "https://api.looma.hr/api/v1/people/p-1001/manager" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"managerId": "p-1042",
"effectiveFrom": "2026-03-01",
"expectedRevision": 0
}'
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/manager", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
"managerId": "p-1042",
"effectiveFrom": "2026-03-01",
"expectedRevision": 0
}),
});
console.log(response.status, await response.json());
import os, requests, uuid
response = requests.post(
"https://api.looma.hr/api/v1/people/p-1001/manager",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}", "Idempotency-Key": str(uuid.uuid4())},
json={
"managerId": "p-1042",
"effectiveFrom": "2026-03-01",
"expectedRevision": 0
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"managerId": "p-1042",
"effectiveFrom": "2026-03-01",
"expectedRevision": 0
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/people/p-1001/manager", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", rand.Text())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
202 response
{
"personId": "p-1001",
"status": "PENDING_APPROVAL",
"transactionId": 4211
}Authentication
Every request except the public contract documents carries one bearer credential. Use an API token for a system your company runs, or REST OAuth for an application that other companies install.
Request
GET /api/v1/people HTTP/1.1
Host: api.looma.hr
Authorization: Bearer <token>API tokens
A company administrator creates tokens in Settings → Configuration → Integrations → API tokens. Creating or rotating a token needs a recent sign-in.
- The secret starts with
looma_pat_and is shown once. Looma stores only its SHA-256 hash. - A token carries a non-empty set of scopes and an optional expiry date. It is valid through the end of that day (UTC).
- Rotate replaces the secret in place: the token keeps its id, name, scopes and expiry. Choose an overlap of up to one hour during which the old secret still works, or none.
- Revoke takes effect immediately. A revoked, expired or unknown token is the same
401. - A token works only while the account that created it is an active member of the company. Write requests re-read that account's role every time, so a demotion applies to the next call.
401 response
{ "message": "A valid API token is required" }Company and field visibility
The company comes from the token alone.
Employee fields pass the company's field-visibility policy for the Integrations role. A denied field is blank and its key is listed in hiddenFields. Policy changes apply to the next request.
Hidden field
{
"id": "p-1001",
"firstName": "Alex",
"workEmail": "",
"hiddenFields": ["workEmail"]
}Scopes
A read scope never implies a write scope, and a write-only token is valid.
| Scope | Grants |
|---|---|
people:read | Read employee directory records and a single employee record. |
org:read | Read legal entities, workforce catalogues and current reporting lines. |
timeoff:read | Read approved time-off requests. |
timecards:read | Read approved timecards and their totals. |
people:write | Create a person and submit effective-dated job, assignment and employment changes. Every change runs the company's configured approval routing. |
org:write | Submit effective-dated reporting-line changes. Every change runs the company's configured manager-change approval routing. |
compensation:write | Submit effective-dated pay changes for a person. HR and administrator tokens only. Every change runs the company's configured pay approval routing. |
webhooks:write | Replay webhook deliveries from the delivery log. Administrator tokens only. |
assistant:read | Use the assistant policy Q&A surface at /assistant/qa. The assistant gathers the token owner's in-audience handbook policies, help articles, readable development plans and assigned learning courses as cited sources; every call is rate limited and audited, and the company must have the assistant enabled. No HR data is sent to an unenabled provider. |
analytics:read | Read tenant headcount and workforce trends. |
benefits:read | Read benefit plans and enrolments. Enrolment details are limited to HR and administrators, or an employee's own record. |
automations.events:write | Submit typed business events to explicitly registered sources. |
compensation:read | Read a person's effective-dated pay history and current pay. HR and administrator tokens only. Amounts honour the integration field-visibility policy. |
documents:read | Read employee document metadata. Employee fields pass the integration field-visibility policy. |
expenses:receive | Deliver receipt attachments to an employee's enabled routing token as private proposals. Never submits claims. |
expenses:read | Read expense claims. The claimant's name passes the field-visibility resolver under the integration viewer role. |
expenses:write | Submit a new expense claim for the token's linked employee through the company's configured EXPENSE_CLAIM approval flow. |
lifecycle:read | Read a person's hire, employment change, leave and termination history. |
objectives:read | Read current-cycle objectives within the token owner's visibility. |
payroll:read | Read payroll runs, a run's payslip metadata and the approved pay inputs that feed a run. Compensation fields pass the field-visibility resolver under the integration viewer role. |
performance:read | Read performance review cycles and a person's review assignments. Company tokens see all cycles and reviews; a manager sees their direct reports' reviews; an employee sees their own. Ratings only appear once released. |
recruiting:read | Read jobs, candidates and applications. Candidate personal fields pass the integration field-visibility policy. |
mcp:read | Call the read-only MCP server at /mcp from an AI assistant. Every tool runs with this token owner's own permissions and field visibility, each call is rate limited and audited, and the company must have the MCP server enabled. No write tools are reachable without a data scope. |
performance:write | Draft or submit a performance-review answer set through the MCP server, always as the token owner and under the same in-product role, employment, effective-date, revision and approval rules that the review screen applies. The performance:read scope is unchanged and separate; this scope only buys the write tools, which the product refuses exactly where it refuses the same save in-app. |
oneonones:write | Save the token owner's private 1:1 notes through the one-on-one module. |
feedback:write | Give feedback through the MCP server as the token owner under the feedback module's own recipient, visibility and company-setting rules. Feedback text is excluded from MCP call audit details. |
timeoff:write | Submit the token owner's own time-off request through the leave module's validation and approval rules. Request notes are excluded from MCP call audit details. |
REST OAuth
Applications that connect to several companies use the authorization-code flow with S256 PKCE instead of a copied token. There is no client secret.
A company administrator registers the application in Settings → Integrations → REST Applications: its name, publisher, exact HTTPS callback URI and the read scopes it may request. Registration returns a public client ID for that company.
Authorize
Generate a random PKCE verifier (43–128 characters), its base64url SHA-256 challenge and a random state bound to the user's browser session. Send the administrator to Looma.
The administrator reviews the application, callback and scopes, then chooses Allow Access. The browser returns to the callback with code, state and tenant_id. Check state before you continue. The code expires after five minutes and works once.
Authorization URL
https://app.looma.hr/workspace/integrations
?client_id=<client-id>
&redirect_uri=https%3A%2F%2Fapp.example.com%2Flooma%2Fcallback
&scope=people%3Aread%20org%3Aread
&resource=urn%3Alooma%3Aapi%3Av1
&code_challenge=<base64url-sha256-of-verifier>
&code_challenge_method=S256
&state=<state>Callback
https://app.example.com/looma/callback?code=<code>&state=<state>&tenant_id=<tenant-id>Exchange the code
Post the code and verifier to the token endpoint. Use access_token as the bearer token on the /api/v1 read endpoints.
POST /oauth/rest/token
curl -X POST "https://api.looma.hr/oauth/rest/token" \
-d grant_type=authorization_code \
-d tenant_id="$TENANT_ID" \
-d client_id="$CLIENT_ID" \
-d redirect_uri="https://app.example.com/looma/callback" \
-d resource="urn:looma:api:v1" \
-d code="$CODE" \
-d code_verifier="$CODE_VERIFIER"
const response = await fetch("https://api.looma.hr/oauth/rest/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
tenant_id: tenantId,
client_id: clientId,
redirect_uri: "https://app.example.com/looma/callback",
resource: "urn:looma:api:v1",
code,
code_verifier: codeVerifier,
}),
});
const tokens = await response.json();
import requests
response = requests.post(
"https://api.looma.hr/oauth/rest/token",
data={
"grant_type": "authorization_code",
"tenant_id": tenant_id,
"client_id": client_id,
"redirect_uri": "https://app.example.com/looma/callback",
"resource": "urn:looma:api:v1",
"code": code,
"code_verifier": code_verifier,
},
timeout=30,
)
tokens = response.json()
form := url.Values{
"grant_type": {"authorization_code"},
"tenant_id": {tenantID},
"client_id": {clientID},
"redirect_uri": {"https://app.example.com/looma/callback"},
"resource": {"urn:looma:api:v1"},
"code": {code},
"code_verifier": {codeVerifier},
}
res, err := http.PostForm("https://api.looma.hr/oauth/rest/token", form)
200 response
{
"access_token": "…",
"refresh_token": "…",
"token_type": "Bearer",
"expires_in": 600,
"scope": "org:read people:read"
}Refresh and revoke
Access tokens last ten minutes; refresh tokens last 30 days. Refresh with grant_type=refresh_token, tenant_id, client_id, resource and refresh_token. Each refresh consumes the old refresh token and returns a new pair: store the new refresh token before you use it. A consumed refresh token cannot be retried; start a new consent instead.
- REST OAuth grants read scopes only. Write and MCP scopes are refused.
- The consenting administrator stays accountable. If their account, membership or administrator role ends, so does the installation's access.
- An administrator can Revoke Installation (ends access now) or Uninstall Application (ends every installation; reconnecting needs a new registration).
Refresh
curl -X POST "https://api.looma.hr/oauth/rest/token" \
-d grant_type=refresh_token \
-d tenant_id="$TENANT_ID" \
-d client_id="$CLIENT_ID" \
-d resource="urn:looma:api:v1" \
-d refresh_token="$REFRESH_TOKEN"Versioning
The URL carries the major version: /api/v1. Additive changes, such as a new endpoint or field, ship within v1 and never break an existing integration. A breaking change gets a new major version.
Every response names the contract release that produced it in X-API-Version. The release record is in the changelog and at GET /api/v1/changelog.
Response headers
HTTP/1.1 200 OK
Content-Type: application/json
X-API-Version: 1.0.0Rate limits
| Budget | Limit |
|---|---|
| Reads | 600 requests per rolling minute, per token |
| Writes | 60 requests per rolling minute, per token, in addition to the read budget |
Over the limit, the API answers 429 with Retry-After in seconds. Budgets are per token, not per company.
| Header | Description |
|---|---|
Retry-After | For a throttled (429) response, the number of seconds until the next rolling minute starts. Omitted otherwise. |
X-API-Version | The semver release of this API contract that produced this response, so an integration can name the era it is running against. |
X-RateLimit-Limit | The per-token request budget for this surface in one rolling minute: 600 for a read and 60 for a write. |
X-RateLimit-Remaining | The number of requests the token may still make in the current minute. A running request has already spent one unit, and a refused request reports 0. |
429 response
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 18
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-API-Version: 1.0.0
{ "message": "Rate limit exceeded. Retry after the current minute." }Idempotency
Send an Idempotency-Key header (1–200 characters) on every write.
- The same key with the same body returns the stored response with
"replayed": trueinstead of staging a second change. - The same key with a different body is a
409. - Keys are kept for 30 days. After that the same request stages a new change.
Request
POST /api/v1/people/p-1001/manager HTTP/1.1
Host: api.looma.hr
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: 6f1c2e9a-manager-p-1001-2026-03-01
{"effectiveFrom":"2026-03-01","managerId":"p-1042","expectedRevision":7}Replayed response
{
"personId": "p-1001",
"status": "PENDING_APPROVAL",
"transactionId": 4211,
"replayed": true
}Pagination
List endpoints answer a cursor page. Pass nextCursor back as cursor, unchanged, and stop when hasMore is false. A cursor is opaque: never build or parse one.
filter and sort apply to the page that was read, so a filtered page can be shorter than limit; nextCursor still continues the whole collection. An unknown field is a 400.
| Parameter | Type | Description |
|---|---|---|
cursor | string | Opaque keyset cursor from a previous response's nextCursor. |
limit | integer | Maximum number of items to return. Values above the maximum are clamped. |
page | integer | The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page. |
fields | string | Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400. |
include | string | Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400. |
filter | string | Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400. |
sort | string | Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400. |
Cursor page
{
"items": [],
"nextCursor": "eyJpZCI6InAtMTA1MCJ9",
"hasMore": true
}Next page
GET /api/v1/people?limit=50&cursor=eyJpZCI6InAtMTA1MCJ9 HTTP/1.1
Host: api.looma.hr
Authorization: Bearer <token>Incremental sync
To keep a copy of the directory, use GET /api/v1/people/changes instead of re-reading /api/v1/people.
- Start without a
checkpointand read untilhasMoreisfalse, storing each page and itscheckpointtogether. - Poll with the last
checkpointfor changes. Apply items in order, replacing each record byid;action: "remove"means delete it. - A checkpoint expires after seven days. An expired or invalid one is
410withresync_required: discard the copy and start again.
GET /api/v1/people/changes
curl "https://api.looma.hr/api/v1/people/changes?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people/changes?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people/changes?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people/changes?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
Errors
Every error body is {"message": "…"}. Messages never reveal whether a token, a scope or a company exists.
| Status | Meaning |
|---|---|
400 |
Invalid parameter or body. Unknown body fields are rejected. |
401 |
Missing, unknown, expired or revoked token |
403 |
The token lacks the scope, or its owner lacks the permission |
404 |
Not found, or not visible to this token |
409 |
Conflict, for example a stale revision or a reused idempotency key |
413 |
Request body too large |
410 |
Sync checkpoint expired |
429 |
Rate limit reached |
503 |
Temporarily unavailable. Retry with backoff. |
409 response
{ "message": "The request conflicts with the current state" }Webhooks
Looma posts one signed HTTPS request per event to each subscribed endpoint. A company administrator adds endpoints and chooses events in Settings → Configuration → Integrations → Webhooks.
Payloads carry identifiers only, unless the endpoint opts in to specific employee fields. Fields the company hides from integrations are left out, and pay is never included.
Delivery
POST /looma/webhooks HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
X-Looma-Event: employee.updated
X-Looma-Delivery: 90211
X-Looma-Timestamp: 1772442900
X-Looma-Signature: sha256=<hex>
{
"event": "employee.updated",
"tenantId": "t-northwind",
"occurredAt": "2026-03-02T09:15:00Z",
"data": {
"personId": "p-1001",
"transactionId": 4211,
"actionType": "EMPLOYMENT_CHANGE",
"status": "COMPLETED"
}
}Events
Lifecycle events fire only when a transaction completes, from the same database transaction that applies the change.
| Event | Sent when |
|---|---|
employee.created | A hire transaction completed and created an employee record. |
employee.updated | An approved change updated employment, reporting, pay or working time. |
employee.terminated | An approved end-of-employment transaction completed. |
leave.approved | A leave request was approved and booked. |
transaction.decided | Any transaction reached a final approved or declined decision. |
candidate.status | A candidate moved to a new pipeline stage. |
audit.event | Every recorded HR audit event, projected to identifiers and the audit browser's fields, near-real-time with retry and a bounded replay window. |
payroll.run.approved | A payroll run reached the approved state after HR sign-off. |
expense.submitted | A new expense claim was submitted and entered the company's configured approval flow. |
Verify the signature
| Header | Value |
|---|---|
X-Looma-Event |
The event name |
X-Looma-Delivery |
The delivery id. Deduplicate on it. |
X-Looma-Timestamp |
Unix seconds when the delivery was signed |
X-Looma-Signature |
sha256=<hex> of HMAC-SHA256(secret, timestamp + "." + rawBody) |
The signing secret starts with whsec_ and is shown once when you create or rotate the endpoint. Compute the HMAC over the raw request body, compare in constant time, and reject a timestamp more than five minutes old.
Verify
timestamp="$X_LOOMA_TIMESTAMP" # from the X-Looma-Timestamp header
expected=$(printf '%s.%s' "$timestamp" "$(cat body.json)" \
| openssl dgst -sha256 -hmac "$LOOMA_WEBHOOK_SECRET" -hex | sed 's/^.* //')
[ "sha256=$expected" = "$X_LOOMA_SIGNATURE" ] && echo valid
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, signatureHeader, timestamp, rawBody) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const received = String(signatureHeader).replace(/^sha256=/, "");
return received.length === expected.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
import hashlib, hmac, time
def verify(secret: str, signature_header: str, timestamp: str, raw_body: bytes) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header.removeprefix("sha256="))
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func Verify(secret, signatureHeader, timestamp string, body []byte) bool {
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sent)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(strings.TrimPrefix(signatureHeader, "sha256=")))
}
Delivery and retries
- Return a
2xxwithin ten seconds and process asynchronously. Delivery is at least once. - Failed deliveries retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then stop.
- Twenty consecutive failures disable the endpoint. Re-enabling it clears the count.
- Endpoints must be public HTTPS URLs. Private, loopback and reserved addresses are refused.
- Rotation returns a new secret once. Deliveries created before the rotation are still signed with the previous secret for 24 hours, so accept both during that window.
- A company administrator can replay a date range from the last 30 days with
POST /api/v1/webhooks/{webhook}/deliveries/replay.
POST /api/v1/webhooks/{webhook}/deliveries/replay
curl -X POST "https://api.looma.hr/api/v1/webhooks/wh-201/deliveries/replay" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"from": "2026-03-01",
"until": "2026-03-01"
}'
const response = await fetch("https://api.looma.hr/api/v1/webhooks/wh-201/deliveries/replay", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
"from": "2026-03-01",
"until": "2026-03-01"
}),
});
console.log(response.status, await response.json());
import os, requests, uuid
response = requests.post(
"https://api.looma.hr/api/v1/webhooks/wh-201/deliveries/replay",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}", "Idempotency-Key": str(uuid.uuid4())},
json={
"from": "2026-03-01",
"until": "2026-03-01"
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"from": "2026-03-01",
"until": "2026-03-01"
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/webhooks/wh-201/deliveries/replay", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", rand.Text())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
SCIM
Looma implements SCIM 2.0 so an identity provider can create, update and deactivate Looma sign-in accounts. A provisioned account is linked to an existing person in Looma; SCIM never creates a person or an employment.
- Open Settings → Configuration → Integrations → SCIM and copy the base URL.
- Create a SCIM token and copy its secret. It starts with
looma_scim_, is shown once and carries only thescim:provisionscope. SCIM tokens are separate from API tokens. - Optionally map identity-provider groups to the Looma roles
hr,manageroremployee. A group cannot grant company administrator access.
userName must be the person's work email in Looma; a user that matches nobody is refused with invalidValue. externalId is optional; if sent, it must equal that person's employee number.
Base URL
https://api.looma.hr/scim/v2Create user
POST /scim/v2/Users HTTP/1.1
Host: api.looma.hr
Authorization: Bearer <scim-token>
Content-Type: application/scim+json
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "alex.nguyen@example.com",
"externalId": "E1001",
"name": { "givenName": "Alex", "familyName": "Nguyen" },
"active": true
}Microsoft Entra ID
- In the Microsoft Entra admin center, open Enterprise applications → New application → Create your own application, and choose Integrate any other application you don't find in the gallery.
- Open Provisioning, set Provisioning Mode to Automatic, and enter the Looma SCIM base URL and token as Tenant URL and Secret Token. Choose Test Connection.
- Under Mappings → Provision Microsoft Entra ID Users, map the attribute that holds the work email (
userPrincipalNameormail) touserName. MapemployeeIdtoexternalIdonly if it holds the Looma employee number. - Assign the users or groups to provision, start provisioning, and check both the Entra provisioning logs and the Looma sync log.
Okta
- In the Okta Admin Console, open Applications → Create App Integration, create a SAML 2.0 or SWA integration, and under General → App Settings set Provisioning to SCIM.
- Under Provisioning → Integration, enter the Looma SCIM base URL as the SCIM connector base URL, set Unique identifier field for users to
userName, choose HTTP Header authentication and paste the SCIM token. Choose Test Connector Configuration. - Under Provisioning → To App, enable Create Users, Update User Attributes and Deactivate Users. Make sure the Okta username is the work email.
- Assign people or groups, push groups you map to roles, and check both the Okta system log and the Looma sync log.
Deactivation
Deactivating or deleting a user deactivates the Looma account and its company membership; reactivating restores them. The person record and its employment history stay in Looma. Each SCIM token has its own request budget, and rotation replaces the secret in place with no overlap.
Deactivate user
PATCH /scim/v2/Users/<id> HTTP/1.1
Host: api.looma.hr
Authorization: Bearer <scim-token>
Content-Type: application/scim+json
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{ "op": "replace", "path": "active", "value": false }]
}SFTP
Looma exchanges payroll and people files with your SFTP server in both directions. A connection holds one server and its credentials; any number of jobs run over it, each with its own direction, mapping profile and schedule. Administrators and HR manage SFTP by default; a custom role can grant the Integrations permission.
- Open Settings → Integrations → SFTP and add a connection: host, port, user, and a password or an OpenSSH private key.
- Enter the server's host key fingerprint, in the
SHA256:…formssh-keygen -lfprints or as hex. Looma checks it on every connection and refuses a mismatch. - Optionally add a PGP public key to encrypt outbound files, and a PGP private key to decrypt inbound ones.
- Choose Test. Credentials are stored sealed and never shown again; the server must have a public address.
Host key fingerprint
ssh-keyscan -t ed25519 sftp.example.com | ssh-keygen -lf -
256 SHA256:Xq3c1u8yN0bD7Yy6hJ2tK4mE9vW5pR0sL1aZ8fG3qT0 sftp.example.com (ED25519)Mapping profiles
A mapping profile in Settings → Integrations → Field mapping is the file contract: direction, entity (people, employment, compensation, time off or company-defined fields), format and the Looma value each column holds.
- Formats: CSV, XLSX, JSON or fixed width, with delimiter, encoding, date format, decimal separator, column widths and header row.
- Fixed columns cannot be renamed. Value maps need a fallback, so an unmapped value fails instead of disappearing.
- Templates combine fields, for example
{{first_name}} {{last_name}}. Country and legal-entity overrides change columns or values. - Every save keeps a version. Use the preview and a dry run before you schedule a job.
people-20260301.csv
employee_number,first_name,last_name,work_email,start_date
E1001,Alex,Nguyen,alex.nguyen@example.com,2024-04-02
E1042,Sam,Okafor,sam.okafor@example.com,2025-01-13Jobs and runs
Each job runs every 5 minutes to 24 hours (default 60 minutes), with Run now beside it. A failed run retries with backoff of up to two hours and notifies the company's administrators.
- Outbound: Looma renders the file, encrypts it if a PGP key is set, uploads it as
<name>.partand renames it into place. A file with no rows fails the run. A termination cutoff keeps years of historical leavers out of a first export. - Inbound: Looma picks files matching the job's pattern, for example
people-{date:yyyyMMdd}.csv. A file is identified by its SHA-256 digest, so a processed file is skipped even under a new name. A file with any row error is not applied and moves to the error directory with<name>.errors.csv. A valid file is applied through the same checks and approval flow as the app and the API. - Inbound files update existing people; new hires come from the hire flow or
POST /api/v1/people. - Limits: 32 MiB per file and 50 files per poll. Every run is logged with its status, trigger, rows, bytes, file names, duration and reason.
people-20260301.errors.csv
row,field,code,reason
3,work_email,invalid_email,The value is not an email addressSDK
The TypeScript SDK wraps the /api/v1 contract with generated operation types, bounded pagination and opt-in retries for reads and for writes that carry an idempotency key. Source: coder/looma-sdk.
| Package | @looma/integration-sdk |
| Runtime | Node 22 or later |
| Contract | API 1.0.0 |
Any HTTP client works with the API directly, and the OpenAPI document generates clients in other languages.
TypeScript
import { LoomaClient } from "@looma/integration-sdk";
const client = new LoomaClient({ baseUrl: "https://api.looma.hr", token: process.env.LOOMA_TOKEN });
for await (const person of client.people({ query: { limit: 100 } }, { maxPages: 100 })) {
console.log(person.id);
}Changelog
Releases of the /api/v1 contract, newest first. Every response names its release in X-API-Version.
v1.0.0 · 2026-10-14
Every response names the contract version, and the release record is published
Every response from the versioned API now carries the X-API-Version header naming the semver contract release that produced it, and GET /api/v1/changelog publishes this release record, so an integration can see which contract era it is running against and what changed since. The first public release of the versioned API ships in this same era: scoped bearer tokens, field visibility applied per person, keyset pagination, per-token rate limiting and HMAC-signed outbound webhooks, with the write surface staging a hire, an employment, a reporting-line and a pay change under an Idempotency-Key.
GET /api/v1/changelog
curl "https://api.looma.hr/api/v1/changelog"
const response = await fetch("https://api.looma.hr/api/v1/changelog");
console.log(response.status, await response.json());
import requests
response = requests.get(
"https://api.looma.hr/api/v1/changelog",
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/changelog", nil)
if err != nil {
panic(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
People
Directory and individual employee records.
- GET Read current headcount by dimension
- GET Read monthly headcount trend
- GET List benefit plans
- GET List benefit participants
- GET List the employee directory
- POST Create a person and start the hire flow
- GET Synchronize people incrementally
- GET Read one employee record
- GET List a person's benefit enrolments
- GET Read a person's compensation history
- POST Submit an effective-dated pay change
- POST Submit an effective-dated employment change
- GET Read a person's lifecycle history
- GET List a person's review assignments
- GET List review cycles
Endpoints
GET /api/v1/analytics/headcount
GET /api/v1/analytics/headcount/trend
GET /api/v1/benefits
GET /api/v1/benefits/{benefit}/participants
GET /api/v1/people
POST /api/v1/people
GET /api/v1/people/changes
GET /api/v1/people/{person}
GET /api/v1/people/{person}/benefits
GET /api/v1/people/{person}/compensation
POST /api/v1/people/{person}/compensation
POST /api/v1/people/{person}/employment
GET /api/v1/people/{person}/history
GET /api/v1/people/{person}/reviews
GET /api/v1/performance/reviewsGET /api/v1/analytics/headcount
Read current headcount by dimension
Scope analytics:read
Responses
200- Tenant-scoped headcount analytics; small groups are withheld.
GET /api/v1/analytics/headcount
curl "https://api.looma.hr/api/v1/analytics/headcount" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/analytics/headcount", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/analytics/headcount",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/analytics/headcount", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/analytics/headcount/trend
Read monthly headcount trend
Scope analytics:read
Responses
200- Tenant-scoped headcount analytics; small groups are withheld.
GET /api/v1/analytics/headcount/trend
curl "https://api.looma.hr/api/v1/analytics/headcount/trend" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/analytics/headcount/trend", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/analytics/headcount/trend",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/analytics/headcount/trend", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/benefits
List benefit plans
Scope benefits:read
Query parameters
limitintegercursorstring
Responses
200- A tenant-scoped cursor page.
GET /api/v1/benefits
curl "https://api.looma.hr/api/v1/benefits?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/benefits?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/benefits?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/benefits?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/benefits/{benefit}/participants
List benefit participants
Scope benefits:read
Path parameters
benefitstring required
Query parameters
limitintegercursorstring
Responses
200- A tenant-scoped cursor page.
GET /api/v1/benefits/{benefit}/participants
curl "https://api.looma.hr/api/v1/benefits/ben-11/participants?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/benefits/ben-11/participants?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/benefits/ben-11/participants?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/benefits/ben-11/participants?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/people
List the employee directory
Current employees as of today, ordered by employee number then id. Uses the same effective-dated primary-employment semantics as the app, then applies the integration field-visibility resolver per person. Pass nextCursor back as cursor unchanged.
Scope people:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor from a previous response's nextCursor. Do not construct or parse it.
includeFormerboolean- Include people whose employment has ended.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields EmployeePage
hasMoreboolean requireditemsarray of Employee requireditems[].accountIdstringitems[].businessTitlestringitems[].countryCodestringitems[].departmentstring requireditems[].employeeNumberstring requireditems[].employmentIdstringitems[].firstNamestring requireditems[].hasPhotobooleanitems[].hiddenFieldsarray of stringitems[].idstring requireditems[].jobTitlestring requireditems[].lastNamestring requireditems[].legalEntitystring requireditems[].lifecyclestringitems[].locationstring requireditems[].revisioninteger requireditems[].startDatestring (date) requireditems[].statusstring requireditems[].workEmailstring requireditems[].workFromHomeboolean or nullitems[].workerTypestringnextCursorstring
Responses
200- A cursor page of employees.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/people
curl "https://api.looma.hr/api/v1/people?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": true,
"items": [
{
"countryCode": "SE",
"department": "People",
"employeeNumber": "E-1001",
"firstName": "Alex",
"id": "p-1001",
"jobTitle": "People Partner",
"lastName": "Nguyen",
"legalEntity": "Northwind Sweden AB",
"location": "Stockholm",
"revision": 1,
"startDate": "2024-03-01",
"status": "active",
"workEmail": "alex.nguyen@example.test",
"workerType": "employee"
}
],
"nextCursor": "RS0xMDAxH3AtMTAwMQ"
}POST /api/v1/people
Create a person and start the hire flow
Stages a hire through the same path as the app's People form: the same normalisation, integrity checks, configurable hire approval routing, audit event, notifications and webhooks. The person is created when the flow completes, not before. The token's creating account must still be an active HR or administrator member of the company.
Scope people:write
Headers
Idempotency-Keystring- An opaque caller-chosen key, 1 to 200 characters. Replaying the same key with the same body returns the first response with replayed=true instead of creating a second change. Replaying it with a different body is a 409.
Request body
The person to hire.
businessTitlestringcostCenterstringcountryCodestring- ISO 3166-1 alpha-2 country code.
departmentstring requiredeffectiveFromstring (date) required- The employment's start date. The hire runs the company's configured hire approval flow.
employeeNumberstring required- The employee's number, unique in the company.
firstNamestring requiredgradestringinitialCompensationCompensationChangeRequestinitialCompensation.amountstring required- An exact decimal string, for example 62500.00. It is stored with its exact precision.
initialCompensation.currencystring required- ISO 4217 currency code.
initialCompensation.effectiveFromstring (date) requiredinitialCompensation.employmentIdstringinitialCompensation.payBasisstring: annual, monthly, hourly requiredinitialCompensation.revisioninteger requiredjobTitlestring requiredlastNamestring requiredlegalEntityIdstring requiredlocationstring requiredworkEmailstring (email) requiredworkFromHomebooleanworkerTypestring: employee, manager, contractor
Response fields WriteResult
personIdstringreplayedbooleanstatusstring requiredtransactionIdinteger required
Responses
202- The change was staged and is now in the company's configured approval flow. It is not applied until that flow completes.
400- The body, the person id, the effective date or the revision is invalid. Unknown body fields are rejected.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
409- The revision is stale, the record already exists, or the Idempotency-Key was used for a different body.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
POST /api/v1/people
curl -X POST "https://api.looma.hr/api/v1/people" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"employeeNumber": "E1001",
"firstName": "Alex",
"lastName": "Nguyen",
"workEmail": "alex.nguyen@example.com",
"legalEntityId": "le-se-01",
"jobTitle": "People Partner",
"department": "People",
"location": "Stockholm",
"effectiveFrom": "2026-03-01"
}'
const response = await fetch("https://api.looma.hr/api/v1/people", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
"employeeNumber": "E1001",
"firstName": "Alex",
"lastName": "Nguyen",
"workEmail": "alex.nguyen@example.com",
"legalEntityId": "le-se-01",
"jobTitle": "People Partner",
"department": "People",
"location": "Stockholm",
"effectiveFrom": "2026-03-01"
}),
});
console.log(response.status, await response.json());
import os, requests, uuid
response = requests.post(
"https://api.looma.hr/api/v1/people",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}", "Idempotency-Key": str(uuid.uuid4())},
json={
"employeeNumber": "E1001",
"firstName": "Alex",
"lastName": "Nguyen",
"workEmail": "alex.nguyen@example.com",
"legalEntityId": "le-se-01",
"jobTitle": "People Partner",
"department": "People",
"location": "Stockholm",
"effectiveFrom": "2026-03-01"
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"employeeNumber": "E1001",
"firstName": "Alex",
"lastName": "Nguyen",
"workEmail": "alex.nguyen@example.com",
"legalEntityId": "le-se-01",
"jobTitle": "People Partner",
"department": "People",
"location": "Stockholm",
"effectiveFrom": "2026-03-01"
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/people", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", rand.Text())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
202 response
{
"personId": "p-1001",
"status": "PENDING_APPROVAL",
"transactionId": 4211
}GET /api/v1/people/changes
Synchronize people incrementally
Requires people:read and a live HR/admin owner. Start without checkpoint against an empty local store; persist each page and its returned checkpoint atomically. Continue with checkpoint until hasMore=false; that checkpoint is the lossless snapshot/delta handoff. Poll it for subsequent deltas. Always use this endpoint for bootstrap, not the directory listing. Checkpoints are opaque, tenant/token/resource-bound and expire after seven days, including unfinished batches. Invalid, foreign or expired checkpoints return 410 resync_required: discard the local projection and bootstrap again. Events are retained eight days; expired metadata is removed on polling. IDs in bootstrap are frozen; delta batches have a fixed upper commit sequence and order by sequence. Apply items in response order as replacements by id; repeated identities and replay are expected. Only identities and invalidations are stored: pages project current committed fields with current field policies, so retries may contain newer values. Removed or erased or no-current-primary people return only id and action=remove; delete all locally held fields. Former employees remain upserts with person.status=Former. Backdated changes invalidate immediately; future changes may invalidate immediately and all people are invalidated on the first poll of each new company day to activate effective dates even without writes. Policy and legal-entity changes invalidate all people. A demoted owner is refused; resume after reauthorization or bootstrap with a new token. A withheld field is blank and listed in hiddenFields; replace the whole previous projection so revoked fields are deleted. Do not apply concurrent page responses out of order.
Scope people:read
Query parameters
checkpointstring- Opaque checkpoint returned by the previous page; omit only for a fresh bootstrap.
limitinteger- 1–200 identities per page, default 50.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields
checkpointstring requiredhasMoreboolean requireditemsarray of object requireditems[].actionstring requireditems[].idstring requireditems[].personEmployee- One employee record with the effective-dated primary employment as of the requested date. Fields denied by the integration field-visibility policy are blank and listed in hiddenFields.
items[].person.accountIdstringitems[].person.businessTitlestringitems[].person.countryCodestringitems[].person.departmentstring requireditems[].person.employeeNumberstring requireditems[].person.employmentIdstringitems[].person.firstNamestring requireditems[].person.hasPhotobooleanitems[].person.hiddenFieldsarray of stringitems[].person.idstring requireditems[].person.jobTitlestring requireditems[].person.lastNamestring requireditems[].person.legalEntitystring requireditems[].person.lifecyclestringitems[].person.locationstring requireditems[].person.revisioninteger requireditems[].person.startDatestring (date) requireditems[].person.statusstring requireditems[].person.workEmailstring requireditems[].person.workFromHomeboolean or nullitems[].person.workerTypestringphasestring required
Responses
200- items: ordered {id, action: upsert, person: Employee} or minimal {id, action: remove}; checkpoint: opaque string; hasMore: boolean; phase: snapshot or delta. Requires people:read and a live HR/admin owner. Start without checkpoint against an empty local store; persist each page and its returned checkpoint atomically. Continue with checkpoint until hasMore=false; that checkpoint is the lossless snapshot/delta handoff. Poll it for subsequent deltas. Always use this endpoint for bootstrap, not the directory listing. Checkpoints are opaque, tenant/token/resource-bound and expire after seven days, including unfinished batches. Invalid, foreign or expired checkpoints return 410 resync_required: discard the local projection and bootstrap again. Events are retained eight days; expired metadata is removed on polling. IDs in bootstrap are frozen; delta batches have a fixed upper commit sequence and order by sequence. Apply items in response order as replacements by id; repeated identities and replay are expected. Only identities and invalidations are stored: pages project current committed fields with current field policies, so retries may contain newer values. Removed or erased or no-current-primary people return only id and action=remove; delete all locally held fields. Former employees remain upserts with person.status=Former. Backdated changes invalidate immediately; future changes may invalidate immediately and all people are invalidated on the first poll of each new company day to activate effective dates even without writes. Policy and legal-entity changes invalidate all people. A demoted owner is refused; resume after reauthorization or bootstrap with a new token. A withheld field is blank and listed in hiddenFields; replace the whole previous projection so revoked fields are deleted. Do not apply concurrent page responses out of order.
401- Credential or membership is no longer live.
403- Missing people:read or live HR/admin role.
410- resync_required. Discard the projection and bootstrap again.
GET /api/v1/people/changes
curl "https://api.looma.hr/api/v1/people/changes?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people/changes?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people/changes?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people/changes?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"checkpoint": "string",
"hasMore": false,
"items": [
{
"action": "string",
"id": "p-1001",
"person": {
"accountId": "string",
"businessTitle": "People Partner",
"countryCode": "SE",
"department": "People",
"employeeNumber": "E1001",
"employmentId": "string",
"firstName": "Alex",
"hasPhoto": false,
"hiddenFields": [],
"id": "p-1001",
"jobTitle": "People Partner",
"lastName": "Nguyen",
"legalEntity": "string",
"lifecycle": "string",
"location": "Stockholm",
"revision": 0,
"startDate": "2026-03-01",
"status": "string",
"workEmail": "string",
"workFromHome": false,
"workerType": "string"
}
}
],
"phase": "string"
}GET /api/v1/people/{person}
Read one employee record
One employee's effective-dated primary employment as of an optional date. Fields denied by the integration field-visibility policy are blank and listed in hiddenFields.
Scope people:read
Path parameters
personstring required- The person id.
Query parameters
onstring (date)- Effective date (YYYY-MM-DD). Defaults to today (UTC).
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
Response fields Employee
accountIdstringbusinessTitlestringcountryCodestringdepartmentstring requiredemployeeNumberstring requiredemploymentIdstringfirstNamestring requiredhasPhotobooleanhiddenFieldsarray of stringidstring requiredjobTitlestring requiredlastNamestring requiredlegalEntitystring requiredlifecyclestringlocationstring requiredrevisioninteger requiredstartDatestring (date) requiredstatusstring requiredworkEmailstring requiredworkFromHomeboolean or nullworkerTypestring
Responses
200- The employee record as of the requested date.
400- The effective date or person id is invalid.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- No employee matched that id on the requested date.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/people/{person}
curl "https://api.looma.hr/api/v1/people/p-1001" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people/p-1001",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people/p-1001", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"countryCode": "SE",
"department": "People",
"employeeNumber": "E-1001",
"firstName": "Alex",
"id": "p-1001",
"jobTitle": "People Partner",
"lastName": "Nguyen",
"legalEntity": "Northwind Sweden AB",
"location": "Stockholm",
"revision": 1,
"startDate": "2024-03-01",
"status": "active",
"workEmail": "alex.nguyen@example.test",
"workerType": "employee"
}GET /api/v1/people/{person}/benefits
List a person's benefit enrolments
Scope benefits:read
Path parameters
personstring required
Query parameters
limitintegercursorstring
Responses
200- A tenant-scoped cursor page.
GET /api/v1/people/{person}/benefits
curl "https://api.looma.hr/api/v1/people/p-1001/benefits?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/benefits?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people/p-1001/benefits?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people/p-1001/benefits?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/people/{person}/compensation
Read a person's compensation history
One person's effective-dated pay history and current pay as of an optional date. HR and administrator tokens only, exactly as in the app. Amounts, the pay-zone range and the market context honour the integration field-visibility policy; a denied policy blanks them and names salary in hiddenFields. Cross-tenant reads are impossible because the tenant comes only from the token.
Scope compensation:read
Path parameters
personstring required- The person id.
Query parameters
onstring (date)- Effective date (YYYY-MM-DD). Defaults to today (UTC).
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
Response fields CompensationRecord
currentobject or null requiredemploymentIdstringhiddenFieldsarray of stringhistoryarray of objecthistory[].amountstring requiredhistory[].currencystring requiredhistory[].effectiveFromstring (date) requiredhistory[].employmentIdstringhistory[].payBasisstring requiredhistory[].revisioninteger requiredmarketobject or nulloneOffPaymentsarray of objectoneOffPayments[].amountstring requiredoneOffPayments[].approvedAtobject or nulloneOffPayments[].createdAtobject requiredoneOffPayments[].currencystring requiredoneOffPayments[].employeeNumberstringoneOffPayments[].hiddenFieldsarray of stringoneOffPayments[].idinteger requiredoneOffPayments[].kindstring requiredoneOffPayments[].payDatestring requiredoneOffPayments[].payHiddenbooleanoneOffPayments[].personIdstring requiredoneOffPayments[].personNamestringoneOffPayments[].reasonstringoneOffPayments[].requestedBystringoneOffPayments[].revisioninteger requiredoneOffPayments[].statusstring requiredoneOffPayments[].titlestring requiredoneOffPayments[].transactionIdinteger or nulloneOffPayments[].updatedAtobject requiredpersonIdstring requiredpriorPayChangePercentstringpromotionLinkedboolean requiredrevisioninteger requiredtotalCashobject or nullzoneobject or null
Responses
200- The effective-dated pay history and current pay as of the requested date.
400- The effective date or person id is invalid.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- No employee matched that id on the requested date.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/people/{person}/compensation
curl "https://api.looma.hr/api/v1/people/p-1001/compensation" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/compensation", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people/p-1001/compensation",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people/p-1001/compensation", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"current": {
"amount": "65000.00",
"currency": "SEK",
"effectiveFrom": "2025-03-01",
"employmentId": "e-1",
"payBasis": "annual",
"revision": 3
},
"employmentId": "e-1",
"history": [
{
"amount": "62500.00",
"currency": "SEK",
"effectiveFrom": "2024-03-01",
"employmentId": "e-1",
"payBasis": "annual",
"revision": 2
}
],
"personId": "p-1001",
"revision": 3
}POST /api/v1/people/{person}/compensation
Submit an effective-dated pay change
Stages a pay change through the same path as the app's compensation form, with the same revision check and configurable compensation-change approval routing. The amount is an exact decimal string and is stored with its exact precision. Pay is HR and administrator only, exactly as in the app.
Scope compensation:write
Path parameters
personstring required- The person id.
Headers
Idempotency-Keystring- An opaque caller-chosen key, 1 to 200 characters. Replaying the same key with the same body returns the first response with replayed=true instead of creating a second change. Replaying it with a different body is a 409.
Request body
The pay to change from effectiveFrom.
amountstring required- An exact decimal string, for example 62500.00. It is stored with its exact precision.
currencystring required- ISO 4217 currency code.
effectiveFromstring (date) requiredemploymentIdstringpayBasisstring: annual, monthly, hourly requiredrevisioninteger required
Response fields WriteResult
personIdstringreplayedbooleanstatusstring requiredtransactionIdinteger required
Responses
202- The change was staged and is now in the company's configured approval flow. It is not applied until that flow completes.
400- The body, the person id, the effective date or the revision is invalid. Unknown body fields are rejected.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
409- The revision is stale, the record already exists, or the Idempotency-Key was used for a different body.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
POST /api/v1/people/{person}/compensation
curl -X POST "https://api.looma.hr/api/v1/people/p-1001/compensation" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amount": "52000.00",
"currency": "SEK",
"payBasis": "annual",
"effectiveFrom": "2026-03-01",
"revision": 0
}'
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/compensation", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
"amount": "52000.00",
"currency": "SEK",
"payBasis": "annual",
"effectiveFrom": "2026-03-01",
"revision": 0
}),
});
console.log(response.status, await response.json());
import os, requests, uuid
response = requests.post(
"https://api.looma.hr/api/v1/people/p-1001/compensation",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}", "Idempotency-Key": str(uuid.uuid4())},
json={
"amount": "52000.00",
"currency": "SEK",
"payBasis": "annual",
"effectiveFrom": "2026-03-01",
"revision": 0
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"amount": "52000.00",
"currency": "SEK",
"payBasis": "annual",
"effectiveFrom": "2026-03-01",
"revision": 0
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/people/p-1001/compensation", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", rand.Text())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
202 response
{
"personId": "p-1001",
"status": "PENDING_APPROVAL",
"transactionId": 4211
}POST /api/v1/people/{person}/employment
Submit an effective-dated employment change
Stages a job, assignment or employment change through the same path as the app's employment form, including the same revision check and configurable employment-change approval routing. The change takes effect on effectiveFrom when the flow completes. HR and administrator tokens may change any person; a manager token only a current direct report.
Scope people:write
Path parameters
personstring required- The person id.
Headers
Idempotency-Keystring- An opaque caller-chosen key, 1 to 200 characters. Replaying the same key with the same body returns the first response with replayed=true instead of creating a second change. Replaying it with a different body is a 409.
Request body
The employment values to change from effectiveFrom.
businessTitlestringcostCenterstringcountryCodestringdepartmentstring requiredeffectiveFromstring (date) required- The date this change takes effect. A past date corrects history; a future date schedules it.
employmentIdstring- The employment to change. Omit when the person has exactly one.
gradestringjobTitlestring requiredlegalEntityIdstring requiredlocationstring requiredrevisioninteger required- The revision the caller last read. A stale revision is a conflict, never a silent overwrite.
workFromHomebooleanworkerTypestring: employee, manager, contractor
Response fields WriteResult
personIdstringreplayedbooleanstatusstring requiredtransactionIdinteger required
Responses
202- The change was staged and is now in the company's configured approval flow. It is not applied until that flow completes.
400- The body, the person id, the effective date or the revision is invalid. Unknown body fields are rejected.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
409- The revision is stale, the record already exists, or the Idempotency-Key was used for a different body.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
POST /api/v1/people/{person}/employment
curl -X POST "https://api.looma.hr/api/v1/people/p-1001/employment" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"legalEntityId": "le-se-01",
"jobTitle": "People Partner",
"department": "People",
"location": "Stockholm",
"effectiveFrom": "2026-03-01",
"revision": 0
}'
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/employment", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
"legalEntityId": "le-se-01",
"jobTitle": "People Partner",
"department": "People",
"location": "Stockholm",
"effectiveFrom": "2026-03-01",
"revision": 0
}),
});
console.log(response.status, await response.json());
import os, requests, uuid
response = requests.post(
"https://api.looma.hr/api/v1/people/p-1001/employment",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}", "Idempotency-Key": str(uuid.uuid4())},
json={
"legalEntityId": "le-se-01",
"jobTitle": "People Partner",
"department": "People",
"location": "Stockholm",
"effectiveFrom": "2026-03-01",
"revision": 0
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"legalEntityId": "le-se-01",
"jobTitle": "People Partner",
"department": "People",
"location": "Stockholm",
"effectiveFrom": "2026-03-01",
"revision": 0
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/people/p-1001/employment", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", rand.Text())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
202 response
{
"personId": "p-1001",
"status": "PENDING_APPROVAL",
"transactionId": 4211
}GET /api/v1/people/{person}/history
Read a person's lifecycle history
Scope lifecycle:read
Path parameters
personstring required
Query parameters
limitintegercursorstring
Responses
200- A cursor-paginated lifecycle history.
GET /api/v1/people/{person}/history
curl "https://api.looma.hr/api/v1/people/p-1001/history?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/history?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people/p-1001/history?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people/p-1001/history?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/people/{person}/reviews
List a person's review assignments
Scope performance:read
Path parameters
personstring required
Query parameters
limitinteger- Page size (1–200; default 50).
cursorstring- Opaque nextCursor from the previous page.
Responses
200- A cursor-paginated page of visible review cycles or reviews.
GET /api/v1/people/{person}/reviews
curl "https://api.looma.hr/api/v1/people/p-1001/reviews?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/reviews?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people/p-1001/reviews?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people/p-1001/reviews?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/performance/reviews
List review cycles
Scope performance:read
Query parameters
limitinteger- Page size (1–200; default 50).
cursorstring- Opaque nextCursor from the previous page.
Responses
200- A cursor-paginated page of visible review cycles or reviews.
GET /api/v1/performance/reviews
curl "https://api.looma.hr/api/v1/performance/reviews?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/performance/reviews?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/performance/reviews?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/performance/reviews?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKObjectives
Employee objectives and goals for the current cycle.
Endpoints
GET /api/v1/employees/{person}/goals
GET /api/v1/employees/{person}/objectives
GET /api/v1/goals
GET /api/v1/objectivesGET /api/v1/employees/{person}/goals
List an employee's visible current goals
Scope objectives:read
Path parameters
personstring required
Query parameters
limitinteger- Page size (1–200; default 50).
cursorstring- Opaque nextCursor from the previous page.
Responses
200- A cursor-paginated page of visible current-cycle objectives.
GET /api/v1/employees/{person}/goals
curl "https://api.looma.hr/api/v1/employees/p-1001/goals?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/employees/p-1001/goals?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/employees/p-1001/goals?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/employees/p-1001/goals?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/employees/{person}/objectives
List an employee's current-cycle objectives
Scope objectives:read
Path parameters
personstring required
Query parameters
limitinteger- Page size (1–200; default 50).
cursorstring- Opaque nextCursor from the previous page.
Responses
200- A cursor-paginated page of visible current-cycle objectives.
GET /api/v1/employees/{person}/objectives
curl "https://api.looma.hr/api/v1/employees/p-1001/objectives?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/employees/p-1001/objectives?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/employees/p-1001/objectives?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/employees/p-1001/objectives?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/goals
List visible current goals
Scope objectives:read
Query parameters
limitinteger- Page size (1–200; default 50).
cursorstring- Opaque nextCursor from the previous page.
Responses
200- A cursor-paginated page of visible current-cycle objectives.
GET /api/v1/goals
curl "https://api.looma.hr/api/v1/goals?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/goals?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/goals?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/goals?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/objectives
List current-cycle objectives
Scope objectives:read
Query parameters
limitinteger- Page size (1–200; default 50).
cursorstring- Opaque nextCursor from the previous page.
Responses
200- A cursor-paginated page of visible current-cycle objectives.
GET /api/v1/objectives
curl "https://api.looma.hr/api/v1/objectives?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/objectives?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/objectives?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/objectives?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKOrganisation
Legal entities, workforce catalogue and reporting lines.
Endpoints
GET /api/v1/organisation
POST /api/v1/people/{person}/manager
GET /api/v1/positions
GET /api/v1/teamsGET /api/v1/organisation
Read the organisation
Legal entities, the active workforce catalogue (up to 1000 entries) and the current reporting line per person, keyset-paginated by person id in groups of up to 500.
Scope org:read
Query parameters
cursorstring- Opaque reporting-line cursor: the last person id from the previous response. Omit for the first page.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
Response fields OrganisationResponse
cataloguearray of OrganisationCatalogueEntry requiredcatalogue[].codestring requiredcatalogue[].idstring requiredcatalogue[].kindstring requiredcatalogue[].namestring requiredlegalEntitiesarray of LegalEntity requiredlegalEntities[].idstring requiredlegalEntities[].namestring requiredreportingLinesarray of OrganisationReportingLine requiredreportingLines[].effectiveFromstring (date) requiredreportingLines[].managerIdstringreportingLines[].personIdstring required
Responses
200- The organisation snapshot.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/organisation
curl "https://api.looma.hr/api/v1/organisation" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/organisation", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/organisation",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/organisation", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"catalogue": [
{
"code": "PEOPLE",
"id": "dep-1",
"kind": "department",
"name": "People"
}
],
"legalEntities": [
{
"id": "le-1",
"name": "Northwind Sweden AB"
}
],
"reportingLines": [
{
"effectiveFrom": "2025-01-01",
"managerId": "p-1000",
"personId": "p-1001"
}
]
}POST /api/v1/people/{person}/manager
Submit an effective-dated reporting-line change
Stages a manager change through the same path as the app's manager-change form, with the same revision check and configurable manager-change approval routing. An empty managerId removes the line manager. HR and administrator tokens may change any person; a manager token only a current direct report.
Scope org:write
Path parameters
personstring required- The person id.
Headers
Idempotency-Keystring- An opaque caller-chosen key, 1 to 200 characters. Replaying the same key with the same body returns the first response with replayed=true instead of creating a second change. Replaying it with a different body is a 409.
Request body
The reporting line to change from effectiveFrom.
effectiveFromstring (date) requiredexpectedRevisioninteger required- The reporting-line revision the caller last read.
managerIdstring required- The new line manager's person id. An empty string removes the line manager.
Response fields WriteResult
personIdstringreplayedbooleanstatusstring requiredtransactionIdinteger required
Responses
202- The change was staged and is now in the company's configured approval flow. It is not applied until that flow completes.
400- The body, the person id, the effective date or the revision is invalid. Unknown body fields are rejected.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
409- The revision is stale, the record already exists, or the Idempotency-Key was used for a different body.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
POST /api/v1/people/{person}/manager
curl -X POST "https://api.looma.hr/api/v1/people/p-1001/manager" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"managerId": "p-1042",
"effectiveFrom": "2026-03-01",
"expectedRevision": 0
}'
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/manager", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
"managerId": "p-1042",
"effectiveFrom": "2026-03-01",
"expectedRevision": 0
}),
});
console.log(response.status, await response.json());
import os, requests, uuid
response = requests.post(
"https://api.looma.hr/api/v1/people/p-1001/manager",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}", "Idempotency-Key": str(uuid.uuid4())},
json={
"managerId": "p-1042",
"effectiveFrom": "2026-03-01",
"expectedRevision": 0
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"managerId": "p-1042",
"effectiveFrom": "2026-03-01",
"expectedRevision": 0
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/people/p-1001/manager", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", rand.Text())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
202 response
{
"personId": "p-1001",
"status": "PENDING_APPROVAL",
"transactionId": 4211
}GET /api/v1/positions
List organisation positions
Scope org:read
Query parameters
limitinteger- Page size (1–200; default 50).
cursorstring- Opaque nextCursor from the previous page.
Responses
200- A cursor-paginated page.
GET /api/v1/positions
curl "https://api.looma.hr/api/v1/positions?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/positions?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/positions?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/positions?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKGET /api/v1/teams
List organisation teams
Scope org:read
Query parameters
limitinteger- Page size (1–200; default 50).
cursorstring- Opaque nextCursor from the previous page.
Responses
200- A cursor-paginated page.
GET /api/v1/teams
curl "https://api.looma.hr/api/v1/teams?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/teams?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/teams?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/teams?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OKEndpoints
GET /api/v1/time-offGET /api/v1/time-off
List approved time off
Approved, uncancelled time-off requests, newest first by transaction id. Pass nextCursor back as cursor unchanged.
Scope timeoff:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor (transaction id) from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields TimeOffPage
hasMoreboolean requireditemsarray of TimeOffRequest requireditems[].endDatestring (date) requireditems[].halfDayboolean requireditems[].personIdstring requireditems[].planIdstring requireditems[].startDatestring (date) requireditems[].transactionIdinteger requireditems[].unitsinteger requirednextCursorstring
Responses
200- A cursor page of leave requests.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/time-off
curl "https://api.looma.hr/api/v1/time-off?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/time-off?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/time-off?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/time-off?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": true,
"items": [
{
"endDate": "2026-07-10",
"halfDay": false,
"personId": "p-1001",
"planId": "plan-annual",
"startDate": "2026-07-01",
"transactionId": 42,
"units": 8
}
],
"nextCursor": "41"
}Endpoints
GET /api/v1/timecardsGET /api/v1/timecards
List approved timecards
Approved timecards, newest first by id. Pass nextCursor back as cursor unchanged.
Scope timecards:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor (timecard id) from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields TimecardPage
hasMoreboolean requireditemsarray of Timecard requireditems[].idinteger requireditems[].periodEndstring (date) requireditems[].periodStartstring (date) requireditems[].personIdstring requireditems[].statusstring requireditems[].totalMinutesinteger requireditems[].transactionIdinteger or nullnextCursorstring
Responses
200- A cursor page of timecards.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/timecards
curl "https://api.looma.hr/api/v1/timecards?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/timecards?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/timecards?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/timecards?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": false,
"items": [
{
"id": 5001,
"periodEnd": "2026-06-15",
"periodStart": "2026-06-01",
"personId": "p-1001",
"status": "approved",
"totalMinutes": 4800,
"transactionId": 42
}
]
}Payroll
Payroll runs, payslips and the pay inputs that feed a run.
Endpoints
GET /api/v1/payroll/inputs
GET /api/v1/payroll/runs
GET /api/v1/payroll/runs/{run}/payslipsGET /api/v1/payroll/inputs
List payroll inputs
The approved pay inputs that feed each payroll run (approved timecard minutes, approved flexible-benefit amounts and approved-unpaid leave units), newest first by run id. Values are withheld and redacted is set when the integration viewer's field policy restricts compensation. Pass nextCursor back as cursor unchanged.
Scope payroll:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor (run id) from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields PayrollInputPage
hasMoreboolean requireditemsarray of PayrollInput requireditems[].flexAmountstringitems[].leaveUnitsstringitems[].redactedbooleanitems[].runIdinteger requireditems[].timecardMinutesstringnextCursorstring
Responses
200- A cursor page of payroll inputs.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/payroll/inputs
curl "https://api.looma.hr/api/v1/payroll/inputs?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/payroll/inputs?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/payroll/inputs?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/payroll/inputs?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": true,
"items": [
{
"flexAmount": "2500.0000",
"leaveUnits": "8",
"runId": 7001,
"timecardMinutes": "4800.0000"
}
],
"nextCursor": "7001"
}GET /api/v1/payroll/runs
List payroll runs
Payroll runs, newest first by run id. Totals are withheld and totalsRedacted is set when the integration viewer's field policy restricts compensation. Pass nextCursor back as cursor unchanged.
Scope payroll:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor (run id) from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields PayrollRunPage
hasMoreboolean requireditemsarray of PayrollRun requireditems[].approvedAtstringitems[].idinteger requireditems[].legalEntityIdstring requireditems[].legalEntityNamestringitems[].lineCountinteger requireditems[].periodEndstring (date) requireditems[].periodIdinteger requireditems[].periodStartstring (date) requireditems[].revisioninteger requireditems[].runKindstringitems[].statusstring requireditems[].totalGrossstringitems[].totalPayoutstringitems[].totalsRedactedbooleannextCursorstring
Responses
200- A cursor page of payroll runs.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/payroll/runs
curl "https://api.looma.hr/api/v1/payroll/runs?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/payroll/runs?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/payroll/runs?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/payroll/runs?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": true,
"items": [
{
"id": 7001,
"legalEntityId": "le-1",
"legalEntityName": "Northwind Sweden AB",
"lineCount": 12,
"periodEnd": "2026-06-30",
"periodId": 410,
"periodStart": "2026-06-01",
"runKind": "regular",
"status": "approved",
"totalGross": "120000.0000",
"totalPayout": "96000.0000"
}
],
"nextCursor": "7001"
}GET /api/v1/payroll/runs/{run}/payslips
List a payroll run's payslips
Metadata of the published payslips in the run's payroll period, newest first. The confidential PDF bytes never appear in a paged list. Pass nextCursor back as cursor unchanged.
Scope payroll:read
Path parameters
runinteger required- The payroll run id.
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor (payslip sequence) from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields PayslipPage
hasMoreboolean requireditemsarray of Payslip requireditems[].byteSizeinteger requireditems[].filenamestring requireditems[].idstring requireditems[].personIdstring requireditems[].publishedAtstringitems[].statusstring requireditems[].versioninteger requirednextCursorstring
Responses
200- A cursor page of payslip metadata.
400- The run id or cursor is invalid.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- No payroll run matched that id.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/payroll/runs/{run}/payslips
curl "https://api.looma.hr/api/v1/payroll/runs/run-2026-03/payslips?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/payroll/runs/run-2026-03/payslips?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/payroll/runs/run-2026-03/payslips?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/payroll/runs/run-2026-03/payslips?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": false,
"items": [
{
"byteSize": 20480,
"filename": "payslip-june.pdf",
"id": "ps-1001",
"personId": "p-1001",
"publishedAt": "2026-07-01T08:00:00",
"status": "published",
"version": 1
}
],
"nextCursor": "17"
}Recruiting
Jobs, candidates and applications.
Endpoints
GET /api/v1/applications
GET /api/v1/candidates
GET /api/v1/jobsGET /api/v1/applications
List vacancy applications
Vacancy applications by existing people, newest first by id. Pass nextCursor back as cursor unchanged.
Scope recruiting:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields ApplicationPage
hasMoreboolean requireditemsarray of Application requireditems[].coverNotestringitems[].createdAtstring requireditems[].idinteger requireditems[].personIdstring requireditems[].statusstring requireditems[].updatedAtstring requireditems[].vacancyIdinteger requirednextCursorstring
Responses
200- A cursor page of applications.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/applications
curl "https://api.looma.hr/api/v1/applications?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/applications?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/applications?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/applications?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": false,
"items": [
{
"id": 1101,
"personId": "p-1001",
"status": "submitted",
"vacancyId": 5001
}
],
"nextCursor": "1101"
}GET /api/v1/candidates
List candidates
Candidate contact records, newest first by id. Candidate personal fields pass the integration field-visibility policy and are blank (and listed in hiddenFields) where that policy denies them. Pass nextCursor back as cursor unchanged.
Scope recruiting:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields CandidatePage
hasMoreboolean requireditemsarray of Candidate requireditems[].createdAtstring requireditems[].emailstring requireditems[].firstNamestring requireditems[].hiddenFieldsarray of stringitems[].idinteger requireditems[].lastNamestring requireditems[].notesstringitems[].phonestringitems[].sourcestringitems[].stagestring requireditems[].updatedAtstring requirednextCursorstring
Responses
200- A cursor page of candidates.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/candidates
curl "https://api.looma.hr/api/v1/candidates?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/candidates?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/candidates?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/candidates?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": false,
"items": [
{
"email": "alex@example.test",
"firstName": "Alex",
"id": 9001,
"lastName": "Nguyen",
"stage": "interview"
}
],
"nextCursor": "9001"
}GET /api/v1/jobs
List jobs (vacancies)
Vacancies, newest first by id, with their opening date, closing date and current state. Pass nextCursor back as cursor unchanged.
Scope recruiting:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields RecruitingJobPage
hasMoreboolean requireditemsarray of RecruitingJob requireditems[].closingDatestringitems[].createdAtstring requireditems[].departmentstringitems[].descriptionstringitems[].idinteger requireditems[].openingDatestring requireditems[].statusstring requireditems[].titlestring requireditems[].updatedAtstring requirednextCursorstring
Responses
200- A cursor page of jobs.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
GET /api/v1/jobs
curl "https://api.looma.hr/api/v1/jobs?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/jobs?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/jobs?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/jobs?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": true,
"items": [
{
"department": "People",
"id": 5001,
"openingDate": "2026-03-01",
"status": "open",
"title": "People Partner"
}
],
"nextCursor": "5001"
}Expenses
Employee expense claims.
Endpoints
POST /api/v1/expense-receipts
GET /api/v1/expenses
POST /api/v1/expenses
GET /api/v1/expenses/{claim}POST /api/v1/expense-receipts
Receive an expense receipt
Delivers one receipt attachment as a private proposal for the employee identified by an enabled routing token. The bearer token determines the company. Retries with the same deliveryKey return the existing intake id. This does not submit an expense claim. The JSON body is limited to 14 MiB and the decoded PDF, PNG or JPEG attachment to 10 MiB.
Scope expenses:receive
Request body
The recipient routing token, stable delivery key, filename and base64 attachment content.
contentstring (byte) required- Base64-encoded PDF, PNG or JPEG attachment, at most 10 MiB after decoding.
deliveryKeystring required- Stable key per attachment, 1 to 200 characters. Retries return the original intake id.
filenamestring required- Attachment filename, 1 to 180 characters, without path separators.
routingTokenstring required- The employee's enabled 64-character hexadecimal recipient capability.
Response fields
intakeIdstring requiredstatusstring: draft required
Responses
202- The receipt was captured, or an existing delivery was replayed.
400- The receipt delivery or attachment is invalid.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
409- The employee has reached the pending receipt limit.
413- The request body exceeds the 14 MiB limit.
429- The per-token rate limit was exceeded. Retry after the current rolling minute.
503- The integration API is temporarily unavailable.
POST /api/v1/expense-receipts
curl -X POST "https://api.looma.hr/api/v1/expense-receipts" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"routingToken": "string",
"deliveryKey": "string",
"filename": "string",
"content": "string"
}'
const response = await fetch("https://api.looma.hr/api/v1/expense-receipts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"routingToken": "string",
"deliveryKey": "string",
"filename": "string",
"content": "string"
}),
});
console.log(response.status, await response.json());
import os, requests
response = requests.post(
"https://api.looma.hr/api/v1/expense-receipts",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
json={
"routingToken": "string",
"deliveryKey": "string",
"filename": "string",
"content": "string"
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"routingToken": "string",
"deliveryKey": "string",
"filename": "string",
"content": "string"
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/expense-receipts", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
202 response
{
"intakeId": "receipt-1001",
"status": "draft"
}GET /api/v1/expenses
List expense claims
Employee expense claims, newest first by id. The claimant's name is blank and listed in hiddenFields when the integration field-visibility policy denies it. Totals are per currency and exact. Pass nextCursor back as cursor unchanged.
Scope expenses:read
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor (claim id) from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields ExpenseClaimPage
hasMoreboolean requireditemsarray of ExpenseClaim requireditems[].createdAtstring requireditems[].employeeNumberstringitems[].hiddenFieldsarray of stringitems[].idinteger requireditems[].lineCountinteger requireditems[].personIdstring requireditems[].personNamestringitems[].revisioninteger requireditems[].statusstring requireditems[].titlestring requireditems[].totalsarray of object requireditems[].totals[].currencystring requireditems[].totals[].totalstring requireditems[].updatedAtstring requirednextCursorstring
Responses
200- A cursor page of expense claims.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute.
503- The integration API is temporarily unavailable.
GET /api/v1/expenses
curl "https://api.looma.hr/api/v1/expenses?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/expenses?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/expenses?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/expenses?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": true,
"items": [
{
"employeeNumber": "E-1001",
"id": 8201,
"lineCount": 2,
"personId": "p-1001",
"personName": "Alex Nguyen",
"revision": 2,
"status": "pending",
"title": "Client lunch"
}
],
"nextCursor": "8201"
}POST /api/v1/expenses
Submit an expense claim
Creates a new expense claim for the token's linked employee and submits it through the same path as the app's claim form: the same normalisation, integrity checks, configurable EXPENSE_CLAIM approval routing, audit event and webhooks. The claim enters the company's approval flow and is not reimbursed until that flow completes. Replaying the same Idempotency-Key with the same body returns the first response instead of submitting a second claim.
Scope expenses:write
Headers
Idempotency-Keystring- An opaque caller-chosen key, 1 to 200 characters. Replaying the same key with the same body returns the first response with replayed=true instead of creating a second claim. Replaying it with a different body is a 409.
Request body
The claim title and line items.
linesarray of object required- At least one line item (up to 50).
lines[].amountstring required- The exact amount, up to four decimal digits.
lines[].categoryIdinteger requiredlines[].currencystring required- ISO 4217 three-letter currency code.
lines[].descriptionstringlines[].expenseDatestring (date) requiredlines[].merchantstring requiredtitlestring required- The claim's title, 1 to 160 characters.
Response fields WriteResult
personIdstringreplayedbooleanstatusstring requiredtransactionIdinteger required
Responses
202- The claim was staged and is now in the company's configured EXPENSE_CLAIM approval flow.
400- The body or the claim inputs are invalid. Unknown body fields are rejected.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
409- The Idempotency-Key was used for a different body, or the claim could not be submitted in the expected state.
429- The per-token rate limit was exceeded. Retry after the current rolling minute.
503- The integration API is temporarily unavailable.
POST /api/v1/expenses
curl -X POST "https://api.looma.hr/api/v1/expenses" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"title": "string",
"lines": [
{
"expenseDate": "2026-03-01",
"categoryId": 0,
"merchant": "string",
"currency": "SEK",
"amount": "52000.00"
}
]
}'
const response = await fetch("https://api.looma.hr/api/v1/expenses", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
"title": "string",
"lines": [
{
"expenseDate": "2026-03-01",
"categoryId": 0,
"merchant": "string",
"currency": "SEK",
"amount": "52000.00"
}
]
}),
});
console.log(response.status, await response.json());
import os, requests, uuid
response = requests.post(
"https://api.looma.hr/api/v1/expenses",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}", "Idempotency-Key": str(uuid.uuid4())},
json={
"title": "string",
"lines": [
{
"expenseDate": "2026-03-01",
"categoryId": 0,
"merchant": "string",
"currency": "SEK",
"amount": "52000.00"
}
]
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"title": "string",
"lines": [
{
"expenseDate": "2026-03-01",
"categoryId": 0,
"merchant": "string",
"currency": "SEK",
"amount": "52000.00"
}
]
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/expenses", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", rand.Text())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
202 response
{
"personId": "p-1001",
"status": "PENDING_APPROVAL",
"transactionId": 9211
}GET /api/v1/expenses/{claim}
Read one expense claim
One expense claim with its full line items and event history. The claimant's name is blank and listed in hiddenFields when the integration field-visibility policy denies it.
Scope expenses:read
Path parameters
claiminteger required- The claim id.
Query parameters
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
Response fields ExpenseClaimDetail
claimExpenseClaim required- One expense claim with its headers and per-currency totals. The claimant's name is blank and listed in hiddenFields under the integration field-visibility policy.
claim.createdAtstring requiredclaim.employeeNumberstringclaim.hiddenFieldsarray of stringclaim.idinteger requiredclaim.lineCountinteger requiredclaim.personIdstring requiredclaim.personNamestringclaim.revisioninteger requiredclaim.statusstring requiredclaim.titlestring requiredclaim.totalsarray of object requiredclaim.totals[].currencystring requiredclaim.totals[].totalstring requiredclaim.updatedAtstring requiredeventsarray of objectevents[].actorIdstringevents[].bodystringevents[].createdAtstring requiredevents[].idinteger requiredevents[].kindstring requiredlinesarray of object requiredlines[].amountstring requiredlines[].categorystringlines[].categoryIdinteger requiredlines[].currencystring requiredlines[].descriptionstringlines[].expenseDatestring requiredlines[].idinteger requiredlines[].merchantstring requiredtotalsarray of object requiredtotals[].currencystring requiredtotals[].totalstring requiredtransactionIdinteger or null
Responses
200- The claim and its lines.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- No expense claim matched that id.
429- The per-token rate limit was exceeded. Retry after the current rolling minute.
503- The integration API is temporarily unavailable.
GET /api/v1/expenses/{claim}
curl "https://api.looma.hr/api/v1/expenses/exp-3101" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/expenses/exp-3101", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/expenses/exp-3101",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/expenses/exp-3101", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"claim": {
"id": 8201,
"lineCount": 2,
"personId": "p-1001",
"personName": "Alex Nguyen",
"revision": 2,
"status": "pending",
"title": "Client lunch"
}
}Endpoints
GET /api/v1/people/{person}/documentsGET /api/v1/people/{person}/documents
List employee documents
Employee document metadata for a person, newest first by id. The document bytes are not returned by this endpoint; use the download endpoint in the self-service API for file access. Employee fields pass the integration field-visibility policy. Pass nextCursor back as cursor unchanged.
Scope documents:read
Path parameters
personstring required- The person id.
Query parameters
limitinteger- Maximum number of items to return. Values above the maximum are clamped.
cursorstring- Opaque keyset cursor (document id) from a previous response's nextCursor.
fieldsstring- Comma-separated top-level properties to return for each record, for example fields=id,firstName,workEmail. A page's items, nextCursor and hasMore and each record's hiddenFields are always returned. A name this resource does not carry is a 400.
includestring- Comma-separated nested collections to return, for example include=reportingLines. Naming one keeps it and drops the answer's other collections; it never adds data the endpoint would not otherwise return. A name this resource does not carry is a 400.
filterstring- Comma-separated field:value matches, all of which must hold, for example filter=status:active,department:People. It narrows the items of the page this read returns, so a filtered page may be shorter than limit; nextCursor still continues the collection. A field this resource does not carry, or a malformed match, is a 400.
sortstring- Comma-separated fields to order the returned page by, descending for a field prefixed with '-', for example sort=-startDate,lastName. It orders the items of the page; the collection's own order still governs which rows the page contains and how nextCursor continues it. A field this resource does not carry is a 400.
pageinteger- The 1-based page of the collection to return, up to the maximum. It is the cursor-free way to traverse a collection: the answer is this read's own answer for that page, with its own nextCursor and hasMore. Page N reads the collection N times, so it spends N of the token's request budget and issues N queries; a client traversing many pages should pass nextCursor back as cursor instead. It cannot be combined with cursor. A page past the collection's last one is an empty page.
Response fields EmployeeDocumentPage
hasMoreboolean requireditemsarray of EmployeeDocument requireditems[].archivedboolean requireditems[].byteSizeinteger requireditems[].categorystring requireditems[].contentTypestring requireditems[].createdAtstring requireditems[].employeeVisibleboolean requireditems[].filenamestring requireditems[].hiddenFieldsarray of stringitems[].idstring requireditems[].personIdstring requireditems[].revisioninteger requireditems[].titlestring requireditems[].uploadedBystring requirednextCursorstring
Responses
200- A cursor page of employee document metadata.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
429- The per-token rate limit was exceeded. Retry after the current rolling minute.
503- The integration API is temporarily unavailable.
GET /api/v1/people/{person}/documents
curl "https://api.looma.hr/api/v1/people/p-1001/documents?limit=2" \
-H "Authorization: Bearer $LOOMA_TOKEN"
const response = await fetch("https://api.looma.hr/api/v1/people/p-1001/documents?limit=2", {
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
},
});
console.log(response.status, await response.json());
import os, requests
response = requests.get(
"https://api.looma.hr/api/v1/people/p-1001/documents?limit=2",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/people/p-1001/documents?limit=2", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"hasMore": true,
"items": [
{
"archived": false,
"byteSize": 102400,
"category": "employment",
"contentType": "application/pdf",
"createdAt": "2026-01-15T10:30:00Z",
"employeeVisible": true,
"filename": "contract.pdf",
"id": "doc-1001",
"personId": "p-1001",
"revision": 1,
"title": "Employment Contract",
"uploadedBy": "hr-account"
}
],
"nextCursor": "doc-1001"
}Endpoints
POST /api/v1/webhooks/{webhook}/deliveries/replayPOST /api/v1/webhooks/{webhook}/deliveries/replay
Replay webhook deliveries from the delivery log
Re-enqueues the exact stored payload of every delivery to this endpoint in the date range, bounded to the last 30 days. Receivers deduplicate replayed deliveries on the X-Looma-Delivery header. Administrator tokens only; this is integration administration, not an HR action.
Scope webhooks:write
Path parameters
webhookstring required- The webhook endpoint id.
Headers
Idempotency-Keystring- An opaque caller-chosen key, 1 to 200 characters. Replaying the same key with the same body returns the first response with replayed=true instead of creating a second change. Replaying it with a different body is a 409.
Request body
The inclusive date range to replay.
fromstring (date) required- First delivery date to replay, within the last 30 days.
untilstring (date) required- Last delivery date to replay, inclusive. Today or earlier.
Responses
200- The number of deliveries re-enqueued.
400- The body, the person id, the effective date or the revision is invalid. Unknown body fields are rejected.
401- Missing, malformed or unknown token. Every authentication failure returns the same message.
403- The token does not include the scope this endpoint requires.
404- The company workspace host does not match the token's company.
409- The revision is stale, the record already exists, or the Idempotency-Key was used for a different body.
429- The per-token rate limit was exceeded. Retry after the current rolling minute. The budget is 600 read or 60 write requests per token in one rolling 1m0s.
503- The integration API is temporarily unavailable.
POST /api/v1/webhooks/{webhook}/deliveries/replay
curl -X POST "https://api.looma.hr/api/v1/webhooks/wh-201/deliveries/replay" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"from": "2026-03-01",
"until": "2026-03-01"
}'
const response = await fetch("https://api.looma.hr/api/v1/webhooks/wh-201/deliveries/replay", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
"from": "2026-03-01",
"until": "2026-03-01"
}),
});
console.log(response.status, await response.json());
import os, requests, uuid
response = requests.post(
"https://api.looma.hr/api/v1/webhooks/wh-201/deliveries/replay",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}", "Idempotency-Key": str(uuid.uuid4())},
json={
"from": "2026-03-01",
"until": "2026-03-01"
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"from": "2026-03-01",
"until": "2026-03-01"
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/webhooks/wh-201/deliveries/replay", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", rand.Text())
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"replayed": 12
}Meta
API description and discovery.
Endpoints
POST /api/v1/automation-sources/{source}/events
GET /api/v1/changelog
GET /api/v1/collections/bruno.json
GET /api/v1/collections/postman.json
GET /api/v1/openapi.jsonPOST /api/v1/automation-sources/{source}/events
Submit a typed external business event
Maximum 4096 bytes. The source must bind this bearer token and event type. An explicit employee-number/person mapping must still match the tenant record. externalEventId is idempotent per source; conflicting duplicates return 409. Accepted active subscriptions are pinned to their rule version. Revocation cancels pending deliveries. Activating a rule never replays old events. Operators can inspect deliveries and request a bounded replay through the source management API.
Scope automations.events:write
Path parameters
sourcestring required
Request body
Only these five fields are accepted.
employeeNumberstring requiredeventTypestring: service_desk_cleared, training_completed, compliance_check_completed requiredexternalEventIdstring requiredoccurredAtstring (date-time) requiredsourceRecordIdstring required
Responses
202- Persisted event receipt (id and duplicate); execution is asynchronous.
400- Invalid type, schema, timestamp or oversized body.
401- Invalid bearer credential.
403- Missing scope, revoked or unbound source, or denied employee mapping.
409- External event ID already has a different payload.
POST /api/v1/automation-sources/{source}/events
curl -X POST "https://api.looma.hr/api/v1/automation-sources/src-7/events" \
-H "Authorization: Bearer $LOOMA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"externalEventId": "string",
"eventType": "service_desk_cleared",
"sourceRecordId": "string",
"employeeNumber": "E1001",
"occurredAt": "2026-03-01T09:00:00Z"
}'
const response = await fetch("https://api.looma.hr/api/v1/automation-sources/src-7/events", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LOOMA_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"externalEventId": "string",
"eventType": "service_desk_cleared",
"sourceRecordId": "string",
"employeeNumber": "E1001",
"occurredAt": "2026-03-01T09:00:00Z"
}),
});
console.log(response.status, await response.json());
import os, requests
response = requests.post(
"https://api.looma.hr/api/v1/automation-sources/src-7/events",
headers={"Authorization": f"Bearer {os.environ['LOOMA_TOKEN']}"},
json={
"externalEventId": "string",
"eventType": "service_desk_cleared",
"sourceRecordId": "string",
"employeeNumber": "E1001",
"occurredAt": "2026-03-01T09:00:00Z"
},
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
)
func main() {
body := strings.NewReader(`{
"externalEventId": "string",
"eventType": "service_desk_cleared",
"sourceRecordId": "string",
"employeeNumber": "E1001",
"occurredAt": "2026-03-01T09:00:00Z"
}`)
req, err := http.NewRequest("POST", "https://api.looma.hr/api/v1/automation-sources/src-7/events", body)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("LOOMA_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
202 response
HTTP/1.1 202 AcceptedGET /api/v1/changelog
Published release record of this API
Returns the contract releases, newest first, with the current version, so an integration can reconcile against the X-API-Version header it was served. It carries no tenant data and needs no token.
No token
Responses
200- The published release record.
GET /api/v1/changelog
curl "https://api.looma.hr/api/v1/changelog"
const response = await fetch("https://api.looma.hr/api/v1/changelog");
console.log(response.status, await response.json());
import requests
response = requests.get(
"https://api.looma.hr/api/v1/changelog",
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/changelog", nil)
if err != nil {
panic(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
{
"apiVersion": "1.0.0",
"current": {
"version": "1.0.0",
"date": "2026-10-14",
"title": "Every response names the contract version, and the release record is published",
"body": "Every response from the versioned API now carries the X-API-Version header naming the semver contract release that produced it, and GET /api/v1/changelog publishes this release record, so an integration can see which contract era it is running against and what changed since. The first public release of the versioned API ships in this same era: scoped bearer tokens, field visibility applied per person, keyset pagination, per-token rate limiting and HMAC-signed outbound webhooks, with the write surface staging a hire, an employment, a reporting-line and a pay change under an Idempotency-Key."
},
"entries": [
{
"version": "1.0.0",
"date": "2026-10-14",
"title": "Every response names the contract version, and the release record is published",
"body": "Every response from the versioned API now carries the X-API-Version header naming the semver contract release that produced it, and GET /api/v1/changelog publishes this release record, so an integration can see which contract era it is running against and what changed since. The first public release of the versioned API ships in this same era: scoped bearer tokens, field visibility applied per person, keyset pagination, per-token rate limiting and HMAC-signed outbound webhooks, with the write surface staging a hire, an employment, a reporting-line and a pay change under an Idempotency-Key."
}
]
}GET /api/v1/collections/bruno.json
Download the Bruno request collection
The whole /api/v1 surface as a Bruno collection, generated from this document. It carries no credential: the base URL and the bearer token are collection variables the developer fills in.
No token
Responses
200- A Bruno 1.x collection document.
GET /api/v1/collections/bruno.json
curl "https://api.looma.hr/api/v1/collections/bruno.json"
const response = await fetch("https://api.looma.hr/api/v1/collections/bruno.json");
console.log(response.status, await response.json());
import requests
response = requests.get(
"https://api.looma.hr/api/v1/collections/bruno.json",
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/collections/bruno.json", nil)
if err != nil {
panic(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OK
Content-Type: application/jsonGET /api/v1/collections/postman.json
Download the Postman request collection
The whole /api/v1 surface as a Postman 2.1 collection, generated from this document with the same requests as the Bruno collection. It carries no credential.
No token
Responses
200- A Postman 2.1 collection document.
GET /api/v1/collections/postman.json
curl "https://api.looma.hr/api/v1/collections/postman.json"
const response = await fetch("https://api.looma.hr/api/v1/collections/postman.json");
console.log(response.status, await response.json());
import requests
response = requests.get(
"https://api.looma.hr/api/v1/collections/postman.json",
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/collections/postman.json", nil)
if err != nil {
panic(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OK
Content-Type: application/jsonGET /api/v1/openapi.json
OpenAPI 3.1 description of this API
Returns this document. It carries no tenant data and needs no token, so tooling and the admin developer-docs page can read the contract.
No token
Responses
200- The OpenAPI 3.1 document.
GET /api/v1/openapi.json
curl "https://api.looma.hr/api/v1/openapi.json"
const response = await fetch("https://api.looma.hr/api/v1/openapi.json");
console.log(response.status, await response.json());
import requests
response = requests.get(
"https://api.looma.hr/api/v1/openapi.json",
timeout=30,
)
print(response.status_code, response.json())
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "https://api.looma.hr/api/v1/openapi.json", nil)
if err != nil {
panic(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(data))
}
200 response
HTTP/1.1 200 OK
Content-Type: application/json