# Mysolution staffing and payroll

> Connect Mysolution (Salesforce-based staffing software) to TimeTracking: placements in via external-ID upserts, approved hours out via GET /days or export runs and webhooks.

URL: https://timetracking.tech/developers/integrations/payroll/mysolution
Last updated: 2026-10-24

## 1. Summary

| | |
|---|---|
| What syncs | In: candidates/employees, accounts (clients), placements. Out: approved hours per placement per hour type, expenses |
| Direction | Mysolution → TimeTracking (master data), TimeTracking → Mysolution (hours) |
| Mechanism | Your middleware (Salesforce Apex/Flow, iPaaS, or a script) calling the TimeTracking REST API: `PUT …/by-external/mysolution/{ref}` upserts in, `GET /days` or export runs out |
| Trigger | Placement changes in Mysolution; approval and export webhooks in TimeTracking |

**Status:** Recipe — works today with your own middleware. No native Mysolution connector.

## 2. Mechanism

Mysolution runs on Salesforce. Assumption — placements, candidates and accounts are Salesforce (custom) objects in your org, and hours/timesheets can be written through the Salesforce REST API or a Mysolution-provided API; object and field API names differ per installation. Verify with your Mysolution consultant.

1. **Master data in.** On placement create/update in Mysolution, middleware upserts the client, person and placement in TimeTracking by their Salesforce ids: `PUT /clients/by-external/mysolution/{accountId}`, `PUT /people/by-external/mysolution/{candidateId}`, `PUT /placements/by-external/mysolution/{placementId}`. These calls are idempotent; no TimeTracking ids need to be stored in Salesforce. Nightly full syncs use `POST /…/batch` (≤ 500 rows per call).
2. **Hours out.** Two options:
   - **Approved day values** (for timesheets/invoicing in Mysolution): `GET /days?from=…&to=…&status=approved,exported` (read key) returns per-day minutes, start/end and break per placement. Paginate with `limit`/`cursor`; poll incrementally with `updated_since`.
   - **Payroll lines with hour types** (overtime, ORT, expenses): commit an export (`POST /exports`) or react to `export.completed` and download the committed file with `GET /exports/runs/{runId}/file`.

## 3. Prerequisites

- TimeTracking API key with `write` scope for upserts; a separate `read` key is enough for `GET /days` and export-run downloads.
- Salesforce integration user and connected app for your middleware.
- A fixed external source name, e.g. `mysolution` (`[a-z0-9_-]{1,40}`), used on every upsert.
- Mysolution hour codes per TimeTracking hour type (only for the payroll-lines option).

## 4. Entity mapping

| Mysolution (Salesforce) object / field | TimeTracking | Notes / external ID |
|---|---|---|
| Account Id | `clients` via `PUT /clients/by-external/mysolution/{AccountId}` | Repeat calls keep the stored ref. |
| Account name, approval setting | `name`, `approvalMode` (`office`/`client`/`both`) | |
| Account contact who approves hours | `client_contacts` (approver) | Managed in the dashboard; no API to create contacts. |
| Candidate Id | `people` via `PUT /people/by-external/mysolution/{CandidateId}` | |
| Employee number, first/last name, email, mobile | `employeeNumber`, `firstName`, `lastName`, `email`, `phone` | Upsert body is the full object; omitted optional fields become `null`. |
| Placement Id | `placements` via `PUT /placements/by-external/mysolution/{PlacementId}` | Link with `personExternalRef` + `clientExternalRef` (same source). |
| Placement number, start/end, function | `ref`, `startsOn`, `endsOn`, `roleTitle` | |
| Placement ended/cancelled | `DELETE /placements/{id}` → `active: false` | Deactivates; hours stay. |
| Timesheet line (date, hours, start/end) | `GET /days` item: `work_date`, `minutes`, `start_min`, `end_min`, `break_min`, `status` | Join `placement_id` to the placement's `external_ref` (`GET /placements/{id}`). |
| Timesheet line with hour code | Export file row: `placementRef`, `date`, `hourType`, `minutes` | Only after commit. |

