API conventions
Rules that apply across all v1 resources.
| Topic | Rule |
|---|---|
| Format | JSON UTF-8. Requests with a body carry Content-Type: application/json. |
| IDs | Public UUIDs. Internal numeric IDs are never exposed. |
| Dates | ISO 8601. Instants in UTC (2026-07-08T09:30:00Z); calendar dates YYYY-MM-DD. |
| Base URL | Versioned in the path: https://api.kinmu.app/v1 · Dev: https://api.dev.kinmu.app/v1. |
Pagination (cursor)
All listings paginate by cursor:
?limit=(default 25, max 100) ·?cursor=<opaque>.- Response:
{ "data": [...], "meta": { "next_cursor": "…"|null, "has_more": true|false } }. - For the next page, resend
cursor=<meta.next_cursor>.has_more=falseornext_cursor=null→ end.
curl "https://api.kinmu.app/v1/employees?limit=50&cursor=eyJpZCI6MTIzfQ" \
-H "Authorization: Bearer kinmu_sk_live_…"The cursor is opaque: don’t build it or parse it, resend it as-is.
Incremental sync (updated_since)
The ?updated_since=<ISO 8601> parameter fetches only what changed since that instant (incremental polling for BI and ERPs). It is not universal: it only filters on these listings.
| Listing | Filters by updated_since? |
|---|---|
GET /v1/employees | Yes |
GET /v1/check-ins | Yes |
GET /v1/absences | Yes |
GET /v1/locations | Yes |
GET /v1/units | Yes |
GET /v1/webhook-endpoints | Yes |
GET /v1/vacation-balances | No — accepted but ignored; deprecated, removal on 2027-08-27 |
GET /v1/work-summaries | No — accepted but ignored; deprecated, removal on 2027-08-27 |
curl "https://api.kinmu.app/v1/employees?updated_since=2026-07-01T00:00:00Z" \
-H "Authorization: Bearer kinmu_sk_live_…"Store the instant of your last sync and use it as updated_since on the next one. See the full pattern in the BI guide.
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 (year= for balances, from/to for summaries) or reload it whole on every pass, and upsert by key in your store. The parameter is still accepted on those two endpoints for compatibility with already-generated SDKs, but it filters nothing: it is marked deprecated and will be removed on 2027-08-27.
Errors
All errors are RFC 9457 (application/problem+json):
{
"type": "https://docs.kinmu.app/errors/invalid_scope",
"title": "Insufficient scope",
"status": 403,
"code": "invalid_scope",
"detail": "The API key does not have the required scope: org:empleados:read.",
"errors": { "required_scope": "org:empleados:read" }
}Program against the code field (stable), not against title/detail.
Code table
code | HTTP | When |
|---|---|---|
unauthenticated | 401 | Missing key, or the key is invalid, revoked or expired. |
invalid_scope | 403 | The key lacks the required scope (errors.required_scope). |
subscription_inactive | 403 | The company has no active service: suspended or archived, no subscription, suspended subscription, expired trial, or a canceled and lapsed or expired subscription. |
addon_disabled | 403 | The Public API addon is not active for the company. |
validation_failed | 422 | Invalid body/parameters (errors with per-field detail). |
not_found | 404 | The resource does not exist or belongs to another company. |
conflict | 409 | State conflict (e.g. deciding an already-decided absence). |
idempotency_conflict | 409 | The Idempotency-Key was reused with a different body. |
rate_limited | 429 | Per-minute limit exceeded (see Retry-After). |
quota_exceeded | 429 | Monthly quota exhausted. |
billing_required | 402 | The plan does not allow the action (e.g. adding an employee without a seat). |
internal_error | 500 | Internal error. |
Multitenant isolation. Requesting a resource from another company returns 404 (not_found), never a 403 that would reveal its existence.
An exhausted trial or a cancellation cut off /v1 with 403 subscription_inactive, never with a 402. The 402 (billing_required) is a different thing: the subscription is alive but the plan does not cover the specific action (e.g. creating an employee with no seat left). Read the 403 as “no service, reactivate in the dashboard” and the 402 as “upgrade the plan”.
Revoking API keys and webhooks (DELETE) keeps working while the subscription is inactive: it is a security control, not service usage.
Rate limits and quota
Every response (2xx and 4xx) includes, with two exceptions documented below:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The key’s per-minute limit. |
X-RateLimit-Remaining | Requests remaining in the current window. |
X-RateLimit-Reset | Unix timestamp at which the window resets. |
X-Kinmu-Quota-Remaining | Requests remaining in the monthly quota. |
Two responses do not carry these headers. 401 (unauthenticated), because authentication cuts in before the throttle and the request is never counted; and every response of GET /v1/reports/{report}/download, because the signed download is served outside the authenticated pipeline. Don’t treat their absence as a failure: in those two cases it is expected.
Limits: live 120/min, test 30/min. Monthly quota: live 10,000 + 1,000×active_employees (max 100,000); test 5,000.
When you exceed the minute → 429 rate_limited with Retry-After: <seconds>. When you exhaust the quota → 429 quota_exceeded. Retry with backoff, respecting Retry-After.
import time, requests
def call_with_retry(session, method, url, **kwargs):
for attempt in range(5):
resp = session.request(method, url, **kwargs)
if resp.status_code != 429:
return resp
wait = int(resp.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
resp.raise_for_status()
return respWatch X-RateLimit-Remaining and X-Kinmu-Quota-Remaining to space out your requests before getting a 429.
Idempotency
On mutating methods (POST / PATCH / DELETE) you can send Idempotency-Key: <unique>:
curl -s -X POST "https://api.kinmu.app/v1/check-ins" \
-H "Authorization: Bearer kinmu_sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f9a…-uuid" \
-d '{ "employee_id": "…", "type": "in", "timestamp": "2026-07-08T08:00:00Z" }'The first request executes and its response is cached for 24 h; a resend with the same body returns the original response (without re-executing). Reusing the same key with a different body → 409 idempotency_conflict.
Webhook creation is the only exception: its copy-once secret is never cached.