# Sources and conflicts

> How TimeTracking merges raw claims from the app, kiosk, client email, imports, the office and the client portal into one value per day, and how conflicts are detected and resolved.

URL: https://timetracking.tech/developers/concepts/sources-and-conflicts
Last updated: 2026-10-24

TimeTracking never overwrites hours. Each input is stored as a **claim** with a **source**. For every placement and date, the claims are resolved into one **day** value.

## Sources

| Source | Created by | Who may send it via API |
|---|---|---|
| `app` | Employee in the mobile/web app | employee token |
| `kiosk` | Clock-out on a kiosk tablet | kiosk endpoints (device token) |
| `mail` | Client timesheet parsed from an email or upload | office credentials; inbound pipeline |
| `import` | File/system import | office credentials (write key) |
| `manual_office` | Office entry | office credentials (write key) |
| `client_portal` | Client correction via the approval link | approval link; office credentials |
| `predicted` | Suggestion for the employee | never counts on its own |

## Resolution rules

The rules are implemented in `resolveDay` in `@tt/core`:

1. Claims with `confidence` below **0.85** (`minConfidence`) never count. Parsed email rows below that threshold go to the [inbox](https://timetracking.tech/developers/data-in/inbound-email) instead.
2. `predicted` claims never count.
3. **Per source, only the newest claim counts.** A resubmission from the same source replaces the earlier one.
4. No usable claims → the day is empty (`minutes: null`).
5. One usable source → `resolution: "auto_single_source"`.
6. Several sources that **agree** → `resolution: "auto_match"`. The winner is picked by priority: `client_portal` > `manual_office` > `mail` > `import` > `kiosk` > `app`.
7. Several sources that **disagree** → **conflict**. The day gets `minutes: null`, `has_conflict: true` and a conflict record with every candidate. A `conflict.created` webhook fires.

Sources **agree** when every pair is within tolerance:

| Setting | Default | Meaning |
|---|---|---|
| `totalToleranceMin` | `0` | Max difference in daily total minutes |
| `clockToleranceMin` | `15` | Max difference in start or end time (only compared when both claims have times) |

Admins change both tolerances in "Instellingen" (Settings).

## Example: a conflict, captured from the API

The office records 07:00–15:30 with a 30-minute break (480 min). The client's email says 450 minutes:

```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","minutes":450,"source":"mail","sourceRef":"docs-capture"}'
```

The day now has `minutes: null` and `has_conflict: true`, with both claim IDs in `claim_ids`. `GET /conflicts?week=2026-11-30` returns:

```json
{
  "data": [
    {
      "id": "c51f9c0d-da32-4edf-86a8-0ba2a86d93a7",
      "dayId": "0ce9b17d-40ab-4542-937a-de57896a7358",
      "workDate": "2026-12-01",
      "personName": "Sanne Janssen",
      "clientName": "Bakkerij Ruijters",
      "candidates": [
        { "source": "manual_office", "claimId": "7d78dbdb-d70c-4960-8796-4cedfa3454e6", "minutes": 480 },
        { "source": "mail", "claimId": "ddce5803-8f75-4ace-9d5c-e0bf9fae1d8a", "minutes": 450 }
      ]
    }
  ]
}
```

## Resolving a conflict

To resolve it, pick the winning claim and add a note:

```sh
curl -s -X POST "$TT/conflicts/c51f9c0d-da32-4edf-86a8-0ba2a86d93a7/resolve" \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"claimId":"7d78dbdb-d70c-4960-8796-4cedfa3454e6","note":"Matches the signed paper timesheet"}'
# → {"data":{"resolved":true}}
```

The day takes the chosen claim's minutes, start, end and break, with `resolution: "office_choice"`. A `conflict.resolved` webhook fires with `{conflictId, claimId}`. Resolving the same conflict again returns **409** `{"code":"conflict","message":"Conflict already resolved"}`. The `claimId` must belong to the conflict's placement and date, otherwise you get **422**.

Other ways a conflict ends:

- The client enters corrected hours on the approval link. That creates a `client_portal` claim and sets `resolution: "client_correction"`.
- A new claim brings the sources back within tolerance.

Conflicted days cannot be approved (`POST /days/approve` counts them as `skipped`). The client link does not accept "approved" for them either; the client must enter corrected hours.

## Weekly totals from client emails

Some client emails give a weekly total per person, not daily hours. These are compared with the app hours for that week. When they match, the item is stored as `matched_week`. When they differ, it becomes a `week_conflict` review item in the inbox with a note like `App: 2400 min, opdrachtgever: 2280 min`. Weekly totals never create claims.

## Reading raw claims

`GET /claims?from=2026-12-01&to=2026-12-13&placement_id=…&source=import` returns every raw claim with `source_ref`, `confidence` and `created_at`. It is paginated. Use it for audits or to check that your import landed. See the [reference](https://timetracking.tech/developers/api/reference#get-claims).

## Designing an integration

- Send hours with the source that matches reality: `import` for system-to-system feeds, `manual_office` for office entries. Put your own record ID in `sourceRef` (max 200 chars).
- Send a client-generated UUID as `id` to make retries safe at claim level. A repeat with the same `id` returns the existing day and creates nothing. You can also send an `Idempotency-Key` header (see [idempotency](https://timetracking.tech/developers/api/idempotency)).
- Watch `conflict.created` to alert a human and `conflict.resolved` to close the alert. Don't auto-resolve material differences.
