Skip to content

Request an integration

Tell us which system you want to connect. We review every request weekly and publish a step-by-step guide or build the connection.

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 day approved.
  • both: required. Office and client must approve, in either order.

See status lifecycle.

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. emailed holds the address, or null when nobody was mailed. Every call creates and mails a new link. Send an Idempotency-Key header so a network retry returns the first link instead of mailing a second one.
  • 422 This client has no placements in the period when nothing matches. 422 Invalid period when endsOn < 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 submitted are silently skipped. So is approved on a day in conflict: the client must use corrected there.
  • Every decision is stored with signer name, role, signature, IP and user agent as an audit record.
  • One timesheet.client_approved webhook 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.