LoomaLoomaAPI OpenAPI

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
  1. A company administrator creates an API token in Settings → Configuration → Integrations → API tokens, with only the scopes the integration needs.
  2. Keep the token in a secret store or an environment variable, never in source control.
  3. 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.

ScopeGrants
people:readRead employee directory records and a single employee record.
org:readRead legal entities, workforce catalogues and current reporting lines.
timeoff:readRead approved time-off requests.
timecards:readRead approved timecards and their totals.
people:writeCreate a person and submit effective-dated job, assignment and employment changes. Every change runs the company's configured approval routing.
org:writeSubmit effective-dated reporting-line changes. Every change runs the company's configured manager-change approval routing.
compensation:writeSubmit effective-dated pay changes for a person. HR and administrator tokens only. Every change runs the company's configured pay approval routing.
webhooks:writeReplay webhook deliveries from the delivery log. Administrator tokens only.
assistant:readUse 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:readRead tenant headcount and workforce trends.
benefits:readRead benefit plans and enrolments. Enrolment details are limited to HR and administrators, or an employee's own record.
automations.events:writeSubmit typed business events to explicitly registered sources.
compensation:readRead a person's effective-dated pay history and current pay. HR and administrator tokens only. Amounts honour the integration field-visibility policy.
documents:readRead employee document metadata. Employee fields pass the integration field-visibility policy.
expenses:receiveDeliver receipt attachments to an employee's enabled routing token as private proposals. Never submits claims.
expenses:readRead expense claims. The claimant's name passes the field-visibility resolver under the integration viewer role.
expenses:writeSubmit a new expense claim for the token's linked employee through the company's configured EXPENSE_CLAIM approval flow.
lifecycle:readRead a person's hire, employment change, leave and termination history.
objectives:readRead current-cycle objectives within the token owner's visibility.
payroll:readRead 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:readRead 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:readRead jobs, candidates and applications. Candidate personal fields pass the integration field-visibility policy.
mcp:readCall 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:writeDraft 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:writeSave the token owner's private 1:1 notes through the one-on-one module.
feedback:writeGive 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:writeSubmit 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.0

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

HeaderDescription
Retry-AfterFor a throttled (429) response, the number of seconds until the next rolling minute starts. Omitted otherwise.
X-API-VersionThe semver release of this API contract that produced this response, so an integration can name the era it is running against.
X-RateLimit-LimitThe per-token request budget for this surface in one rolling minute: 600 for a read and 60 for a write.
X-RateLimit-RemainingThe 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": true instead 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.

ParameterTypeDescription
cursorstringOpaque keyset cursor from a previous response's nextCursor.
limitintegerMaximum number of items to return. Values above the maximum are clamped.
pageintegerThe 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.
fieldsstringComma-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.
includestringComma-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.
filterstringComma-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.
sortstringComma-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.

  1. Start without a checkpoint and read until hasMore is false, storing each page and its checkpoint together.
  2. Poll with the last checkpoint for changes. Apply items in order, replacing each record by id; action: "remove" means delete it.
  3. A checkpoint expires after seven days. An expired or invalid one is 410 with resync_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.

EventSent when
employee.createdA hire transaction completed and created an employee record.
employee.updatedAn approved change updated employment, reporting, pay or working time.
employee.terminatedAn approved end-of-employment transaction completed.
leave.approvedA leave request was approved and booked.
transaction.decidedAny transaction reached a final approved or declined decision.
candidate.statusA candidate moved to a new pipeline stage.
audit.eventEvery 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.approvedA payroll run reached the approved state after HR sign-off.
expense.submittedA 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 2xx within 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.

  1. Open Settings → Configuration → Integrations → SCIM and copy the base URL.
  2. Create a SCIM token and copy its secret. It starts with looma_scim_, is shown once and carries only the scim:provision scope. SCIM tokens are separate from API tokens.
  3. Optionally map identity-provider groups to the Looma roles hr, manager or employee. 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/v2

