# Planbition planning and hours

> Combine Planbition workforce planning with TimeTracking: import Planbition hours as claims for matching, or send approved TimeTracking hours to Planbition.

URL: https://timetracking.tech/developers/integrations/payroll/planbition
Last updated: 2026-10-24

## 1. Summary

| | |
|---|---|
| What syncs | Option 1: Planbition worked/planned hours → TimeTracking claims. Option 2: TimeTracking approved hours → Planbition |
| Direction | Either, per customer; pick one source of truth for rosters |
| Mechanism | Script/iPaaS: Planbition export (file or API) → `POST /claims` with `source: "import"`; or TimeTracking export file → Planbition import |
| Trigger | Daily or weekly |

**Status:** Recipe — works today with a script or iPaaS. No native Planbition connector.

## 2. Mechanism

Planbition is workforce planning software used by staffing agencies and their clients (rosters, availability, hours). Assumption — Planbition can export planned/realised hours per employee per day (file or API) and can import hours; the exact interface depends on your Planbition contract. Verify with your Planbition consultant.

**Option 1 — Planbition hours as a source.** Each realised Planbition shift becomes a TimeTracking claim with `source: "import"` and `sourceRef` = the Planbition shift id. TimeTracking matches it against app, kiosk and client hours per placement and day; agreeing sources auto-match (priority `client_portal > manual_office > mail > import > kiosk > app`), disagreeing sources create a conflict for the office. See [Sources and conflicts](https://timetracking.tech/developers/concepts/sources-and-conflicts).

**Option 2 — TimeTracking hours to Planbition.** A custom export mapping writes per-day lines (no `groupBy`) that Planbition imports as realised hours.

Rosters: TimeTracking has its own roster (`/shifts`). Do not plan in both systems. If Planbition is the planner, leave TimeTracking shifts unused.

## 3. Prerequisites

- TimeTracking API key with `write` scope.
- A shared employee key: Planbition employee number = TimeTracking `employee_number`.
- A way to resolve the TimeTracking `placementId` for a Planbition shift (person + client + date). Build a lookup from `GET /placements`.
- Planbition export/import access.

## 4. Entity mapping

| Planbition object / field | TimeTracking | Notes / external ID |
|---|---|---|
| Employee number | `people.employee_number` | Only stable key today. |
| Customer / department | `clients.name` or `clients.external_ref` | Set `externalRef` via `POST /clients`. |
| Contract / assignment | `placements` (`ref`) | Resolve per person + client, valid on the date (`starts_on` ≤ date ≤ `ends_on`). |
| Shift id | Claim `sourceRef` (≤ 200 chars) | Traceability. |
| Date, start, end, break | `workDate`, `startMin`, `endMin`, `breakMin` | Minutes since midnight; `endMin` < `startMin` = night shift. |
| Worked hours | `minutes` (alternative to start/end) | |

Placement lookup: `GET /placements?person_id=<id>&active=true` (paginated `{items, next_cursor}`) narrows the candidates. If Planbition holds its own contract ids, store them with `PUT /placements/by-external/planbition/{contractId}` and look them up from the response.

## 5. Step-by-step setup

```sh
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_…        # write scope
```

Option 1 — import a realised shift:

```sh
curl -s -X POST "$TT/claims" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"placementId":"00000000-0000-4000-8000-0000000000e4","workDate":"2026-12-01","startMin":420,"endMin":930,"breakMin":30,"source":"import","sourceRef":"planbition:shift:88213"}'
```

The response is the resolved timesheet day (real capture, from a `manual_office` 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", "has_conflict": false, "version": 1 } }
```

If another source disagrees, the response shows `"has_conflict": true` and `"minutes": null`, and a `conflict.created` webhook fires.

Retries: claims are append-only and per source only the newest counts. Re-sending the same shift adds a duplicate `import` claim that replaces the earlier one with the same value — harmless for the day value. To be strictly idempotent, generate a deterministic UUID per Planbition shift and pass it as `id`: a claim with an existing `id` is not inserted again.

```ts
import { v5 as uuidv5 } from "uuid";
const NS = "6f1c0d5e-2b8a-4f43-9d0e-7a1c2b3d4e5f"; // your own namespace UUID
const id = uuidv5(`planbition:${shiftId}`, NS);
```

For volume, send up to 500 shifts per call to `POST /claims/batch` (`{"items":[…]}`, rows run sequentially, one bad row never aborts):

```sh
curl -s -X POST "$TT/claims/batch" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -H "Idempotency-Key: planbition-2026-12-07-run1" \
  -d '{"items":[
    {"placementId":"96574dce-db96-4da1-82cf-de32021472d9","workDate":"2026-12-07","startMin":420,"endMin":930,"breakMin":30,"source":"import","sourceRef":"planbition:shift:88213"},
    {"placementId":"96574dce-db96-4da1-82cf-de32021472d9","workDate":"2026-12-08","minutes":480,"source":"import","sourceRef":"planbition:shift:88214"}]}'
