# 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.

URL: https://timetracking.tech/developers/data-in/client-approval-links
Last updated: 2026-10-24

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](https://timetracking.tech/developers/concepts/status-lifecycle#client-approval-modes).

## 1. Create a link

```sh
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"}'
```

```json
{ "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.

```sh
curl -s "$TT/client/approve/$TOKEN"
```

```json
{
  "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.

```sh
# 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

```sh
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.
