Client approval links
Magic links that let a client contact approve, correct or reject hours without an account: creating links, the email-code verification flow, the public endpoints, and how decisions change day status.
Last updated: · Markdown version
A client approval link (accordeerlink) is a signed URL. With it, a client contact reviews a period's hours for their placements and approves, corrects or rejects each day, then signs with their name. The client needs no account.
When you need it
It depends on the client's approval_mode:
office: optional. The office approval is enough.client: required. The client's approval makes the dayapproved.both: required. Office and client must approve, in either order.
See status lifecycle.
1. Create a link
curl -s -X POST "$TT/approval-links" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"clientId":"00000000-0000-4000-8000-0000000000c2","startsOn":"2026-11-30","endsOn":"2026-12-06"}'
{ "data": { "url": "https://app.timetracking.tech/approve/<token>", "expiresAt": "2026-11-06T19:09:09.202Z", "emailed": "ruud@logistiekparkstad.example" } }
- The link covers every placement of that client that overlaps the period. It is valid for 14 days.
- When the client has an approver contact, the link is emailed automatically to that contact.
emailedholds the address, ornullwhen nobody was mailed. Every call creates and mails a new link. Send anIdempotency-Keyheader so a network retry returns the first link instead of mailing a second one. - 422
This client has no placements in the periodwhen nothing matches. 422Invalid periodwhenendsOn<startsOn. - The token is returned once. Only its hash is stored.
The MCP tool create_client_approval_link returns the same {url, expiresAt, emailed}. Assistants should only share the URL themselves when emailed is null.
Automatic reminders are sent on day 2 and day 4 to clients that have not completed their link. The reminder replaces the old link and keeps the original timeline.
2. What the client sees (public)
The token in the URL is the credential. Send no Authorization header.
curl -s "$TT/client/approve/$TOKEN"
{
"data": {
"linkId": "b374151b-659a-4133-889e-1bd262647f48",
"requiresCode": true,
"contactEmailMasked": "r•••@logistiekparkstad.example",
"orgName": "Jobhunting Uitzenden (demo)",
"clientName": "Logistiek Parkstad",
"startsOn": "2026-11-30",
"endsOn": "2026-12-06",
"completed": false,
"rows": [
{ "dayId": "0c0dbf6f-9bc8-48b7-bfe1-cf8f6566a878", "personName": "Piotr Nowak", "roleTitle": "Heftruckchauffeur", "workDate": "2026-12-01",
"minutes": 480, "startMin": 360, "endMin": 870, "breakMin": 30, "note": null, "status": "submitted", "clientApproved": false }
]
}
}
The view shows days with status submitted, approved, exported or locked. Only submitted days can receive decisions. Unknown, expired or revoked tokens return 404 This link is invalid or has expired.
3. Email-code verification
When requiresCode is true (the client has an approver contact), the signer must prove they control that mailbox. The office therefore cannot sign on the client's behalf.
# Mail a 6-digit code to the contact (valid 10 minutes; at most one new code per minute)
curl -s -X POST "$TT/client/approve/$TOKEN/code"
# → {"data":{"sentTo":"r•••@logistiekparkstad.example"}}
# Exchange the code for a proof (valid 2 hours); 5 wrong attempts → request a new code
curl -s -X POST "$TT/client/approve/$TOKEN/verify" -H "content-type: application/json" -d '{"code":"123456"}'
# → {"data":{"proof":"1792789749942.317037da…"}}
# wrong code → 422 {"code":"invalid","message":"The code is not correct"}
4. Submit decisions
curl -s -X POST "$TT/client/approve/$TOKEN" -H "content-type: application/json" -H "x-approval-proof: $PROOF" \
-d '{"signerName":"Ruud Hendriks","signerRole":"Operations manager","signature":"R. Hendriks",
"decisions":[{"dayId":"0c0dbf6f-9bc8-48b7-bfe1-cf8f6566a878","decision":"approved"}]}'
| Field | Rules |
|---|---|
signerName |
2–120 chars, required |
signerRole |
≤ 120 chars |
signature |
2–120 chars (typed signature), required |
decisions[].dayId |
a day shown in the view |
decisions[].decision |
approved, corrected or rejected |
decisions[].correctedMinutes |
0–1440, required for corrected |
decisions[].reason |
≤ 500 chars, required for corrected and rejected |
The response is the refreshed view. When nothing is left to decide, it has completed: true and the day now has status: "approved" and clientApproved: true. Without a valid proof you get 403 {"code":"forbidden","message":"Confirm the code sent by email first"}.
Effects
| Decision | Effect |
|---|---|
approved |
Sets approved_by_client and the timestamp. The day becomes approved if the mode is client, or both with office approval already given |
corrected |
Stores a client_portal claim with the corrected minutes. That claim becomes the day value (resolution: "client_correction", any conflict cleared). The note Opdrachtgever: <reason> is added. Approval effects as above |
rejected |
The day stays submitted with note Opdrachtgever: <reason> and goes back to the office queue |
Notes:
- Decisions on days that are not
submittedare silently skipped. So isapprovedon a day in conflict: the client must usecorrectedthere. - Every decision is stored with signer name, role, signature, IP and user agent as an audit record.
- One
timesheet.client_approvedwebhook fires per submission:{linkId, signer, days}.
Building your own client portal
You can embed the flow in your own portal with the public endpoints: GET view → POST /code → POST /verify → POST decisions with x-approval-proof. Treat personName, note and signerName as untrusted text.