Create 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

  1. 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.
  2. 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.
  3. Under Mappings → Provision Microsoft Entra ID Users, map the attribute that holds the work email (userPrincipalName or mail) to userName. Map employeeId to externalId only if it holds the Looma employee number.
  4. Assign the users or groups to provision, start provisioning, and check both the Entra provisioning logs and the Looma sync log.

Okta

  1. 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.
  2. 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.
  3. Under Provisioning → To App, enable Create Users, Update User Attributes and Deactivate Users. Make sure the Okta username is the work email.
  4. 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.

  1. Open Settings → Integrations → SFTP and add a connection: host, port, user, and a password or an OpenSSH private key.
  2. Enter the server's host key fingerprint, in the SHA256:… form ssh-keygen -lf prints or as hex. Looma checks it on every connection and refuses a mismatch.
  3. Optionally add a PGP public key to encrypt outbound files, and a PGP private key to decrypt inbound ones.
  4. 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-13

Jobs 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>.part and 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 address

SDK

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))
}

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/reviews

GET /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 OK

GET /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 OK

GET /api/v1/benefits

List benefit plans

Scope benefits:read

Query parameters

limit integer
cursor string

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 OK

GET /api/v1/benefits/{benefit}/participants

List benefit participants

Scope benefits:read

Path parameters

benefit string required

Query parameters

limit integer
cursor string

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 OK

GET /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

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor from a previous response's nextCursor. Do not construct or parse it.
includeFormer boolean
Include people whose employment has ended.
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.
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.

Response fields EmployeePage

hasMore boolean required
items array of Employee required
items[].accountId string
items[].businessTitle string
items[].countryCode string
items[].department string required
items[].employeeNumber string required
items[].employmentId string
items[].firstName string required
items[].hasPhoto boolean
items[].hiddenFields array of string
items[].id string required
items[].jobTitle string required
items[].lastName string required
items[].legalEntity string required
items[].lifecycle string
items[].location string required
items[].revision integer required
items[].startDate string (date) required
items[].status string required
items[].workEmail string required
items[].workFromHome boolean or null
items[].workerType string
nextCursor string

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

businessTitle string
costCenter string
countryCode string
ISO 3166-1 alpha-2 country code.
department string required
effectiveFrom string (date) required
The employment's start date. The hire runs the company's configured hire approval flow.
employeeNumber string required
The employee's number, unique in the company.
firstName string required
grade string
initialCompensation CompensationChangeRequest
initialCompensation.amount string required
An exact decimal string, for example 62500.00. It is stored with its exact precision.
initialCompensation.currency string required
ISO 4217 currency code.
initialCompensation.effectiveFrom string (date) required
initialCompensation.employmentId string
initialCompensation.payBasis string: annual, monthly, hourly required
initialCompensation.revision integer required
jobTitle string required
lastName string required
legalEntityId string required
location string required
workEmail string (email) required
workFromHome boolean
workerType string: employee, manager, contractor

Response fields WriteResult

personId string
replayed boolean
status string required
transactionId integer 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

checkpoint string
Opaque checkpoint returned by the previous page; omit only for a fresh bootstrap.
limit integer
1–200 identities per page, default 50.
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.
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.

Response fields

checkpoint string required
hasMore boolean required
items array of object required
items[].action string required
items[].id string required
items[].person Employee
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.accountId string
items[].person.businessTitle string
items[].person.countryCode string
items[].person.department string required
items[].person.employeeNumber string required
items[].person.employmentId string
items[].person.firstName string required
items[].person.hasPhoto boolean
items[].person.hiddenFields array of string
items[].person.id string required
items[].person.jobTitle string required
items[].person.lastName string required
items[].person.legalEntity string required
items[].person.lifecycle string
items[].person.location string required
items[].person.revision integer required
items[].person.startDate string (date) required
items[].person.status string required
items[].person.workEmail string required
items[].person.workFromHome boolean or null
items[].person.workerType string
phase string 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

person string required
The person id.

Query parameters

on string (date)
Effective date (YYYY-MM-DD). Defaults to today (UTC).
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.

Response fields Employee

