BI / Power BI
Obiettivo: portare i dati di rilevazione presenze nel tuo data warehouse o direttamente in Power BI / Looker / Metabase con la sincronizzazione incrementale.
Scopes consigliati (sola lettura): org:fichajes:read, org:ausencias:read, org:saldos:read, org:estructura:read.
Per la BI usa una chiave dedicata di sola lettura. Così puoi revocarla o ruotarla senza toccare le integrazioni di scrittura.
Pattern: polling incrementale con updated_since
updated_since=<ISO8601> restituisce solo ciò che è stato modificato da quell’istante, ma non tutti gli elenchi lo accettano: sì employees, check-ins, absences, locations, units e webhook-endpoints; su vacation-balances e work-summaries è accettato ma non filtra (obsoleto, rimozione il 27-08-2027). Il pattern:
Caricamento iniziale (backfill)
Percorri ogni risorsa paginando per cursor fino a has_more=false. Salva l’istante di inizio come marca temporale (watermark).
Caricamenti incrementali
A ogni esecuzione pianificata, richiedi ?updated_since=<watermark> e aggiorna la marca temporale all’istante precedente all’inizio della chiamata.
Deduplica per id
Poiché updated_since si basa su updated_at, uno stesso record può ricomparire se è cambiato. Fai upsert per id (UUID) nel tuo storage.
Ricarica gli aggregati per intervallo
vacation-balances e work-summaries non filtrano per updated_since: sono aggregati, non c’è un delta da chiedere. A ogni passata richiedi di nuovo l’intervallo che ti serve — year= per i saldi, from/to per i riepiloghi — e fai upsert sulla chiave reale di ciascuna risorsa: (employee_id, period_start, period_end) per work-summaries e (employee_id, year) per vacation-balances. Per una chiusura mensile basta ricaricare il mese in corso e quello precedente.
Esempio 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) # inizio dello storico che ti interessa
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)
# Marca temporale PRIMA di chiamare, per non perdere i record scritti durante la 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)
# La finestra `from`/`to` delimita QUALI eventi ti interessano (il `timestamp` della timbratura).
# `updated_since` delimita QUALI MODIFICHE portare (`updated_at`): le tranche senza modifiche tornano vuote.
# Sono due assi diversi: per questo l'intervallo parte sempre da SYNC_START, dato che una correzione
# fatta oggi su una timbratura di gennaio arriva solo se la tranche di gennaio è ancora nella finestra.
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)
# Aggregati: senza `updated_since` si ricarica l'intero intervallo. Si parte dal primo
# giorno del MESE PRECEDENTE (a gennaio si passa all'anno scorso) per raccogliere le
# consolidazioni tardive della chiusura precedente.
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)Prendi la marca temporale prima di avviare la sincronizzazione, non dopo. Così i record scritti mentre il processo era in esecuzione entrano nella passata successiva.
Risorse utili per la BI
| Risorsa | Cosa offre |
|---|---|
work-summaries | Metriche di giornata pronte per l’aggregazione (ore, straordinari, notturne). updated_since qui non filtra (obsoleto): ricarica per intervallo. |
check-ins | Grana a livello di evento per analisi di presenza e puntualità. |
absences | Assenteismo per tipo e periodo. |
vacation-balances | Saldi e accantonamenti ferie. updated_since qui non filtra (obsoleto): ricarica per year. |
locations / units | Dimensioni per segmentare (sede, reparto). |
Connessione da Power BI
Power BI può consumare l’API direttamente con Web.Contents e header di autorizzazione. Esempio semplificato 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
TablePer paginare in Power Query, avvolgi la chiamata in una funzione che segua meta.next_cursor con List.Generate finché has_more è false.
Rispetta i rate limit: per grandi volumi, pianifica l’aggiornamento fuori dalle ore di punta e tieni d’occhio X-Kinmu-Quota-Remaining.
Alternativa event-driven
Se preferisci non fare polling, sottoscrivi i webhooks (checkin.created, absence.approved, vacation_balance.updated, …) e aggiorna il tuo storage a ogni evento ricevuto. Le due strade si combinano bene: webhooks per il tempo reale + un polling notturno con updated_since come rete di sicurezza.