BI / Power BI
Doel: de tijdregistratiedata naar je datawarehouse brengen, of direct naar Power BI / Looker / Metabase, via incrementele synchronisatie.
Aanbevolen scopes (alleen-lezen): org:fichajes:read, org:ausencias:read, org:saldos:read, org:estructura:read.
Gebruik voor BI een aparte alleen-lezen key. Dan kun je die intrekken of roteren zonder schrijvende integraties te raken.
Patroon: incrementeel pollen met updated_since
updated_since=<ISO8601> retourneert alleen wat er sinds dat moment is gewijzigd, maar niet elk lijst-endpoint accepteert hem: employees, check-ins, absences, locations, units en webhook-endpoints wel; bij vacation-balances en work-summaries wordt hij geaccepteerd maar filtert hij niet (verouderd, verwijdering op 27-08-2027). Het patroon:
Initiële load (backfill)
Loop elke resource door en pagineer met cursor tot has_more=false. Bewaar het starttijdstip als watermark (watermark).
Incrementele loads
Vraag bij elke geplande run ?updated_since=<watermark> op en zet je watermark op het tijdstip vóór de start van de call.
Dedupliceer op id
Omdat updated_since op updated_at is gebaseerd, kan hetzelfde record opnieuw verschijnen als het is gewijzigd. Doe een upsert op id (UUID) in je opslag.
Laad de aggregaties per periode opnieuw
vacation-balances en work-summaries filteren niet op updated_since: het zijn aggregaties, er valt geen delta op te vragen. Vraag elke ronde de periode opnieuw op — year= bij saldi, from/to bij summaries — en doe een upsert op de echte sleutel van elke resource: (employee_id, period_start, period_end) bij work-summaries en (employee_id, year) bij vacation-balances. Voor een maandafsluiting volstaat het om de lopende en de vorige maand opnieuw te laden.
Voorbeeld in Python
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) # begin van de historie die je nodig hebt
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 VÓÓR het aanroepen, zodat records die tijdens de sync worden geschreven niet wegvallen
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)
# Het venster `from`/`to` bepaalt WELKE events je wilt (de `timestamp` van de check-in).
# `updated_since` bepaalt WELKE WIJZIGINGEN je ophaalt (`updated_at`): stukken zonder wijziging komen leeg terug.
# Twee verschillende assen, en daarom begint de periode altijd bij SYNC_START: een correctie van
# vandaag op een check-in uit januari komt alleen binnen als het januari-stuk nog in het venster zit.
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)
# Aggregaties: zonder `updated_since` wordt de hele periode opnieuw geladen. Begin op de
# eerste dag van de VORIGE MAAND (in januari dus in het vorige jaar) om late
# consolidaties van de vorige afsluiting mee te nemen.
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)Neem de watermark voordat je de synchronisatie start, niet erna. Zo komen records die tijdens de run zijn geschreven in de volgende run mee.
Nuttige resources voor BI
| Resource | Levert |
|---|---|
work-summaries | Werktijdmetrics klaar voor aggregatie (uren, overuren, nachturen). updated_since filtert hier niet (verouderd): opnieuw laden per periode. |
check-ins | Data op eventniveau voor analyse van aanwezigheid en stiptheid. |
absences | Verzuim per type en periode. |
vacation-balances | Verlofsaldi en -reserveringen. updated_since filtert hier niet (verouderd): opnieuw laden per year. |
locations / units | Dimensies om op te segmenteren (locatie, afdeling). |
Verbinden vanuit Power BI
Power BI kan de API direct aanspreken met Web.Contents en een autorisatieheader. Vereenvoudigd voorbeeld in Power Query (M):
let
BaseUrl = "https://api.kinmu.app/v1",
ApiKey = "kinmu_sk_live_…", // usa Parámetros / almacén de credenciales, no lo escribas en claro
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
TableOm in Power Query te pagineren, wikkel je de call in een functie die meta.next_cursor volgt met List.Generate tot has_more false is.
Respecteer de rate limits: plan de refresh voor grote volumes buiten piekuren en houd X-Kinmu-Quota-Remaining in de gaten.
Event-driven alternatief
Wil je liever niet pollen, abonneer je dan op webhooks (checkin.created, absence.approved, vacation_balance.updated, …) en werk je opslag bij zodra elk event binnenkomt. De combinatie werkt goed: webhooks voor realtime + een nachtelijke polling-run met updated_since als vangnet.