accountId string
businessTitle string
countryCode string
department string required
employeeNumber string required
employmentId string
firstName string required
hasPhoto boolean
hiddenFields array of string
id string required
jobTitle string required
lastName string required
legalEntity string required
lifecycle string
location string required
revision integer required
startDate string (date) required
status string required
workEmail string required
workFromHome boolean or null
workerType string

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

person string required

Query parameters

limit integer
cursor string

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 OK

GET /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

person string required
The person id.

Query parameters

on string (date)
Effective date (YYYY-MM-DD). Defaults to today (UTC).
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.

Response fields CompensationRecord

current object or null required
employmentId string
hiddenFields array of string
history array of object
history[].amount string required
history[].currency string required
history[].effectiveFrom string (date) required
history[].employmentId string
history[].payBasis string required
history[].revision integer required
market object or null
oneOffPayments array of object
oneOffPayments[].amount string required
oneOffPayments[].approvedAt object or null
oneOffPayments[].createdAt object required
oneOffPayments[].currency string required
oneOffPayments[].employeeNumber string
oneOffPayments[].hiddenFields array of string
oneOffPayments[].id integer required
oneOffPayments[].kind string required
oneOffPayments[].payDate string required
oneOffPayments[].payHidden boolean
oneOffPayments[].personId string required
oneOffPayments[].personName string
oneOffPayments[].reason string
oneOffPayments[].requestedBy string
oneOffPayments[].revision integer required
oneOffPayments[].status string required
oneOffPayments[].title string required
oneOffPayments[].transactionId integer or null
oneOffPayments[].updatedAt object required
personId string required
priorPayChangePercent string
promotionLinked boolean required
revision integer required
totalCash object or null
zone object 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

person string required
The person id.

Headers

Idempotency-Key string
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.

amount string required
An exact decimal string, for example 62500.00. It is stored with its exact precision.
currency string required
ISO 4217 currency code.
effectiveFrom string (date) required
employmentId string
payBasis string: annual, monthly, hourly required
revision integer required

Response fields WriteResult

personId string
replayed boolean
status string required
transactionId integer 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

person string required
The person id.

Headers

Idempotency-Key string
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.

businessTitle string
costCenter string
countryCode string
department string required
effectiveFrom string (date) required
The date this change takes effect. A past date corrects history; a future date schedules it.
employmentId string
The employment to change. Omit when the person has exactly one.
grade string
jobTitle string required
legalEntityId string required
location string required
revision integer required
The revision the caller last read. A stale revision is a conflict, never a silent overwrite.
workFromHome boolean
workerType string: employee, manager, contractor

Response fields WriteResult

personId string
replayed boolean
status string required
transactionId integer 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

person string required

Query parameters

limit integer
cursor string

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 OK

GET /api/v1/people/{person}/reviews

List a person's review assignments

Scope performance:read

Path parameters

person string required

Query parameters

limit integer
Page size (1–200; default 50).
cursor string
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 OK

GET /api/v1/performance/reviews

List review cycles

Scope performance:read

Query parameters

limit integer
Page size (1–200; default 50).
cursor string
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 OK

Endpoints

GET   /api/v1/employees/{person}/goals
GET   /api/v1/employees/{person}/objectives
GET   /api/v1/goals
GET   /api/v1/objectives

GET /api/v1/employees/{person}/goals

List an employee's visible current goals

Scope objectives:read

Path parameters

person string required

Query parameters

limit integer
Page size (1–200; default 50).
cursor string
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 OK

GET /api/v1/employees/{person}/objectives

List an employee's current-cycle objectives

Scope objectives:read

Path parameters

person string required

Query parameters

limit integer
Page size (1–200; default 50).
cursor string
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 OK

GET /api/v1/goals

List visible current goals

Scope objectives:read

Query parameters

limit integer
Page size (1–200; default 50).
cursor string
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 OK

GET /api/v1/objectives

List current-cycle objectives

Scope objectives:read

Query parameters

limit integer
Page size (1–200; default 50).
cursor string
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 OK

Endpoints

