# Data model

> The TimeTracking entities (organisation, person, client, placement, claim, day, conflict, leave, expense, shift, export run, period) and how they relate.

URL: https://timetracking.tech/developers/concepts/data-model
Last updated: 2026-10-24

Everything belongs to one **organisation** (org). An API key, webhook or user session never sees another org's data.

```
Organisation
├── Person ───────────┐            (employee / flex worker; employee_number, email, active)
├── Client ──┐        │            (approval_mode office|client|both, mail_domains, contacts)
│            └── Placement ◄───────┘   person × client, starts_on … ends_on, ref
│                  ├── Claim*      raw hours from one source (append-only)
│                  ├── Day         one resolved value per placement per date (timesheet_day)
│                  │     └── Conflict   when claims disagree (candidates → office picks one)
│                  ├── Shift       planned work (roster), draft → published
│                  └── Expense     km, travel_time, public_transport, parking, meal, other
├── Leave request    person, leave type, starts_on … ends_on, minutes_per_day
├── Export mapping   JSON recipe for a payroll file (presets + org-specific)
├── Export run       an immutable, committed payroll file + pay lines
└── Period           a locked date range (week, four_weeks, semi_month, month)
```

## Entities

| Entity | API resource | Key fields (responses are snake_case) | Notes |
|---|---|---|---|
| Person | `/people` | `id`, `employee_number`, `first_name`, `last_name`, `email`, `phone`, `locale`, `active`, `user_id`, `external_source`, `external_ref` | `employee_number` is the payroll key and must be digits for the SD Worx preset. `user_id` is set once the person has app access. |
| Client | `/clients` | `id`, `name`, `external_ref`, `external_source`, `approval_mode`, `mail_domains`, `client_contacts[]` | `approval_mode` decides who must approve hours (see [status lifecycle](https://timetracking.tech/developers/concepts/status-lifecycle)). `mail_domains` links inbound email to the client. |
| Placement | `/placements` | `id`, `person_id`, `client_id`, `ref`, `role_title`, `starts_on`, `ends_on`, `active`, `external_source`, `external_ref` | Hours are **always** recorded on a placement and a date. `ref` is your placement number and appears in exports as `placementRef`. |
| Claim | `/claims` | `id`, `placement_id`, `work_date`, `source`, `minutes`, `start_min`, `end_min`, `break_min`, `note`, `confidence`, `source_ref`, `created_at` | One raw input. Never edited; a newer claim from the same source replaces the older one. See [sources and conflicts](https://timetracking.tech/developers/concepts/sources-and-conflicts). |
| Day | `/days`, `/org/week` | `id`, `placement_id`, `person_id`, `work_date`, `minutes`, `start_min`, `end_min`, `break_min`, `status`, `resolution`, `has_conflict`, `claim_ids`, `approved_by_office_at`, `approved_by_client`, `approved_by_client_at`, `export_run_id`, `version`, `updated_at` | Derived from claims. `minutes` is `null` while the day is in conflict. `version` increases with every change. |
| Conflict | `/conflicts` | `id`, `dayId`, `workDate`, `personName`, `clientName`, `candidates[{claimId, source, minutes}]` | Resolved with `POST /conflicts/{id}/resolve {claimId}`. |
| Leave request | `/leave` | `id`, `person_id`, `leave_type_id`, `starts_on`, `ends_on`, `minutes_per_day`, `status` (`requested`, `approved`, `rejected`, `cancelled`) | Leave types per org: `vacation`, `sick`, `special`, `unpaid` by default (`/leave/types`). |
| Expense | `/expenses` | `id`, `person_id`, `placement_id`, `work_date`, `kind`, `km`, `minutes`, `amount_cents`, `status` (`submitted`, `approved`, `rejected`, `exported`) | `km` is priced with the placement's `per_km` component. Without one, `amount_cents` is `0`. |
| Shift | `/shifts` | `id`, `client_id`, `placement_id` (null = open shift), `work_date`, `start_min`, `end_min`, `break_min`, `status` (`draft`, `published`), `requires_approval` | A plan only. Shifts never become hours by themselves. |
| Shift request | `/shift-requests` | `id`, `kind` (`claim`, `swap`), `shift_id`, requester, target (swap), `target_shift_id` (exchange), `status` (`pending_colleague`, `pending_planner`, `approved`, `rejected`, `declined`, `cancelled`) | Claims of open shifts and swaps between colleagues. Only an approved request changes the roster. |
| Export run | `/exports/runs` | `id`, `mapping_id`, `mapping_snapshot`, `starts_on`, `ends_on`, `row_count`, `content_hash`, `pay_lines`, `warnings` | Immutable and reproducible. The file is at `/exports/runs/{id}/file`. |
| Period | (`POST /periods/lock`) | `kind`, `starts_on`, `ends_on`, `locked_at` | See [periods and locking](https://timetracking.tech/developers/concepts/periods-and-locking). |

## Identifiers

- TimeTracking IDs are UUIDs. Store them in your system.
- **External IDs** (v1.1) give every person, client and placement an optional `(external_source, external_ref)` pair. Examples: `("bullhorn", "12345")`, `("carerix", "PL-555")`. Upsert by that pair with `PUT /people/by-external/{source}/{ref}` (also `/clients/...` and `/placements/...`). A placement can point at its person and client by their external refs (`personExternalRef`, `clientExternalRef`) from the same source. See the [API reference](https://timetracking.tech/developers/api/reference#put-placements-by-external-source-ref).
- Older natural keys that still work: `employee_number` (people; the [CSV import](https://timetracking.tech/developers/data-in/csv-import) matches on it), placement `ref` (the CSV import matches on it), client `name` (the CSV import matches case-insensitively).

## Units and time

- Durations are integer minutes. Money is integer cents. Dates are `YYYY-MM-DD` in the org's timezone (default `Europe/Amsterdam`).
- `start_min` and `end_min` are minutes after midnight, from 0 to 1439. A shift that ends at or before its start crosses midnight. It belongs to the date it **started** on.
- Weeks are ISO weeks (Monday–Sunday). Endpoints that take `week` accept any date inside the week.

## Request vs response naming

Request bodies use **camelCase** (`placementId`, `workDate`, `startMin`). Most responses return database rows in **snake_case** (`placement_id`, `work_date`, `start_min`). Aggregated views (`/org/week`, `/conflicts`, `/client/approve/{token}`, export preview) use camelCase. The [API reference](https://timetracking.tech/developers/api/reference) shows the real shape of every response.

## Roles

| Role | Typical user | Can do |
|---|---|---|
| `owner`, `admin` | Agency management | Everything, including API keys, office users, webhooks, period lock, kiosk devices, export formats, GDPR erasure |
| `office`, `planner` | Intercedent / planner | Master data, hours, approvals, exports, rosters, leave and expense decisions |
| `accountant` | External accountant | Read only |
| `employee` | Flex worker | Own hours (`app`/`kiosk`), own leave, expenses, availability |
| client (no account) | Client contact | Approve, correct or reject via a [magic link](https://timetracking.tech/developers/data-in/client-approval-links) |

API keys act as `accountant` (`read` scope), `office` (`write`) or `admin` + `office` (`admin`). No key can create keys or change office roles. See [authentication](https://timetracking.tech/developers/api/authentication).
