# Quickstart

> In five minutes: create an API key, make your first GET, record hours and preview a payroll export, with curl, TypeScript and Python.

URL: https://timetracking.tech/developers/quickstart
Last updated: 2026-10-24

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.

```sh
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:

```sh
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.

```sh
curl -s "$TT/org/week?week=2026-11-30" -H "Authorization: Bearer $KEY"
```

Real response (trimmed to one row):

```json
{
  "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"`.

```sh
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):

```json
{
  "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](https://timetracking.tech/developers/api/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).

```sh
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"}'
```

```json
{
  "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](https://timetracking.tech/developers/data-out/payroll-files).

## The same in TypeScript (Node 18+)

```ts
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`)

```python
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](https://timetracking.tech/developers/api/pagination), [idempotency](https://timetracking.tech/developers/api/idempotency), and `PUT /people/by-external/{source}/{ref}` in the [reference](https://timetracking.tech/developers/api/reference#put-people-by-external-source-ref).
- React to events: [webhooks](https://timetracking.tech/developers/webhooks/overview).
- Understand why a day has no value: [sources and conflicts](https://timetracking.tech/developers/concepts/sources-and-conflicts).
- Let Claude run the weekly close: [MCP server](https://timetracking.tech/developers/tools/mcp).