GET   /api/v1/organisation
POST  /api/v1/people/{person}/manager
GET   /api/v1/positions
GET   /api/v1/teams

GET /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

cursor string
Opaque reporting-line cursor: the last person id from the previous response. Omit for the first 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.

Response fields OrganisationResponse

catalogue array of OrganisationCatalogueEntry required
catalogue[].code string required
catalogue[].id string required
catalogue[].kind string required
catalogue[].name string required
legalEntities array of LegalEntity required
legalEntities[].id string required
legalEntities[].name string required
reportingLines array of OrganisationReportingLine required
reportingLines[].effectiveFrom string (date) required
reportingLines[].managerId string
reportingLines[].personId string 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

person string required
The person id.

Headers

Idempotency-Key string
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.

effectiveFrom string (date) required
expectedRevision integer required
The reporting-line revision the caller last read.
managerId string required
The new line manager's person id. An empty string removes the line manager.

Response fields WriteResult

personId string
replayed boolean
status string required
transactionId integer 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

limit integer
Page size (1–200; default 50).
cursor string
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 OK

GET /api/v1/teams

List organisation teams

Scope org:read

Query parameters

limit integer
Page size (1–200; default 50).
cursor string
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 OK

Time off

Approved leave requests.

Endpoints

GET   /api/v1/time-off

GET /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

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor (transaction id) from a previous response's nextCursor.
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.
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.

Response fields TimeOffPage

hasMore boolean required
items array of TimeOffRequest required
items[].endDate string (date) required
items[].halfDay boolean required
items[].personId string required
items[].planId string required
items[].startDate string (date) required
items[].transactionId integer required
items[].units integer required
nextCursor string

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/timecards

GET /api/v1/timecards

List approved timecards

Approved timecards, newest first by id. Pass nextCursor back as cursor unchanged.

Scope timecards:read

Query parameters

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor (timecard id) from a previous response's nextCursor.
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.
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.

Response fields TimecardPage

hasMore boolean required
items array of Timecard required
items[].id integer required
items[].periodEnd string (date) required
items[].periodStart string (date) required
items[].personId string required
items[].status string required
items[].totalMinutes integer required
items[].transactionId integer or null
nextCursor string

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}/payslips

GET /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

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor (run id) from a previous response's nextCursor.
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.
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.

Response fields PayrollInputPage

hasMore boolean required
items array of PayrollInput required
items[].flexAmount string
items[].leaveUnits string
items[].redacted boolean
items[].runId integer required
items[].timecardMinutes string
nextCursor string

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

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor (run id) from a previous response's nextCursor.
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.
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.

Response fields PayrollRunPage

hasMore boolean required
items array of PayrollRun required
items[].approvedAt string
items[].id integer required
items[].legalEntityId string required
items[].legalEntityName string
items[].lineCount integer required
items[].periodEnd string (date) required
items[].periodId integer required
items[].periodStart string (date) required
items[].revision integer required
items[].runKind string
items[].status string required
items[].totalGross string
items[].totalPayout string
items[].totalsRedacted boolean
nextCursor string

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

run integer required
The payroll run id.

Query parameters

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor (payslip sequence) from a previous response's nextCursor.
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.
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.

Response fields PayslipPage

hasMore boolean required
items array of Payslip required
items[].byteSize integer required
items[].filename string required
items[].id string required
items[].personId string required
items[].publishedAt string
items[].status string required
items[].version integer required
nextCursor string

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"
}

Endpoints

GET   /api/v1/applications
GET   /api/v1/candidates
GET   /api/v1/jobs

GET /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

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor from a previous response's nextCursor.
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.
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.

Response fields ApplicationPage

hasMore boolean required
items array of Application required
items[].coverNote string
items[].createdAt string required
items[].id integer required
items[].personId string required
items[].status string required
items[].updatedAt string required
items[].vacancyId integer required
nextCursor string

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

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor from a previous response's nextCursor.
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.
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.

Response fields CandidatePage

