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
| Resource | Provides |
|---|---|
work-summaries | Workday metrics ready for aggregation (hours, overtime, night). updated_since does not filter here (deprecated): reload by range. |
check-ins | Event-level grain for presence and punctuality analysis. |
absences | Absenteeism by type and period. |
vacation-balances | Vacation balances and provisions. updated_since does not filter here (deprecated): reload by year. |
locations / units | Dimensions 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
TableTo 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.