Skip to content

Salesforce Setup

Sections 1–2 of the Salesforce integration guide — everything that has to exist in your org before any code runs. Start at the overview.

Enable the consent model. Setup → Data Protection and Privacy → Make data protection details available in records. This exposes the standard consent objects and adds an Individual lookup to Contact, Lead, User and Person Account.

Salesforce Setup, Data Protection and Privacy page, with “Make data protection details available in records” enabled

Use this rather than custom checkboxes. A checkbox cannot distinguish “declined” from “never asked,” and consent belongs to a person, not to a Contact record. Consent objects also don’t count against data storage.

Note the reminder on that screen: add the Individual field to your Lead, Contact and Person Account page layouts. The lookup exists via the API without it, but admins cannot see or set it in the UI, which makes the matching described in 5. Matching consents to people impossible to verify by hand.

You’ll write to:

Object Holds
Individual The person. Anchor record and join key.
DataUseLegalBasis The lawful basis — Consent, Legitimate Interest, etc.
DataUsePurpose Why you are processing — Marketing, Analytics, etc. One per purpose.
ContactPointTypeConsent The decision, per category per person.

Create the legal basis records first: DataUsePurpose.LegalBasisId is a lookup to DataUseLegalBasis, not a picklist, so the referenced record has to exist before a purpose can point at it. Record the statutory reference in Source — it costs nothing and answers the obvious audit question.

The Consent Data Use Legal Basis record in Salesforce, with GDPR Article 6(1)(a) recorded in the Source field

Then create one DataUsePurpose per purpose you intend to sync, each pointing at that legal basis. Set CanDataSubjectOptOut to reflect whether opt-out is actually available — true for consent-based purposes, potentially false for a legitimate-interest one.

The All Data Use Purposes list view in Salesforce, showing four purposes each pointing at the Consent legal basis

Add two custom fields. Mark both as External ID and Unique. This is what lets the sync run over and over without creating duplicate records: Salesforce matches on the ID you supply and updates the existing record instead of adding another one.

Object Label API name Type
Individual Secure Privacy Subject ID Secure_Privacy_Subject_Id__c Text(128)
ContactPointTypeConsent Secure Privacy Consent Key Secure_Privacy_Consent_Key__c Text(64)

On Individual, create Secure Privacy Subject ID as Text(128):

The Salesforce custom field wizard creating Secure Privacy Subject ID as Text length 128, marked External ID and Unique case-sensitive

At step 4 of the wizard, add it to the Individual layout so you can check values by eye during testing:

Step 4 of the custom field wizard, adding Secure Privacy Subject ID to the Individual page layout

Then repeat on ContactPointTypeConsent for Secure Privacy Consent Key as Text(64) — same Unique and External ID settings:

The Salesforce custom field wizard creating Secure Privacy Consent Key as Text length 64, marked External ID and Unique case-sensitive

Step 4 of the custom field wizard, adding Secure Privacy Consent Key to the Contact Point Type Consent page layout

After saving, confirm the field reports all three properties — the length, External ID, and Unique Case Sensitive:

The Set Field-Level Security screen for Secure Privacy Subject ID, showing Data Type “Text(128) (External ID) (Unique Case Sensitive)”

Create a dedicated integration user with a permission set granting read/create/edit on the consent objects, read on Contact and Lead, API Enabled, and no delete.

Grant field-level security on both custom fields. Missing field-level security is the most common cause of “no such column” errors — the field exists, the API just cannot see it. In the permission set, go to Object Settings → Individual → Field Permissions and tick Read and Edit for Secure Privacy Subject ID. Repeat on Contact Point Type Consent for the consent key.

Permission set Field Permissions for the Individual object, with Read and Edit ticked for Secure Privacy Subject ID only

Turn on field history tracking for PrivacyConsentStatus. Free audit trail.


Use JWT bearer. Generate a key pair, then create an External Client App: App Manager → New External Client App. Enable OAuth, set a callback URL (unused by this flow, but the form requires one), and select the scopes api and refresh_token offline_access.

The External Client App OAuth settings, with a callback URL set and the api and refresh_token offline_access scopes selected

Further down the same form, under Flow Enablement, tick Enable JWT Bearer Flow — that is what reveals the certificate upload. Keep the private key in a secret manager, and note the certificate expiry: an expired signing certificate is a silent, total outage.

The Flow Enablement section of the External Client App form, with Enable JWT Bearer Flow ticked and the certificate upload revealed

After saving, set Permitted Users to Admin approved users are pre-authorised under Policies → OAuth Policies, then assign the integration user’s permission set to the app. Missing this step produces a bare invalid_grant with no useful detail, and it is the most common reason a correctly configured certificate still fails.

The External Client App OAuth Policies, with Permitted Users set to “Admin approved users are pre-authorized”

// Token acquisition + refresh. Cache it, but refresh on expiry -- a one-way
// isConnected flag means every call fails after the session times out until
// someone restarts the process.
let cached: { token: string; instanceUrl: string; at: number } | null = null;
const REFRESH_AFTER_MS = 20 * 60 * 1000;
async function getConnection(force = false): Promise<Connection> {
if (force || !cached || Date.now() - cached.at > REFRESH_AFTER_MS) {
const assertion = jwt.sign(
{
iss: config.clientId,
sub: config.integrationUsername,
aud: config.loginUrl, // test.salesforce.com for sandboxes
exp: Math.floor(Date.now() / 1000) + 180,
},
config.privateKey,
{ algorithm: 'RS256' },
);
const res = await fetch(`${config.loginUrl}/services/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!res.ok) throw new Error(`JWT exchange failed: ${res.status}`);
const data = await res.json();
cached = { token: data.access_token, instanceUrl: data.instance_url, at: Date.now() };
}
return new Connection({
instanceUrl: cached.instanceUrl,
accessToken: cached.token,
version: '67.0',
});
}
// Retry once on session expiry.
async function withSession<T>(fn: (c: Connection) => Promise<T>): Promise<T> {
try {
return await fn(await getConnection());
} catch (e: any) {
if (e?.errorCode !== 'INVALID_SESSION_ID') throw e;
return fn(await getConnection(true));
}
}

Verify the whole chain before writing any integration code — sf org login jwt with the same certificate, client ID and username will succeed only if the certificate, the flow toggle and the permitted-users policy are all correct together.

Client credentials is a valid simpler alternative — enable it on the app, set Run As to the integration user, swap grant_type for client_credentials with client_id/secret. Everything downstream is identical. The trade-off is a long-lived secret instead of a certificate.

Use jsforce v3 (import { Connection } from 'jsforce' — no default export) and don’t install @types/jsforce; v3 ships its own types and the two conflict.