# Salesforce Service Cloud

> Send Salesforce Service Cloud case emails to Dimension Labs with the universal API, plus messaging transcripts and opportunity data as files.

This brings in the emails on your Service Cloud cases, one conversation per case. You run it on your side: a small script queries Salesforce with SOQL and posts each email to the [universal API](/api/tracker). Messaging transcripts and sales data (opportunities and accounts) come in as files: see [Messaging sessions](#messaging-sessions) and [Sales data](#sales-data-opportunities-and-accounts).

## What you need

- A Salesforce admin who can create an external client app, and a user for it to run as with read access to Case and EmailMessage.
- The app's consumer key and consumer secret, used with the OAuth 2.0 client credentials flow.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Salesforce

Salesforce recommends external client apps for API access. Creating new connected apps is restricted as of Spring '26 ([authorization through external client apps or connected apps](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/intro_oauth_and_connected_apps.htm)).

1. From **Setup**, enter **External Client Apps** in the Quick Find box and create a new app.
2. Enable OAuth settings and add the **Manage user data via APIs (api)** scope.
3. Select **Enable Client Credentials Flow**.
4. In the app's policies, under **Client Credentials Flow**, set **Run As** to the integration user.
5. Copy the consumer key and consumer secret.

Then request an access token. The response includes `access_token` and `instance_url` ([connect using OAuth](https://developer.salesforce.com/docs/atlas.en-us.chatterapi.meta/chatterapi/quickstart_connecting.htm)):

```bash
curl -X POST https://YOUR_DOMAIN.my.salesforce.com/services/oauth2/token \
  -d 'grant_type=client_credentials' \
  -d "client_id=$SF_CLIENT_ID" -d "client_secret=$SF_CLIENT_SECRET"
```

Send the token as `Authorization: Bearer ACCESS_TOKEN` on every call.

## 2. Pull new case emails

Use the [Query resource](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_query.htm), `GET /services/data/v67.0/query?q=SOQL`, and filter on the `CreatedDate` [system field](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/system_fields.htm) to get only what's new since your cursor.

[EmailMessage](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_emailmessage.htm) records with a `ParentId` belong to a case, and `Parent` gives you the case's fields:

```sql
SELECT Id, ParentId, Parent.CaseNumber, Parent.ContactId, Parent.AccountId, Parent.Origin, Parent.Status,
       Incoming, TextBody, Subject, CreatedDate
FROM EmailMessage
WHERE ParentId != null AND CreatedDate >= 2026-10-13T00:00:00Z
ORDER BY CreatedDate
```

SOQL dateTime values take the form `YYYY-MM-DDThh:mm:ssZ`, without milliseconds ([date formats](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_dateformats.htm)). A query returns up to 2,000 records at a time. When `done` is `false`, request the `nextRecordsUrl` from the response to get the next batch ([execute a SOQL query](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm)).

`TextBody` usually includes the earlier emails of the thread, quoted below the reply. The code below cuts each body at the first quoted-reply marker it recognizes, so each message carries only its new text. Check a few of your own emails: the markers depend on your customers' email clients.

### Messaging sessions

For standard SMS, WhatsApp and Facebook Messenger channels, each message is a [ConversationEntry](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_conversationentry.htm) record whose `ConversationId` is the [MessagingSession](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_messagingsession.htm) id. Salesforce notes that these records aren't created until the session ends and the agent closes the session tab, except the first message of a session, and that for enhanced Messaging channels and Messaging for In-App and Web the `Message` field is blank or no record is created. Salesforce points to Data Cloud, the Connect REST API, or the Conversation Transcript Export tool for those transcripts. The Connect REST API [conversation entries](https://developer.salesforce.com/docs/atlas.en-us.chatterapi.meta/chatterapi/connect_resources_conversation_entries.htm) resource is meant for single conversations, not bulk export, and has its own hourly rate limit.

So for messaging, export transcripts with Salesforce's Conversation Transcript Export and [upload the file](/data/upload-a-file): one row per message, the MessagingSession id as `sessionId`, the session's `MessagingEndUserId` as `userId`, and `incoming` `true` only when the entry's `ActorType` is `EndUser`. Then run a [backfill](/dimensions/backfill).

## 3. Map the fields

| Dimension Labs | Salesforce | Notes |
|---|---|---|
| `text` | EmailMessage `TextBody` | Plain text. Cut the quoted thread. Skip empty values. |
| `userId` | Case `ContactId` (via `Parent.ContactId`) | Salesforce's internal contact id. Fall back to the case id when it's empty. Don't use `FromAddress`: it is an email address. |
| `incoming` | EmailMessage `Incoming` | `true` for a received email. |
| `sessionId` | EmailMessage `ParentId` (the case id) | Salesforce ids have no colons. |
| `conversationId` | Same as `sessionId` | |
| `dimensionlabs_timestamp` | EmailMessage `CreatedDate` | Convert with `toMillis`. |
| `platformJson` | Case `CaseNumber`, `Origin`, `Status`, `AccountId`; EmailMessage `Subject` | `AccountId` lets you join to account data, such as opportunities. |

**Personal data.** Email bodies hold signatures, names and phone numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// salesforce.mjs. Environment: SF_DOMAIN, SF_CLIENT_ID, SF_CLIENT_SECRET, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const soqlTime = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
// Cuts the quoted earlier emails: "On ... wrote:", "-----Original Message-----", or a "From:" header line.
const newText = (body) => body.split(/\n(?:On .+wrote:|-{2,} ?Original Message ?-{2,}|From: .+)\s*\n/i)[0].trim();

async function token() {
  const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.SF_CLIENT_ID, client_secret: process.env.SF_CLIENT_SECRET });
  const res = await fetch(`https://${process.env.SF_DOMAIN}.my.salesforce.com/services/oauth2/token`, { method: 'POST', body });
  if (!res.ok) throw new Error(`Salesforce token request returned ${res.status}: ${await res.text()}`);
  return res.json(); // { access_token, instance_url }
}

// Sends every case email created at or after `since` (Unix ms). Returns the new `since`.
export async function syncSalesforce({ since, historical = false }) {
  const { access_token, instance_url } = await token();
  // Retries server errors (5xx) and rate limits with a growing pause.
  const get = async (url) => {
    for (let attempt = 0; ; attempt++) {
      const res = await fetch(url, { headers: { Authorization: `Bearer ${access_token}` } });
      if (res.ok) return res.json();
      if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Salesforce returned ${res.status}: ${await res.text()}`);
      await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
    }
  };
  const soql = `SELECT Id, ParentId, Parent.CaseNumber, Parent.ContactId, Parent.AccountId, Parent.Origin, Parent.Status, Incoming, TextBody, Subject, CreatedDate FROM EmailMessage WHERE ParentId != null AND CreatedDate >= ${soqlTime(since)} ORDER BY CreatedDate`;
  let path = `/services/data/v67.0/query?q=${encodeURIComponent(soql)}`;
  let newest = since;
  while (path) {
    const page = await get(instance_url + path);
    for (const e of page.records) {
      const text = e.TextBody ? newText(e.TextBody) : '';
      if (!text) continue;
      const ts = toMillis(e.CreatedDate);
      await sendToDimensionLabs({
        text,
        userId: e.Parent?.ContactId ?? e.ParentId,
        incoming: e.Incoming,
        sessionId: e.ParentId,
        conversationId: e.ParentId,
        dimensionlabs_timestamp: ts,
        platformJson: { case_number: e.Parent?.CaseNumber, origin: e.Parent?.Origin, status: e.Parent?.Status, account_id: e.Parent?.AccountId, subject: e.Subject },
      }, { historical: historical || ts < Date.now() - HOUR });
      newest = Math.max(newest, ts);
    }
    path = page.done ? null : page.nextRecordsUrl;
  }
  return newest;
}
```

## 5. Run it

Save the code as `salesforce.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncSalesforce as sync } from './salesforce.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every case email since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new emails to `/track`, and they are enriched automatically every day. An email that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

You can also [upload a file](/data/upload-a-file) of exported emails or transcripts (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Sales data: opportunities and accounts

Opportunities, accounts and renewals are business data, not conversations. They are never enriched, and they come in by file upload only today (an API for business data is on the roadmap).

1. Export the records to CSV, for example with a Salesforce report or Data Loader, or with SOQL such as `SELECT Id, AccountId, StageName, Amount, CloseDate, Type FROM Opportunity`.
2. Keep `AccountId`: the case emails above carry the same id in `platformJson.account_id`, which is what lets the agent join the two. Leave out contact names and emails.
3. In **Data Sources**, upload the file under **Structured data**. It is queryable within about a minute. See [Business data format](/data/business-data-format) and [Join business data](/data/join-business-data).

To refresh it, upload a new export on your own schedule.

## Salesforce reference

- [Authorization through external client apps or connected apps](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/intro_oauth_and_connected_apps.htm)
- [Create an external client app for the OAuth 2.0 client credentials flow](https://developer.salesforce.com/docs/industries/loyalty/guide/authorization.html)
- [Connect using OAuth (token request)](https://developer.salesforce.com/docs/atlas.en-us.chatterapi.meta/chatterapi/quickstart_connecting.htm)
- [Query resource](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_query.htm)
- [Execute a SOQL query](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_query.htm)
- [SOQL date formats](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_dateformats.htm)
- [System fields](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/system_fields.htm)
- [EmailMessage](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_emailmessage.htm)
- [Case](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_case.htm)
- [MessagingSession](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_messagingsession.htm)
- [ConversationEntry](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_conversationentry.htm)
- [Conversation entries (Connect REST API)](https://developer.salesforce.com/docs/atlas.en-us.chatterapi.meta/chatterapi/connect_resources_conversation_entries.htm)

Checked against Salesforce's documentation on 2026-10-13.