## 5. Step-by-step setup

```sh
export TT=https://app.timetracking.tech/api/v1
export KEY=tt_live_…        # write scope
```

1. Upsert the client:

```sh
curl -s -X PUT "$TT/clients/by-external/mysolution/001Qy00000AbCdEIAV" \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" -H "Idempotency-Key: sf-001Qy00000AbCdEIAV-v3" \
  -d '{"name":"Logistiek Parkstad","externalRef":"001Qy00000AbCdEIAV","approvalMode":"client","mailDomains":"logistiekparkstad.example"}'
```

2. Upsert the person:

```sh
curl -s -X PUT "$TT/people/by-external/mysolution/003Qy00000XyZ12IAB" \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"employeeNumber":"104577","firstName":"Piotr","lastName":"Nowak","email":"piotr@example.com","locale":"pl"}'
```

Real response (demo data, source `bullhorn`; abridged):

```json
{ "data": { "id": "69a47995-bc6a-4bc9-9b9b-070935b974bb", "employee_number": "9910", "first_name": "Eva",
  "last_name": "Docs", "email": "eva.docs@example.com", "active": true,
  "external_source": "bullhorn", "external_ref": "12345", "created": true } }
```

A repeat call returns the same `id` with `"created": false`. Person upserts emit `person.created` / `person.updated`.

3. Upsert the placement by external refs:

```sh
curl -s -X PUT "$TT/placements/by-external/mysolution/a0X7Q00000PLC103" \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"personExternalRef":"003Qy00000XyZ12IAB","clientExternalRef":"001Qy00000AbCdEIAV","ref":"PLC-103","roleTitle":"Heftruckchauffeur","startsOn":"2026-10-01"}'
```

Missing person or client → `422 Person and client must exist (by id or external ref) before the placement`.

4. Nightly full sync with batch (≤ 500 rows, sequential, one bad row never aborts):

```sh
curl -s -X POST "$TT/people/batch" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"items":[{"externalSource":"mysolution","externalRef":"003Qy00000XyZ12IAB","employeeNumber":"104577","firstName":"Piotr","lastName":"Nowak"}]}'
```

Real response shape:

```json
{ "data": { "ok": 1, "failed": 1, "results": [
  { "index": 0, "ok": true, "data": { "id": "02595da9-06ea-45d1-a5e8-927d854a2c46", "external_ref": "12346", "created": true } },
  { "index": 1, "ok": false, "error": { "code": "invalid", "message": "firstName: Too small: expected string to have >=1 characters; lastName: Invalid input: expected string, received undefined" } } ] } }
```

5. Hours out — approved day values, incremental:

```sh
curl -s "$TT/days?from=2026-11-30&to=2026-12-13&status=approved,exported&updated_since=2026-12-07T00:00:00Z&limit=500" \
  -H "Authorization: Bearer $KEY"
```

Real item (demo data):

```json
{ "id": "0ce9b17d-40ab-4542-937a-de57896a7358", "placement_id": "00000000-0000-4000-8000-0000000000e4",
  "person_id": "00000000-0000-4000-8000-0000000000d4", "work_date": "2026-12-01", "minutes": 480,
  "start_min": 420, "end_min": 930, "break_min": 30, "status": "exported", "resolution": "office_choice",
  "has_conflict": false, "export_run_id": "239a1898-fab5-486c-a2be-92d939be90cc", "version": 6,
  "updated_at": "2026-10-23T19:08:42.871612+00:00" }
```

Follow `next_cursor` until it is `null`. Write each item to the Mysolution timesheet object; use the day `id` (and `version`) to update instead of duplicate.

6. Hours out — payroll lines: on `export.completed` `{runId, …}` download the committed file:

```sh
curl -s "$TT/exports/runs/<runId>/file" -H "Authorization: Bearer $KEY" -o run.csv
```

`preset-generic` columns: `employee_number;employee_name;client;placement;date;hour_type;hours` (per day, decimal point, no amounts). For expenses and Mysolution hour codes, an admin builds a custom mapping in the dashboard (`/export/mappings`).

