Quickstart
In five minutes: create an API key, make your first GET, record hours and preview a payroll export, with curl, TypeScript and Python.
Last updated: · Markdown version
This takes about five minutes. You will create an API key, read the week overview, record hours, and preview a payroll export. Nothing in this guide changes payroll data irreversibly.
1. Create an API key
An admin creates keys in the dashboard: "Regels & koppelingen" (Rules & integrations) → "API-sleutels" (API keys). Tick "Mag ook schrijven" (may also write) for a key with the write scope. The key (tt_live_…) is shown once. TimeTracking stores only its hash.
You can also create a key over the API with an admin user access token. Keys cannot create keys.
curl -s -X POST https://app.timetracking.tech/api/v1/api-keys \
-H "Authorization: Bearer $ADMIN_USER_ACCESS_TOKEN" \
-H "content-type: application/json" \
-d '{"name":"quickstart","scopes":["read","write"]}'
# → {"data":{"key":"tt_live_…"}}
Set two shell variables. The rest of the docs use them:
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_…
2. First GET: the week overview
GET /org/week returns every active placement for one ISO week (Mon–Sun). For each one you get seven day cells, totals, KPIs and open conflicts. Any date inside the week selects that week.
curl -s "$TT/org/week?week=2026-11-30" -H "Authorization: Bearer $KEY"
Real response (trimmed to one row):
{
"data": {
"weekStart": "2026-11-30",
"weekEnd": "2026-12-06",
"conflicts": [],
"kpis": { "placements": 4, "submitted": 1, "approved": 0, "conflicts": 0, "missing": 3, "totalMinutes": 930 },
"rows": [
{
"placementId": "00000000-0000-4000-8000-0000000000e4",
"personId": "00000000-0000-4000-8000-0000000000d4",
"personName": "Sanne Janssen",
"employeeNumber": "1004",
"clientName": "Bakkerij Ruijters",
"clientId": "00000000-0000-4000-8000-0000000000c3",
"approvalMode": "office",
"days": [
null,
{ "dayId": "0ce9b17d-40ab-4542-937a-de57896a7358", "minutes": 480, "status": "submitted", "conflict": false, "clientApproved": false, "officeApproved": false },
{ "dayId": "81739347-0330-49eb-a559-877cb1889d63", "minutes": 450, "status": "submitted", "conflict": false, "clientApproved": false, "officeApproved": false },
null, null, null, null
],
"totalMinutes": 930,
"status": "submitted"
}
]
}
}
days[0] is Monday. null means nothing has been recorded for that day. To find a placementId, use GET /placements.
3. Record hours
Record a shift from 07:00 to 15:30 with a 30-minute break. That is 480 worked minutes. Office credentials use source: "manual_office".
curl -s -X POST "$TT/claims" \
-H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-H "Idempotency-Key: quickstart-2026-12-01-e4" \
-d '{"placementId":"00000000-0000-4000-8000-0000000000e4","workDate":"2026-12-01","startMin":420,"endMin":930,"breakMin":30,"source":"manual_office","note":"Entered from paper timesheet"}'
The response is the resolved day (not the claim):
{
"data": {
"id": "0ce9b17d-40ab-4542-937a-de57896a7358",
"placement_id": "00000000-0000-4000-8000-0000000000e4",
"work_date": "2026-12-01",
"minutes": 480,
"start_min": 420,
"end_min": 930,
"break_min": 30,
"status": "draft",
"resolution": "auto_single_source",
"claim_ids": ["7d78dbdb-d70c-4960-8796-4cedfa3454e6"],
"has_conflict": false
}
}
You can send minutes (for example 450) instead of startMin/endMin. If the call times out, retrying with the same Idempotency-Key returns the first response and does not create a second claim (see idempotency).
4. Preview a payroll export
A preview changes nothing. It returns the file content, the parsed rows, validation issues (which block a commit), rule warnings, and pendingCount (days in the range that are not approved yet and are therefore not in the file).
curl -s -X POST "$TT/exports" \
-H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{"mappingId":"preset-generic","startsOn":"2026-11-30","endsOn":"2026-12-06","mode":"preview"}'
{
"data": {
"content": "employee_number;employee_name;client;placement;date;hour_type;hours\r\n1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-01;normal;8.00\r\n1004;Sanne Janssen;Bakkerij Ruijters;PL-2026-104;2026-12-02;normal;7.50\r\n",
"rows": [["1004","Sanne Janssen","Bakkerij Ruijters","PL-2026-104","2026-12-01","normal","8.00"], ["1004","Sanne Janssen","Bakkerij Ruijters","PL-2026-104","2026-12-02","normal","7.50"]],
"issues": [],
"ok": true,
"dayCount": 2,
"pendingCount": 1,
"warnings": [{ "key": "00000000-0000-4000-8000-0000000000e4", "date": "2026-12-02", "code": "break_too_short", "message": "Break 0 min, at least 30 min required", "person": "Sanne Janssen" }],
"packName": "NL basis (voorbeeld — afstemmen op CAO)"
}
}
"mode":"commit" creates the export run and moves the included days to exported. This cannot be undone. See payroll files.
The same in TypeScript (Node 18+)
const TT = "https://app.timetracking.tech/api/v1";
const KEY = process.env.TT_API_KEY!;
async function tt<T>(method: string, path: string, body?: unknown, idempotencyKey?: string): Promise<T> {
const res = await fetch(TT + path, {
method,
headers: {
authorization: `Bearer ${KEY}`,
"content-type": "application/json",
...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429) throw new Error(`rate limited, retry after ${res.headers.get("retry-after")}s`);
const json = await res.json();
if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`);
return json.data as T;
}
const week = await tt<{ rows: { placementId: string; personName: string; totalMinutes: number }[] }>("GET", "/org/week?week=2026-11-30");
console.log(week.rows.map((r) => `${r.personName}: ${r.totalMinutes / 60} h`));
const day = await tt("POST", "/claims", {
placementId: week.rows[0].placementId,
workDate: "2026-12-01",
startMin: 7 * 60,
endMin: 15 * 60 + 30,
breakMin: 30,
source: "manual_office",
}, `qs-${week.rows[0].placementId}-2026-12-01`);
console.log(day);
const preview = await tt<{ ok: boolean; rows: string[][]; issues: unknown[]; pendingCount: number }>("POST", "/exports", {
mappingId: "preset-generic", startsOn: "2026-11-30", endsOn: "2026-12-06", mode: "preview",
});
console.log(preview.ok, preview.rows.length, "rows,", preview.pendingCount, "days not yet approved");
The same in Python (3.9+, requests)
import os, requests
TT = "https://app.timetracking.tech/api/v1"
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {os.environ['TT_API_KEY']}"})
def tt(method, path, body=None, idempotency_key=None):
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
r = S.request(method, TT + path, json=body, headers=headers, timeout=30)
if r.status_code == 429:
raise RuntimeError(f"rate limited, retry after {r.headers.get('Retry-After')}s")
payload = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code} {payload['error']['code']}: {payload['error']['message']}")
return payload["data"]
week = tt("GET", "/org/week?week=2026-11-30")
for row in week["rows"]:
print(row["personName"], row["totalMinutes"] / 60, "h", row["status"])
day = tt("POST", "/claims", {
"placementId": week["rows"][0]["placementId"],
"workDate": "2026-12-01",
"startMin": 420, "endMin": 930, "breakMin": 30,
"source": "manual_office",
}, idempotency_key=f"qs-{week['rows'][0]['placementId']}-2026-12-01")
print(day["status"], day["minutes"], day["has_conflict"])
preview = tt("POST", "/exports", {"mappingId": "preset-generic", "startsOn": "2026-11-30", "endsOn": "2026-12-06", "mode": "preview"})
print(preview["ok"], len(preview["rows"]), "rows;", preview["pendingCount"], "days not yet approved")
Next steps
- Sync master data from your ATS: pagination, idempotency, and
PUT /people/by-external/{source}/{ref}in the reference. - React to events: webhooks.
- Understand why a day has no value: sources and conflicts.
- Let Claude run the weekly close: MCP server.