Skip to Content
Use-case guidesBI / Power BI

BI / Power BI

Goal: bring time tracking data into your data warehouse or directly into Power BI / Looker / Metabase through incremental sync.

Recommended scopes (read-only): org:fichajes:read, org:ausencias:read, org:saldos:read, org:estructura:read.

For BI use a dedicated read-only key. That way you can revoke or rotate it without affecting write integrations.

Pattern: incremental polling with updated_since

updated_since=<ISO8601> returns only what has changed since that instant, but not every listing accepts it: employees, check-ins, absences, locations, units and webhook-endpoints do; on vacation-balances and work-summaries it is accepted but does not filter (deprecated, removal on 2027-08-27). The pattern:

Initial load (backfill)

Walk each resource paginating by cursor until has_more=false. Store the start instant as a watermark (watermark).

Incremental loads

On each scheduled run, request ?updated_since=<watermark> and update your watermark to the instant just before the call started.

Deduplicate by id

Since updated_since is based on updated_at, the same record may reappear if it changed. Do an upsert by id (UUID) in your store.

Reload the aggregates by range

vacation-balances and work-summaries do not filter by updated_since: they are aggregates, there is no delta to ask for. Re-query the range you care about on every pass — year= for balances, from/to for summaries — and upsert by each resource’s real key: (employee_id, period_start, period_end) for work-summaries and (employee_id, year) for vacation-balances. For a monthly close, reloading the current and the previous month is enough.

Python example

import os, requests from datetime import date, datetime, timedelta, timezone BASE = os.environ["KINMU_BASE_URL"] KEY = os.environ["KINMU_API_KEY"] SYNC_START = date(2026, 1, 1) # where the history you care about starts session = requests.Session() session.headers.update({"Authorization": f"Bearer {KEY}"}) def sync(resource, since=None): rows, cursor = [], None params = {"limit": 100} if since: params["updated_since"] = since while True: if cursor: params["cursor"] = cursor r = session.get(f"{BASE}/{resource}", params=params, timeout=30) r.raise_for_status() body = r.json() rows.extend(body["data"]) if not body["meta"]["has_more"]: break cursor = body["meta"]["next_cursor"] return rows def first_day_of_previous_month(day): first = day.replace(day=1) return (first - timedelta(days=1)).replace(day=1) # Watermark BEFORE calling, so you don't miss records written during the sync watermark = datetime.now(timezone.utc).isoformat() today = datetime.now(timezone.utc).date() last_watermark = load_last_watermark() # ISO 8601 | None employees = sync("employees", since=last_watermark) # The `from`/`to` window bounds WHICH events you care about (the check-in `timestamp`). # `updated_since` bounds WHICH CHANGES to fetch (`updated_at`): unchanged chunks come back empty. # They are different axes, which is why the range always starts at SYNC_START: a correction # made today to a January check-in only arrives if January's chunk is still in the window. checkins = [] chunk_from = SYNC_START while chunk_from <= today: chunk_to = min(chunk_from + timedelta(days=91), today) checkins += sync(f"check-ins?from={chunk_from}&to={chunk_to}", since=last_watermark) chunk_from = chunk_to + timedelta(days=1) # Aggregates: with no `updated_since`, the whole range is reloaded. Start on the first # day of the PREVIOUS MONTH (in January that crosses into last year) to pick up late # consolidations of the previous close. previous_month = first_day_of_previous_month(today) balances = [] for year in sorted({previous_month.year, today.year}): balances += sync(f"vacation-balances?year={year}") summaries = sync(f"work-summaries?period=month&from={previous_month}&to={today}") save_watermark(watermark)

Take the watermark before starting the sync, not after. That way the records written while the process was running are picked up in the next pass.

Useful resources for BI

ResourceProvides
work-summariesWorkday metrics ready for aggregation (hours, overtime, night). updated_since does not filter here (deprecated): reload by range.
check-insEvent-level grain for presence and punctuality analysis.
absencesAbsenteeism by type and period.
vacation-balancesVacation balances and provisions. updated_since does not filter here (deprecated): reload by year.
locations / unitsDimensions to segment by (site, department).

Connecting from Power BI

Power BI can consume the API directly with Web.Contents and an authorization header. Simplified example in Power Query (M):

let BaseUrl = "https://api.kinmu.app/v1", ApiKey = "kinmu_sk_live_…", // use Parameters / credential store, don't write it in plaintext Source = Json.Document( Web.Contents(BaseUrl, [ RelativePath = "work-summaries", Query = [ period = "month", from = "2026-01-01", #"to" = "2026-12-31", limit = "100" ], Headers = [ Authorization = "Bearer " & ApiKey, Accept = "application/json" ] ]) ), Data = Source[data], Table = Table.FromRecords(Data) in Table

To paginate in Power Query, wrap the call in a function that follows meta.next_cursor with List.Generate until has_more is false.

Respect the rate limits: for large volumes, schedule the refresh outside peak hours and watch X-Kinmu-Quota-Remaining.

Event-driven alternative

If you prefer not to poll, subscribe to webhooks (checkin.created, absence.approved, vacation_balance.updated, …) and update your store as each event arrives. They combine well: webhooks for real time + a nightly poll with updated_since as a safety net.

Last updated on