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.
Last updated: · Markdown version
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:
- Claims with
confidencebelow 0.85 (minConfidence) never count. Parsed email rows below that threshold go to the inbox instead. predictedclaims never count.- Per source, only the newest claim counts. A resubmission from the same source replaces the earlier one.
- No usable claims → the day is empty (
minutes: null). - One usable source →
resolution: "auto_single_source". - Several sources that agree →
resolution: "auto_match". The winner is picked by priority:client_portal>manual_office>mail>import>kiosk>app. - Several sources that disagree → conflict. The day gets
minutes: null,has_conflict: trueand a conflict record with every candidate. Aconflict.createdwebhook 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:
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:
{
"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:
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_portalclaim and setsresolution: "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.
Designing an integration
- Send hours with the source that matches reality:
importfor system-to-system feeds,manual_officefor office entries. Put your own record ID insourceRef(max 200 chars). - Send a client-generated UUID as
idto make retries safe at claim level. A repeat with the sameidreturns the existing day and creates nothing. You can also send anIdempotency-Keyheader (see idempotency). - Watch
conflict.createdto alert a human andconflict.resolvedto close the alert. Don't auto-resolve material differences.