hasMore boolean required
items array of Candidate required
items[].createdAt string required
items[].email string required
items[].firstName string required
items[].hiddenFields array of string
items[].id integer required
items[].lastName string required
items[].notes string
items[].phone string
items[].source string
items[].stage string required
items[].updatedAt string required
nextCursor string

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

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor from a previous response's nextCursor.
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.
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.

Response fields RecruitingJobPage

hasMore boolean required
items array of RecruitingJob required
items[].closingDate string
items[].createdAt string required
items[].department string
items[].description string
items[].id integer required
items[].openingDate string required
items[].status string required
items[].title string required
items[].updatedAt string required
nextCursor string

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"
}

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.

content string (byte) required
Base64-encoded PDF, PNG or JPEG attachment, at most 10 MiB after decoding.
deliveryKey string required
Stable key per attachment, 1 to 200 characters. Retries return the original intake id.
filename string required
Attachment filename, 1 to 180 characters, without path separators.
routingToken string required
The employee's enabled 64-character hexadecimal recipient capability.

Response fields

intakeId string required
status string: 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

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor (claim id) from a previous response's nextCursor.
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.
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.

Response fields ExpenseClaimPage

hasMore boolean required
items array of ExpenseClaim required
items[].createdAt string required
items[].employeeNumber string
items[].hiddenFields array of string
items[].id integer required
items[].lineCount integer required
items[].personId string required
items[].personName string
items[].revision integer required
items[].status string required
items[].title string required
items[].totals array of object required
items[].totals[].currency string required
items[].totals[].total string required
items[].updatedAt string required
nextCursor string

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

lines array of object required
At least one line item (up to 50).
lines[].amount string required
The exact amount, up to four decimal digits.
lines[].categoryId integer required
lines[].currency string required
ISO 4217 three-letter currency code.
lines[].description string
lines[].expenseDate string (date) required
lines[].merchant string required
title string required
The claim's title, 1 to 160 characters.

Response fields WriteResult

personId string
replayed boolean
status string required
transactionId integer 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

claim integer required
The claim id.

Query parameters

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.

Response fields ExpenseClaimDetail

claim ExpenseClaim 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.createdAt string required
claim.employeeNumber string
claim.hiddenFields array of string
claim.id integer required
claim.lineCount integer required
claim.personId string required
claim.personName string
claim.revision integer required
claim.status string required
claim.title string required
claim.totals array of object required
claim.totals[].currency string required
claim.totals[].total string required
claim.updatedAt string required
events array of object
events[].actorId string
events[].body string
events[].createdAt string required
events[].id integer required
events[].kind string required
lines array of object required
lines[].amount string required
lines[].category string
lines[].categoryId integer required
lines[].currency string required
lines[].description string
lines[].expenseDate string required
lines[].id integer required
lines[].merchant string required
totals array of object required
totals[].currency string required
totals[].total string required
transactionId integer 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"
  }
}

Documents

Employee document metadata.

Endpoints

GET   /api/v1/people/{person}/documents

GET /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

person string required
The person id.

Query parameters

limit integer
Maximum number of items to return. Values above the maximum are clamped.
cursor string
Opaque keyset cursor (document id) from a previous response's nextCursor.
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.
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.

Response fields EmployeeDocumentPage

hasMore boolean required
items array of EmployeeDocument required
items[].archived boolean required
items[].byteSize integer required
items[].category string required
items[].contentType string required
items[].createdAt string required
items[].employeeVisible boolean required
items[].filename string required
items[].hiddenFields array of string
items[].id string required
items[].personId string required
items[].revision integer required
items[].title string required
items[].uploadedBy string required
nextCursor string

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/replay

POST /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

webhook string required
The webhook endpoint id.

Headers

Idempotency-Key string
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.

from string (date) required
First delivery date to replay, within the last 30 days.
until string (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
}

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

POST /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

source string required

Request body

Only these five fields are accepted.

employeeNumber string required
eventType string: service_desk_cleared, training_completed, compliance_check_completed required
externalEventId string required
occurredAt string (date-time) required
sourceRecordId string 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 Accepted

GET /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/json

GET /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/json

GET /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