```

Real response shape (abridged; third row had no hours):

```json
{ "data": { "ok": 2, "failed": 1, "results": [
  { "index": 0, "ok": true, "data": { "work_date": "2026-12-07", "minutes": 480, "status": "draft", "resolution": "auto_single_source" } },
  { "index": 1, "ok": true, "data": { "work_date": "2026-12-08", "minutes": 480, "status": "draft" } },
  { "index": 2, "ok": false, "error": { "code": "invalid", "message": "…Provide either minutes or start and end time…" } } ] } }
```

With an `Idempotency-Key`, a retried request with the same key replays the stored response (header `idempotent-replayed: true`) for 24 h. Each created claim emits `claim.created` `{placementId, workDate, source, minutes, dayId, dayStatus}`.

Check what is stored: `GET /claims?from=2026-12-07&to=2026-12-13&source=import` (raw claims) and `GET /days?from=…&to=…` (resolved day values), both with a read key.

Option 2 — an admin builds a per-day mapping in the dashboard (`/export/mappings`, copy `preset-generic`; its id is the UUID in the URL), for example:

```json
{
  "id": "planbition-realisatie",
  "name": "Planbition — gerealiseerde uren",
  "format": "csv", "delimiter": ";", "includeHeader": true, "encoding": "utf-8", "lineEnding": "crlf",
  "columns": [
    { "header": "Personeelsnummer", "source": "employeeNumber" },
    { "header": "Klant", "source": "clientName" },
    { "header": "Datum", "source": "date", "transform": { "kind": "date", "format": "DD-MM-YYYY" } },
    { "header": "Soort", "source": "hourType" },
    { "header": "Uren", "source": "minutes", "transform": { "kind": "hours_hm" } }
  ],
  "tables": {}, "validation": [{ "column": "Personeelsnummer", "required": true }]
}
```

Column names are assumptions; verify with your Planbition consultant. Then `tt export --week 2026-11-30 --mapping planbition-realisatie --commit -o planbition-w49.csv`.

## 6. Sync schedule and triggering events

- Option 1: daily import of yesterday's realised shifts, before the office reviews the week.
- Option 2: weekly after the close; on `export.completed` download the file with `GET /exports/runs/{runId}/file`.
- `claim.created` fires for every imported claim (useful for monitoring, noisy for large batches).
- `conflict.created` `{timesheetDayId, placementId, workDate, candidates}` tells you where Planbition and other sources disagree.

## 7. Corrections, conflicts and locked periods

- Employees cannot edit submitted days; office and imports can still add claims until a day is exported.
- A changed Planbition shift on an approved day sends the day back to `submitted` and clears approvals when the value changes.
- Claims on exported/locked days are refused with `423` before anything is stored.

## 8. Testing checklist

- [ ] Placement lookup finds exactly one placement per person/client/date.
- [ ] A matching Planbition shift and app entry auto-match (`resolution: "auto_match"`).
- [ ] A different value creates a conflict visible in `GET /conflicts?week=`.
- [ ] Re-running the import with deterministic `id`s changes nothing (`GET /claims?source=import` shows no extra rows).
- [ ] A retried batch with the same `Idempotency-Key` returns `idempotent-replayed: true`.

## 9. Troubleshooting

| HTTP / code | Message | Fix |
|---|---|---|
| 404 `not_found` | Placement not found | Wrong or foreign placement id. |
| 422 `invalid` | Provide either minutes or start and end time | Send `minutes` or `startMin`+`endMin`. |
| 422 `invalid` | Invalid input (`issues[]`) | Date must be `YYYY-MM-DD`; minutes 0–1439 for times. |
| 403 `forbidden` | API key is read-only | `write` key. |
| 423 `locked` | This day is exported or locked; book a correction in an open period | Period closed; book corrections in an open period. |
| 429 `rate_limited` | Rate limit of 600 requests per minute exceeded | Throttle to < 10 req/s; wait 60 s. |

## 10. Limits and known gaps

- Batch max 500 rows; rows run sequentially, so large imports take time — stay under 600 requests/min.
- `Idempotency-Key` replays only within 24 h; use deterministic claim `id`s for permanent dedupe.
- Matching tolerance (total and clock) is an org setting; default 0 min total, 15 min clock.

- Encoding: a mapping with `encoding: "windows-1252"` is delivered as Windows-1252 (Latin-1) only by `GET /exports/runs/{id}/file` (`content-type: text/csv; charset=windows-1252`, no BOM). Characters outside Latin-1 (€, curly quotes, em dash) become `?`. The preview/commit JSON `content` and the dashboard download stay UTF-8.

> **Planned (not in API v1.1)** — export-mapping CRUD via the API.

## 11. Build it with Claude

```text
Build a daily Planbition → TimeTracking import.
Input: a Planbition CSV/API export of realised shifts (ask me for the columns; mark Planbition specifics TODO).
1. Load GET /api/v1/placements once; map (employee_number, client name, date) → placementId.
2. Send shifts in chunks of ≤500 to POST /api/v1/claims/batch (Idempotency-Key per chunk) with source "import", startMin/endMin/breakMin (minutes since midnight),
   sourceRef "planbition:shift:<id>" and a deterministic UUIDv5 id so reruns are idempotent.
3. Skip dates in locked/exported periods (a 423 means stop for that day), throttle under 600 req/min,
   and print a summary: created, auto-matched, conflicts (has_conflict true), errors.
```
