# Salesforce integration

> Sync Salesforce Accounts, Contacts and a custom Placement object with TimeTracking using Apex callouts to external-ID upserts, and pull approved hours with GET /days.

URL: https://timetracking.tech/developers/integrations/ats-crm/salesforce
Last updated: 2026-10-24

## 1. Summary

**Status:** Recipe (no native connector or AppExchange package).

**Direction:** Salesforce to TimeTracking for clients, people and placements; TimeTracking to Salesforce for approved hours (optional).

## 2. Mechanism

- **Salesforce to TimeTracking.** A record-triggered Flow on `Placement__c` enqueues an Apex Queueable that calls `PUT /{clients|people|placements}/by-external/salesforce/{18-char Id}`. The API key lives in a **Named Credential**.
- **TimeTracking to Salesforce.** A scheduled Apex job (or middleware) calls `GET /days?from&to&status=approved,exported&updated_since=<checkpoint>` and upserts `Timesheet_Day__c` on an external-id field holding the TimeTracking day `id`.
- Optional near-real-time trigger: the `timesheet.approved` webhook into middleware that verifies `tt-signature` and starts the same pull.

Assumption — verify with your Salesforce admin/consultant: object and field names (`Placement__c`, `Employee_Number__c`, `Timesheet_Day__c`) are examples; staffing packages built on Salesforce have their own objects.

## 3. Prerequisites

- Salesforce admin rights: Named Credential + External Credential, Apex deploy (sandbox first).
- Custom object `Timesheet_Day__c` with `TT_Day_Id__c` (Text 36, External ID, unique), `Placement__c` lookup, `Work_Date__c`, `Minutes__c`, `Status__c`.
- TimeTracking API key (`write` for upserts; a separate `read` key is enough for the pull).

## 4. Entity mapping

| Salesforce object.field | TimeTracking field | Notes |
|---|---|---|
| `Account.Id` | client external ref | |
| `Account.Name` | `name` | |
| `Account.Website` domain | `mailDomains` | |
| `Account.Timesheet_Approval__c` | `approvalMode` | `office` / `client` / `both`. |
| `Contact.Id` (worker) | person external ref | |
| `Contact.FirstName`, `LastName`, `Email`, `MobilePhone`, `Employee_Number__c` | person fields | |
| `Placement__c.Id` | placement external ref; `ref` `SF-<Id>` | |
| `Placement__c.Worker__c` / `Account__c` | `personExternalRef` / `clientExternalRef` | |
| `Start_Date__c` / `End_Date__c` | `startsOn` / `endsOn` | `String.valueOf(Date)` gives `YYYY-MM-DD`. |
| `Job_Title__c` | `roleTitle` | |
| `Status__c = Ended` | `active: false` (or `DELETE /placements/{id}`) | |
| `GET /days` item | `Timesheet_Day__c` | Upsert on `TT_Day_Id__c`; placement via `GET /placements/{placement_id}` → `external_ref` = Salesforce Id. |

**External-ID strategy.** `source` = `salesforce`, `ref` = 18-character record Id. No write-back of TimeTracking UUIDs is needed. Upserts are full replaces.

## 5. Step-by-step setup

1. Named Credential `TimeTracking`: URL `https://app.timetracking.tech/api/v1`, custom header `Authorization: Bearer {!$Credential.TimeTracking.ApiKey}`.
2. Apex:

```apex
public with sharing class TTPlacementSync implements Queueable, Database.AllowsCallouts {
    private Id placementId;
    public TTPlacementSync(Id placementId) { this.placementId = placementId; }

    private static Map<String, Object> put(String path, Map<String, Object> body, String idemKey) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:TimeTracking' + path);
        req.setMethod('PUT');
        req.setHeader('Content-Type', 'application/json');
        req.setHeader('Idempotency-Key', idemKey);
        req.setBody(JSON.serialize(body, true));
        HttpResponse res = new Http().send(req);
        Map<String, Object> json = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
        if (res.getStatusCode() >= 300) throw new CalloutException(res.getStatusCode() + ' ' + JSON.serialize(json.get('error')));
        return (Map<String, Object>) json.get('data');
    }

    public void execute(QueueableContext ctx) {
        Placement__c p = [SELECT Id, Start_Date__c, End_Date__c, Job_Title__c, Status__c, LastModifiedDate,
                            Account__r.Id, Account__r.Name, Worker__r.Id, Worker__r.FirstName, Worker__r.LastName,
                            Worker__r.Email, Worker__r.MobilePhone, Worker__r.Employee_Number__c
                          FROM Placement__c WHERE Id = :placementId];
        String v = String.valueOf(p.LastModifiedDate.getTime());
        put('/clients/by-external/salesforce/' + p.Account__r.Id,
            new Map<String, Object>{ 'name' => p.Account__r.Name, 'externalRef' => String.valueOf(p.Account__r.Id) }, 'sf-acc-' + p.Account__r.Id + '-' + v);
        put('/people/by-external/salesforce/' + p.Worker__r.Id, new Map<String, Object>{
            'employeeNumber' => p.Worker__r.Employee_Number__c, 'firstName' => p.Worker__r.FirstName,
            'lastName' => p.Worker__r.LastName, 'email' => p.Worker__r.Email, 'phone' => p.Worker__r.MobilePhone }, 'sf-con-' + p.Worker__r.Id + '-' + v);
        Map<String, Object> body = new Map<String, Object>{
            'personExternalRef' => String.valueOf(p.Worker__r.Id), 'clientExternalRef' => String.valueOf(p.Account__r.Id),
            'ref' => 'SF-' + p.Id, 'roleTitle' => p.Job_Title__c, 'startsOn' => String.valueOf(p.Start_Date__c),
            'active' => p.Status__c != 'Ended' };
        if (p.End_Date__c != null) body.put('endsOn', String.valueOf(p.End_Date__c));
        put('/placements/by-external/salesforce/' + p.Id, body, 'sf-pl-' + p.Id + '-' + v);
    }
}
```

