BI / Power BI
Objetivo: llevar los datos de control horario a tu data warehouse o directamente a Power BI / Looker / Metabase mediante sincronización incremental.
Scopes recomendados (solo lectura): org:fichajes:read, org:ausencias:read, org:saldos:read, org:estructura:read.
Para BI usa una key dedicada de solo lectura. Así puedes revocarla o rotarla sin afectar a integraciones de escritura.
Patrón: polling incremental con updated_since
updated_since=<ISO8601> devuelve solo lo modificado desde ese instante, pero no lo aceptan todos los listados: sí employees, check-ins, absences, locations, units y webhook-endpoints; en vacation-balances y work-summaries se acepta pero no filtra (obsoleto, se retira el 27-08-2027). El patrón:
Carga inicial (backfill)
Recorre cada recurso paginando por cursor hasta has_more=false. Guarda el instante de inicio como marca de agua (watermark).
Cargas incrementales
En cada ejecución programada, pide ?updated_since=<watermark> y actualiza tu marca de agua al instante anterior al inicio de la llamada.
Deduplica por id
Como updated_since se basa en updated_at, un mismo registro puede volver a aparecer si cambió. Haz upsert por id (UUID) en tu almacén.
Recarga los agregados por rango
vacation-balances y work-summaries no filtran por updated_since: son agregados, no hay delta que pedir. Vuelve a consultar el rango que te interesa en cada pasada —year= en saldos, from/to en resúmenes— y haz upsert por la clave real de cada recurso: (employee_id, period_start, period_end) en work-summaries y (employee_id, year) en vacation-balances. Para un cierre mensual basta con recargar el mes en curso y el anterior.
Ejemplo en 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) # inicio del histórico que te interesa
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 de agua ANTES de llamar, para no perder registros escritos 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 ventana `from`/`to` delimita QUÉ eventos te interesan (el `timestamp` del fichaje).
# `updated_since` delimita QUÉ CAMBIOS traer (el `updated_at`): los tramos sin cambios vuelven vacíos.
# Son ejes distintos, por eso el rango arranca siempre en SYNC_START: una corrección de hoy
# sobre un fichaje de enero solo llega si el tramo de enero sigue dentro de la ventana.
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)
# Agregados: sin `updated_since` se recarga el rango entero. Empieza en el primer día
# del MES ANTERIOR (en enero cruza al año pasado) para recoger las consolidaciones
# tardías del cierre previo.
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)Toma la marca de agua antes de empezar la sincronización, no después. Así los registros escritos mientras corría el proceso entran en la siguiente pasada.
Recursos útiles para BI
| Recurso | Aporta |
|---|---|
work-summaries | Métricas de jornada listas para agregación (horas, extra, nocturnas). updated_since no filtra aquí (obsoleto): recarga por rango. |
check-ins | Grano de evento para análisis de presencia y puntualidad. |
absences | Absentismo por tipo y período. |
vacation-balances | Saldos y provisiones de vacaciones. updated_since no filtra aquí (obsoleto): recarga por year. |
locations / units | Dimensiones para segmentar (centro, departamento). |
Conexión desde Power BI
Power BI puede consumir la API directamente con Web.Contents y cabecera de autorización. Ejemplo simplificado en 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
TablePara paginar en Power Query, envuelve la llamada en una función que siga meta.next_cursor con List.Generate hasta que has_more sea false.
Respeta los límites de tasa: para volúmenes grandes, programa la actualización fuera de horas punta y vigila X-Kinmu-Quota-Remaining.
Alternativa event-driven
Si prefieres no hacer polling, suscríbete a webhooks (checkin.created, absence.approved, vacation_balance.updated, …) y actualiza tu almacén al recibir cada evento. Combina bien: webhooks para tiempo real + un polling nocturno con updated_since como red de seguridad.