## 6. Sync schedule and triggering events

| Flow | Trigger |
|---|---|
| Placement upsert | Salesforce record-triggered Flow / Apex on placement change; nightly batch as safety net |
| Day values out | `timesheet.approved` `{dayIds, approved}` / `timesheet.client_approved` → `GET /days?updated_since=…`; or hourly poll |
| Payroll lines out | `export.completed` → `GET /exports/runs/{runId}/file` |
| Echo of upserts (optional) | `person.created/updated`, `placement.created/updated` |

Webhooks are managed by an admin (dashboard, admin user token or an `admin`-scope API key). Verify `tt-signature` ([Verify signatures](https://timetracking.tech/developers/webhooks/verify-signatures)); dedupe on the event `id`, because a replay (`POST /webhook-deliveries/{id}/replay`) resends the same id.

## 7. Corrections, conflicts and locked periods

- Ending a placement early fails with `409` when hours exist after the new end date.
- A changed value on an approved day moves it back to `submitted` and bumps `version`; your `updated_since` poll picks it up.
- Exported/locked days are immutable (`423`); corrections go into an open week and appear as new days/lines.
- If Mysolution hours are also pushed into TimeTracking as claims (`source: "import"`, e.g. via `POST /claims/batch`), differences create conflicts that the office resolves.

## 8. Testing checklist

- [ ] Running the same placement upsert twice returns the same ids with `created: false`.
- [ ] Ending a placement in Mysolution deactivates it in TimeTracking.
- [ ] `GET /days` incremental poll picks up a re-approved day exactly once per version.
- [ ] Webhook receiver rejects a bad signature and ignores a replayed event id it already processed.

## 9. Troubleshooting

| HTTP / code | Message | Fix |
|---|---|---|
| 422 `invalid` | Person and client must exist (by id or external ref) before the placement | Upsert person and client with the same source first. |
| 422 `invalid` | Invalid cursor | Restart pagination without `cursor`. |
| 422 `invalid` | Idempotency-Key was already used for a different request | Use a new key per distinct request (8–200 chars). |
| 404 `not_found` | Not found | `GET /{resource}/{id}` with a wrong or foreign id. |
| 409 `conflict` | N registration(s) exist after <date> | Hours after new end date. |
| 409 `conflict` | Client has placements; deactivate those instead | `DELETE /clients/{id}` only works without placements. |
| 403 `forbidden` | API key is read-only / Admin role required | `write` key for upserts; webhooks need an admin user or an `admin`-scope key. |
| 429 `rate_limited` | Rate limit of 600 requests per minute exceeded | Wait 60 s (`Retry-After`); prefer batch. |

## 10. Limits and known gaps

- No API to create client contacts (approvers).
- Upsert and `PATCH` bodies are full objects; omitted optional fields become `null`.
- Plain `POST /people` emits no webhook; only by-external and batch-external upserts emit `person.created`.

> **Planned (not in API v1.1)** — export-mapping CRUD via the API, OAuth2 for marketplace apps.

## 11. Build it with Claude

```text
Build a Mysolution (Salesforce) ↔ TimeTracking sync.
- Source: Salesforce placement, candidate and account records (ask me for object/field API names; mark them TODO).
- In: PUT /api/v1/clients/by-external/mysolution/{AccountId},
  PUT /api/v1/people/by-external/mysolution/{CandidateId}, PUT /api/v1/placements/by-external/mysolution/{PlacementId}
  with personExternalRef/clientExternalRef. Nightly: the same via POST /api/v1/{people|clients|placements}/batch (≤500 rows).
  Ended placements → DELETE /api/v1/placements/{id}. Send an Idempotency-Key per write.
- Out: poll GET /api/v1/days?status=approved,exported&updated_since=<last run>&limit=500 (follow next_cursor) and upsert
  Mysolution timesheet lines keyed on day id. On export.completed, download GET /api/v1/exports/runs/{runId}/file.
- Respect 429 Retry-After; log per-row batch errors without stopping.
```