3. Record-triggered Flow on `Placement__c` (after save, when dates/status/worker change) calling an invocable wrapper that enqueues `TTPlacementSync`.
4. Scheduled Apex (every 15 min) for the pull: call `GET /days?from=<today-60>&to=<today>&status=approved,exported&updated_since=<checkpoint>&limit=500`, follow `next_cursor`, upsert `Timesheet_Day__c` with `Database.upsert(records, Timesheet_Day__c.TT_Day_Id__c)`, then store the newest `updated_at` as the next checkpoint.

## 6. Sync schedule and triggering events

| Trigger | Action |
|---|---|
| `Placement__c` insert/update (Flow) | Queueable upserts. |
| Nightly scheduled Apex | Re-upsert placements changed in the last 24 h (repairs failed callouts). |
| Every 15 min, or `timesheet.approved` via middleware | Pull `GET /days?updated_since=…`. |
| `export.completed` | Optional: stamp exported days (they come through the pull with `status: exported`). |

## 7. Corrections, conflicts and locked periods

- Salesforce never edits hours. `Timesheet_Day__c` is overwritten by day `id`.
- A correction returns a day to `submitted` (excluded by the status filter) until re-approved; consider also pulling `submitted` to flag such rows.
- Exported/locked days are final (`423`).
- Shortening `End_Date__c` before existing hours fails with `409`; surface it on the record.

## 8. Testing checklist

- [ ] Creating a Placement in a sandbox returns `created: true` for all three upserts.
- [ ] Editing it returns `created: false` and does not duplicate.
- [ ] The Flow does not recurse.
- [ ] Pull is incremental (second run with the checkpoint returns only new changes).
- [ ] A `409` is captured on the record.

## 9. Troubleshooting

| Error | Fix |
|---|---|
| `System.CalloutException: Unauthorized endpoint` | Use the Named Credential (`callout:TimeTracking`). |
| `401` | Rotate the key in the External Credential. |
| `403` "API key is read-only" | Upserts need a `write` key. |
| `422` "Person and client must exist …" | Account/Contact upsert failed; check its error first. |
| `422` "Invalid cursor" | Restart the pull without `cursor`. |
| `409` "N registration(s) exist after …" | Do not shorten before existing hours. |
| `429` | Requeue after 60 s. |

## 10. Limits and known gaps

- `Idempotency-Key` responses are stored 24 h; keys above include `LastModifiedDate` so real changes are not replayed.
- AppExchange package with OAuth2: Planned.

## 11. Build it with Claude

```text
Write Salesforce metadata and Apex for a Placement__c ↔ TimeTracking sync. Named Credential "TimeTracking"
(base https://app.timetracking.tech/api/v1, Bearer key via External Credential). Outbound Queueable:
PUT /clients/by-external/salesforce/{AccountId} {name},
PUT /people/by-external/salesforce/{ContactId} {employeeNumber, firstName, lastName, email, phone},
PUT /placements/by-external/salesforce/{PlacementId} {personExternalRef, clientExternalRef, ref, roleTitle,
startsOn, endsOn, active}; full replace; Idempotency-Key header; responses { data } / { error }.
Inbound scheduled job: GET /days?from&to&status=approved,exported&updated_since&limit=500, follow next_cursor,
upsert Timesheet_Day__c on TT_Day_Id__c. Include Apex tests with HttpCalloutMock. Ask me for real API names.
```
