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.

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__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:
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);
    }
}
  1. Record-triggered Flow on Placement__c (after save, when dates/status/worker change) calling an invocable wrapper that enqueues TTPlacementSync.
  2. 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

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.