⌘K
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.
Last updated: · Markdown version
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__cenqueues an Apex Queueable that callsPUT /{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 upsertsTimesheet_Day__con an external-id field holding the TimeTracking dayid. - Optional near-real-time trigger: the
timesheet.approvedwebhook into middleware that verifiestt-signatureand 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__cwithTT_Day_Id__c(Text 36, External ID, unique),Placement__clookup,Work_Date__c,Minutes__c,Status__c. - TimeTracking API key (
writefor upserts; a separatereadkey 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
- Named Credential
TimeTracking: URLhttps://app.timetracking.tech/api/v1, custom headerAuthorization: Bearer {!$Credential.TimeTracking.ApiKey}. - 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);
}
}
- Record-triggered Flow on
Placement__c(after save, when dates/status/worker change) calling an invocable wrapper that enqueuesTTPlacementSync. - Scheduled Apex (every 15 min) for the pull: call
GET /days?from=<today-60>&to=<today>&status=approved,exported&updated_since=<checkpoint>&limit=500, follownext_cursor, upsertTimesheet_Day__cwithDatabase.upsert(records, Timesheet_Day__c.TT_Day_Id__c), then store the newestupdated_atas 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__cis overwritten by dayid. - A correction returns a day to
submitted(excluded by the status filter) until re-approved; consider also pullingsubmittedto flag such rows. - Exported/locked days are final (
423). - Shortening
End_Date__cbefore existing hours fails with409; surface it on the record.
8. Testing checklist
- Creating a Placement in a sandbox returns
created: truefor all three upserts. - Editing it returns
created: falseand does not duplicate. - The Flow does not recurse.
- Pull is incremental (second run with the checkpoint returns only new changes).
- A
409is 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-Keyresponses are stored 24 h; keys above includeLastModifiedDateso real changes are not replayed.- AppExchange package with OAuth2: Planned.
11. Build it with Claude
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.