Salesforce is the enterprise CRM that underpins the revenue operations of most large organizations. When your product needs to integrate with an enterprise customer's Salesforce instance, the stakes are high: the data is critical, the volumes are large, and the organizational dependency is deep.
This guide covers the full Salesforce integration surface authentication with Connected Apps and JWT, REST and Bulk APIs, Platform Events, Change Data Capture, and enterprise automation workflows across every major frontend and backend stack.
Overview
Salesforce is a cloud-based CRM platform that has been the dominant system of record for enterprise revenue teams for over two decades. Its data model is built around a set of standard objects Accounts, Contacts, Leads, Opportunities, Cases, Campaigns, and Activities that represent the full arc of a B2B customer relationship from initial interest through closed deal, ongoing support, and renewal.
Standard Salesforce objects relevant to most integrations:
- Accounts: companies or organizations that your business has a relationship with; the anchor record for B2B data
- Contacts: individual people associated with accounts; linked to activities, opportunities, and cases
- Leads: unqualified prospects that have not yet been associated with an account; converted to Contact + Account + Opportunity when qualified
- Opportunities: potential revenue-generating deals at various pipeline stages, each with an amount, close date, and probability
- Cases: customer support and service requests linked to accounts and contacts
- Campaigns: marketing initiatives with associated campaign members tracking response and attribution
- Custom Objects: organization-specific data models defined in Salesforce with the same API access as standard objects
- Flows and Apex: Salesforce's native automation layer that can trigger actions, update records, and call external systems
Salesforce dominates enterprise CRM for several structural reasons. Its data model is flexible enough to represent almost any B2B business process through custom objects and relationships. Its automation platform (Flows, Process Builder, Apex triggers) allows business users and developers to build workflows without external code. Its AppExchange ecosystem connects Salesforce to virtually every enterprise system category. And its reporting and forecasting tools give leadership teams a single, authoritative view of pipeline, revenue, and customer health.
For software teams, this dominance means that any product serving enterprise customers will eventually receive a Salesforce integration requirement. Understanding the integration surface thoroughly before that requirement arrives saves weeks of architectural rework.
Business Benefits
Single Source of Truth for Customer Data
In most enterprises, customer data lives in four to eight systems: the product, the CRM, the ERP, the support desk, the marketing automation platform, and billing. A Salesforce integration collapses this into a unified record that all teams work from. When a sales rep opens an account, they see product usage data. When customer success opens a contact, they see deal history and support cases side by side. Decisions get faster because the data is in one place.
Sales Pipeline Visibility and Forecasting
Salesforce Opportunities are the basis for revenue forecasting. When your product automatically creates or updates opportunities based on real customer behavior a trial activation, a usage threshold, an upgrade event the pipeline becomes accurate in real time rather than dependent on salespeople manually updating deal stages. Leaders can forecast with confidence because the data reflects actual product signals, not optimistic estimates.
Automated Lead Routing and Qualification
A product-qualified lead a user who has activated a feature, hit a usage milestone, or completed a meaningful trial flow can be automatically created as a Salesforce Lead or Opportunity with full context attached: company size, usage data, trial start date, and activation events. The lead routes to the right owner based on territory rules, and the rep receives a notification before the prospect has raised their hand. This compresses the time-to-first-contact from days to minutes.
Customer Lifecycle Tracking
An integrated Salesforce instance tracks the complete customer journey: from first website interaction (Campaign membership) through lead qualification (Lead conversion), deal closing (Opportunity stage), onboarding, and expansion (additional Opportunities). When churn risk signals appear in your product, that context is immediately visible in Salesforce so customer success can act. When a customer expands their usage, a renewal opportunity is created automatically.
Enterprise Reporting and Compliance
Enterprise customers often require Salesforce as the system of record for audit, compliance, and board reporting. A reliable integration ensures that the data in Salesforce reflects reality not a two-week-old export. Revenue reports are accurate, compliance audits can be run against live data, and leadership teams make investment decisions with confidence that the numbers represent the current state of the business.
Architecture Overview
Enterprise Salesforce integrations require a layered architecture. The frontend never touches Salesforce directly. A backend service layer owns all Salesforce API credentials and all outbound calls. Inbound events from Salesforce are received via Platform Events or Change Data Capture, verified, queued, and processed asynchronously.
flowchart TD
FE["Frontend\nReact / Next.js / Angular"] -- "User actions\n(lead submit, deal view)" --> API["Backend API Layer"]
API -- "REST / Bulk API\n(CRUD, SOQL queries)" --> SF["Salesforce APIs\nv59.0 REST + Bulk v2"]
SF -- "CRM Platform\nAccounts / Leads / Opportunities" --> SFDB[("Salesforce Org\n+ Custom Objects")]
SFDB -- "Platform Events\n(CDC, custom events)" --> PE["Platform Event Bus"]
PE -- "Streamed via CometD\n(jsforce / Bayeux)" --> SUB["Event Subscriber\n(your backend)"]
SUB -- "Enqueue event" --> Q["Message Queue\nBullMQ / SQS / RabbitMQ"]
Q -- "Process event" --> WORKER["Background Worker"]
WORKER -- "Sync state" --> DB[("Your Database")]
API -- "Cache SOQL lookups" --> CACHE["Redis Cache"]
API -- "Large imports" --> BULK["Bulk API v2 Worker"]Integration Patterns
- Real-time sync: product event fires a background job that immediately calls the Salesforce REST API to create or update a record; suitable for high-value events (lead creation, deal stage changes, support case creation)
- Scheduled sync: a cron job runs every N minutes and queries Salesforce via SOQL for records changed since the last run using SystemModstamp filtering; suitable for lower-priority data consistency checks and reconciliation
- Event-driven sync: Salesforce publishes a Platform Event or Change Data Capture event; your subscriber receives it via CometD streaming, queues it, and processes it asynchronously; suitable for Salesforce-initiated changes that your product needs to react to
- Bulk sync: Salesforce Bulk API v2 handles millions of records in a single job; use for initial data migrations, nightly reconciliation of large object types, and large-scale backfills
Architectural rule: your backend owns the Salesforce OAuth tokens and all API calls. Your frontend never communicates with Salesforce directly. Platform Events are received by a dedicated subscriber process running server-side, not by client code. All event processing happens in a queue worker, not synchronously in the event handler.
Frontend Integration
The frontend's role in a Salesforce integration is capturing user input and displaying CRM data retrieved through your backend. All Salesforce API calls including authentication, querying, and record creation are made exclusively from the backend. The frontend communicates only with your own API layer.
React
React handles Salesforce-connected interfaces through custom hooks that abstract backend API calls. Lead capture forms, opportunity dashboards, and account detail views all fetch data from your API, which fetches from Salesforce.
import { useState } from "react";
interface LeadPayload {
firstName: string;
lastName: string;
email: string;
company: string;
title?: string;
phone?: string;
}
export function useSalesforceLead() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submitLead(data: LeadPayload) {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/crm/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(await res.text());
return await res.json();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Submission failed");
return null;
} finally {
setLoading(false);
}
}
return { submitLead, loading, error };
}Do not expose Salesforce record IDs directly to the frontend. Return your internal record IDs and resolve them to Salesforce IDs server-side. This prevents clients from constructing Salesforce API calls and keeps your integration layer replaceable without frontend changes.
Next.js
Next.js server actions run entirely on the server, making them ideal for Salesforce lead creation from marketing pages and landing forms. The form data never reaches the client-side JavaScript bundle, and the Salesforce credentials never leave the server.
"use server";
import { getSalesforceClient } from "@/lib/salesforce";
import { redirect } from "next/navigation";
export async function submitEnterpriseLeadForm(formData: FormData) {
const sf = await getSalesforceClient();
const result = await sf.sobject("Lead").upsert(
{
FirstName: formData.get("firstName") as string,
LastName: formData.get("lastName") as string,
Email: formData.get("email") as string,
Company: formData.get("company") as string,
Title: formData.get("title") as string,
LeadSource: "Website",
},
"Email"
);
if (!result.success) {
throw new Error("Lead creation failed");
}
redirect("/thank-you");
}Vue.js
Vue 3 composables abstract Salesforce-related API calls and expose reactive state for use across components. Separate composables for leads, opportunities, and account lookups keep concern boundaries clean and make testing straightforward.
import { ref } from "vue";
export function useSalesforceLead() {
const loading = ref(false);
const error = ref<string | null>(null);
const leadId = ref<string | null>(null);
async function createLead(payload: {
firstName: string;
lastName: string;
email: string;
company: string;
}) {
loading.value = true;
error.value = null;
try {
const res = await fetch("/api/crm/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Lead creation failed");
const data = await res.json();
leadId.value = data.id;
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Error";
} finally {
loading.value = false;
}
}
return { createLead, loading, error, leadId };
}Angular
Angular enterprise applications benefit from a centralized SalesforceService registered at the root level. This single service handles all CRM interactions and is injected into whichever components and feature modules need access. Angular's HttpClient with interceptors manages authentication headers, retry logic, and error normalization consistently across all Salesforce API calls.
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { retry, catchError, throwError } from "rxjs/operators";
interface Lead { firstName: string; lastName: string; email: string; company: string; }
@Injectable({ providedIn: "root" })
export class SalesforceService {
constructor(private http: HttpClient) {}
createLead(lead: Lead): Observable<{ id: string }> {
return this.http
.post<{ id: string }>("/api/crm/leads", lead)
.pipe(retry(2), catchError((err) => throwError(() => err)));
}
getOpportunities(accountId: string): Observable<unknown[]> {
return this.http
.get<unknown[]>(`/api/crm/accounts/${accountId}/opportunities`)
.pipe(retry(2), catchError((err) => throwError(() => err)));
}
searchAccounts(query: string): Observable<unknown[]> {
return this.http
.get<unknown[]>(`/api/crm/accounts?q=${encodeURIComponent(query)}`)
.pipe(catchError((err) => throwError(() => err)));
}
}Svelte
SvelteKit form actions handle Salesforce lead creation server-side with progressive enhancement. The form submits to a named action, which runs entirely on the server and calls your Salesforce service layer before redirecting. No Salesforce logic or credentials reach the browser.
import type { Actions } from "./$types";
import { createSalesforceLead } from "$lib/salesforce";
export const actions: Actions = {
submitLead: async ({ request }) => {
const data = await request.formData();
await createSalesforceLead({
firstName: String(data.get("firstName")),
lastName: String(data.get("lastName")),
email: String(data.get("email")),
company: String(data.get("company")),
});
return { success: true };
},
};Solid.js
Solid.js createResource handles asynchronous Salesforce data fetching with fine-grained reactivity. Use a signal as the query key so that changing the selected account ID automatically triggers a refetch of that account's opportunities without re-rendering unrelated UI.
Qwik
Qwik's routeAction$ and routeLoader$ run on the server, making them natural integration points for Salesforce calls. A routeLoader$ pre-fetches account data before the page renders. A routeAction$ handles lead form submissions server-side. Neither adds any JavaScript bundle cost to the client for the CRM interaction itself.
Astro
Astro API endpoints handle Salesforce form submissions at the edge, create leads in Salesforce, and redirect to confirmation pages. Static marketing pages with Astro-powered lead forms are fully decoupled from the React or Vue components that may render the rest of the site, with Salesforce calls running entirely at the Astro server layer.
import type { APIRoute } from "astro";
import { createSalesforceLead } from "../../lib/salesforce";
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const result = await createSalesforceLead(body);
return new Response(JSON.stringify({ id: result.id }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
};Backend Integration
Salesforce provides official SDKs for JavaScript and Java. For all other languages, the Salesforce REST API is well-documented and straightforward to call with any HTTP client. All integrations should use the latest available API version (v59.0 at time of writing) and a dedicated service class that centralizes all Salesforce logic.
Node.js
npm install jsforcejsforce is the most complete Salesforce client for Node.js. It supports OAuth flows, SOQL queries, CRUD on all Salesforce objects, Bulk API v2, and Platform Event streaming via CometD. Instantiate a single connection and reuse it across all requests, refreshing the access token when it expires.
import jsforce from "jsforce";
let connection: jsforce.Connection | null = null;
export async function getSalesforceClient(): Promise<jsforce.Connection> {
if (connection) return connection;
connection = new jsforce.Connection({
instanceUrl: process.env.SF_INSTANCE_URL,
accessToken: process.env.SF_ACCESS_TOKEN,
});
return connection;
}
export async function upsertLead(email: string, fields: Record<string, string>) {
const sf = await getSalesforceClient();
const result = await sf.sobject("Lead").upsert(
{ Email: email, ...fields },
"Email"
);
if (Array.isArray(result)) return result[0];
return result;
}
export async function queryLeads(soql: string) {
const sf = await getSalesforceClient();
const result = await sf.query(soql);
return result.records;
}
export async function createOpportunity(fields: {
Name: string;
AccountId: string;
StageName: string;
CloseDate: string;
Amount: number;
}) {
const sf = await getSalesforceClient();
return sf.sobject("Opportunity").create(fields);
}NestJS
Wrap the jsforce client in an injectable NestJS service. Register SalesforceModule as a global module so any feature module can inject SalesforceService without re-importing the module. Use OnModuleInit to establish the connection at startup and log the connected Salesforce org.
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import jsforce, { Connection } from "jsforce";
@Injectable()
export class SalesforceService implements OnModuleInit {
private conn: Connection;
private readonly logger = new Logger(SalesforceService.name);
constructor(private config: ConfigService) {}
async onModuleInit() {
this.conn = new jsforce.Connection({
instanceUrl: this.config.get<string>("SF_INSTANCE_URL"),
accessToken: this.config.get<string>("SF_ACCESS_TOKEN"),
});
this.logger.log("Salesforce connection initialized");
}
async createLead(fields: Record<string, string>) {
this.logger.log(`Creating SF Lead: ${fields["Email"]}`);
return this.conn.sobject("Lead").create(fields);
}
async updateOpportunityStage(id: string, stageName: string) {
this.logger.log(`Updating Opportunity ${id} to ${stageName}`);
return this.conn.sobject("Opportunity").update({ Id: id, StageName: stageName });
}
async query<T>(soql: string): Promise<T[]> {
const result = await this.conn.query<T>(soql);
return result.records;
}
async bulkUpsert(objectType: string, records: object[], externalIdField: string) {
const job = this.conn.bulk.createJob(objectType, "upsert", {
extIdField: externalIdField,
});
const batch = job.createBatch();
return new Promise((resolve, reject) => {
batch.execute(records);
batch.on("response", resolve);
batch.on("error", reject);
});
}
}Python FastAPI
pip install httpximport httpx
import os
from typing import Any
SF_INSTANCE = os.getenv("SF_INSTANCE_URL")
SF_TOKEN = os.getenv("SF_ACCESS_TOKEN")
SF_API = f"{SF_INSTANCE}/services/data/v59.0"
HEADERS = {
"Authorization": f"Bearer {SF_TOKEN}",
"Content-Type": "application/json",
}
async def upsert_lead(email: str, fields: dict[str, Any]) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
# Check for existing lead
soql = f"SELECT Id FROM Lead WHERE Email = '{email}' LIMIT 1"
search = await client.get(f"{SF_API}/query", params={"q": soql}, headers=HEADERS)
search.raise_for_status()
records = search.json().get("records", [])
if records:
lead_id = records[0]["Id"]
res = await client.patch(
f"{SF_API}/sobjects/Lead/{lead_id}",
headers=HEADERS,
json=fields,
)
res.raise_for_status()
return {"id": lead_id, "created": False}
else:
res = await client.post(
f"{SF_API}/sobjects/Lead",
headers=HEADERS,
json={"Email": email, **fields},
)
res.raise_for_status()
return {**res.json(), "created": True}
async def create_opportunity(fields: dict) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
res = await client.post(
f"{SF_API}/sobjects/Opportunity",
headers=HEADERS,
json=fields,
)
res.raise_for_status()
return res.json()Laravel
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class SalesforceService
{
private string $instanceUrl;
private string $apiBase;
public function __construct()
{
$this->instanceUrl = config('services.salesforce.instance_url');
$this->apiBase = $this->instanceUrl . '/services/data/v59.0';
}
private function headers(): array
{
return [
'Authorization' => 'Bearer ' . config('services.salesforce.access_token'),
'Content-Type' => 'application/json',
];
}
public function upsertLead(string $email, array $fields): array
{
$soql = "SELECT Id FROM Lead WHERE Email = '{$email}' LIMIT 1";
$search = Http::withHeaders($this->headers())
->get("{$this->apiBase}/query", ['q' => $soql])
->throw()->json();
if (!empty($search['records'])) {
$id = $search['records'][0]['Id'];
Http::withHeaders($this->headers())
->patch("{$this->apiBase}/sobjects/Lead/{$id}", $fields)
->throw();
return ['id' => $id, 'created' => false];
}
$result = Http::withHeaders($this->headers())
->post("{$this->apiBase}/sobjects/Lead", array_merge(['Email' => $email], $fields))
->throw()->json();
return ['id' => $result['id'], 'created' => true];
}
public function createOpportunity(array $fields): array
{
return Http::withHeaders($this->headers())
->post("{$this->apiBase}/sobjects/Opportunity", $fields)
->throw()->json();
}
}Django
import requests
import os
SF_API = os.getenv("SF_INSTANCE_URL") + "/services/data/v59.0"
HEADERS = {
"Authorization": f"Bearer {os.getenv('SF_ACCESS_TOKEN')}",
"Content-Type": "application/json",
}
def upsert_lead(email: str, fields: dict) -> dict:
soql = f"SELECT Id FROM Lead WHERE Email = '{email}' LIMIT 1"
search = requests.get(f"{SF_API}/query", params={"q": soql}, headers=HEADERS)
search.raise_for_status()
records = search.json().get("records", [])
if records:
lead_id = records[0]["Id"]
requests.patch(
f"{SF_API}/sobjects/Lead/{lead_id}",
headers=HEADERS,
json=fields,
).raise_for_status()
return {"id": lead_id, "created": False}
res = requests.post(
f"{SF_API}/sobjects/Lead",
headers=HEADERS,
json={"Email": email, **fields},
)
res.raise_for_status()
return {**res.json(), "created": True}
def run_soql(soql: str) -> list:
res = requests.get(f"{SF_API}/query", params={"q": soql}, headers=HEADERS)
res.raise_for_status()
return res.json().get("records", [])Go
package salesforce
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
)
var (
instanceURL = os.Getenv("SF_INSTANCE_URL")
accessToken = os.Getenv("SF_ACCESS_TOKEN")
apiBase = instanceURL + "/services/data/v59.0"
)
func authHeader() string { return "Bearer " + accessToken }
func CreateLead(fields map[string]interface{}) (string, error) {
body, _ := json.Marshal(fields)
req, _ := http.NewRequest("POST", apiBase+"/sobjects/Lead", bytes.NewBuffer(body))
req.Header.Set("Authorization", authHeader())
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct{ Id string }
json.NewDecoder(resp.Body).Decode(&result)
return result.Id, nil
}
func QuerySOQL(soql string) ([]map[string]interface{}, error) {
u := apiBase + "/query?q=" + url.QueryEscape(soql)
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Authorization", authHeader())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Records []map[string]interface{} `json:"records"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode error: %w", err)
}
return result.Records, nil
}Spring Boot
Salesforce provides an official Java SDK (force-rest-api) but most Spring Boot teams use WebClient directly against the Salesforce REST API. Register a SalesforceClient bean with the instance URL base path and Authorization header. Wrap all calls in a service with retry using Spring Retry and a circuit breaker for resilience.
@Service
public class SalesforceService {
private final WebClient webClient;
private final String apiBase;
public SalesforceService(
@Value("${sf.instance-url}") String instanceUrl,
@Value("${sf.access-token}") String token
) {
this.apiBase = instanceUrl + "/services/data/v59.0";
this.webClient = WebClient.builder()
.baseUrl(apiBase)
.defaultHeader("Authorization", "Bearer " + token)
.defaultHeader("Content-Type", "application/json")
.build();
}
public Mono<Map> createLead(Map<String, Object> fields) {
return webClient.post()
.uri("/sobjects/Lead")
.bodyValue(fields)
.retrieve()
.onStatus(HttpStatusCode::isError, r ->
Mono.error(new RuntimeException("SF error: " + r.statusCode())))
.bodyToMono(Map.class);
}
public Mono<Map> querySOQL(String soql) {
return webClient.get()
.uri(uriBuilder -> uriBuilder.path("/query").queryParam("q", soql).build())
.retrieve()
.bodyToMono(Map.class);
}
}ASP.NET Core
Register a typed HttpClient for Salesforce in Program.cs with the base address and Authorization header. Inject SalesforceService into controllers and other services via the DI container. Add Polly retry and circuit breaker policies to handle transient Salesforce API failures gracefully.
public class SalesforceService
{
private readonly HttpClient _http;
private readonly string _apiBase;
public SalesforceService(HttpClient http, IConfiguration config)
{
_http = http;
_apiBase = config["Salesforce:InstanceUrl"] + "/services/data/v59.0";
}
public async Task<string> CreateLeadAsync(Dictionary<string, object> fields)
{
var res = await _http.PostAsJsonAsync($"{_apiBase}/sobjects/Lead", fields);
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<JsonElement>();
return body.GetProperty("id").GetString()!;
}
public async Task<JsonElement> QueryAsync(string soql)
{
var encoded = Uri.EscapeDataString(soql);
var res = await _http.GetAsync($"{_apiBase}/query?q={encoded}");
res.EnsureSuccessStatusCode();
return await res.Content.ReadFromJsonAsync<JsonElement>();
}
public async Task UpdateRecordAsync(string objectType, string id, Dictionary<string, object> fields)
{
var res = await _http.PatchAsJsonAsync($"{_apiBase}/sobjects/{objectType}/{id}", fields);
res.EnsureSuccessStatusCode();
}
}Rust
use reqwest::Client;
use serde_json::{json, Value};
use std::collections::HashMap;
pub struct SalesforceClient {
client: Client,
api_base: String,
token: String,
}
impl SalesforceClient {
pub fn new(instance_url: &str, token: &str) -> Self {
Self {
client: Client::new(),
api_base: format!("{}/services/data/v59.0", instance_url),
token: token.to_owned(),
}
}
pub async fn create_lead(&self, fields: HashMap<&str, &str>) -> Result<String, reqwest::Error> {
let payload: Value = fields.iter().map(|(k, v)| (*k, json!(v))).collect();
let res: Value = self
.client
.post(format!("{}/sobjects/Lead", self.api_base))
.bearer_auth(&self.token)
.json(&payload)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(res["id"].as_str().unwrap_or("").to_owned())
}
pub async fn query(&self, soql: &str) -> Result<Vec<Value>, reqwest::Error> {
let res: Value = self
.client
.get(format!("{}/query", self.api_base))
.query(&[("q", soql)])
.bearer_auth(&self.token)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(res["records"].as_array().cloned().unwrap_or_default())
}
}Authentication and Security
Salesforce authentication is built around Connected Apps OAuth 2.0 application registrations inside a Salesforce org that define permissions, allowed scopes, and the callback URL for authorization flows. Every integration requires a Connected App.
Connected Apps and OAuth Scopes
Create a Connected App in Salesforce Setup. Set the OAuth scopes to the minimum required: api (REST API access), refresh_token (for token refresh), and optionally offline_access. Additional scopes like full give broader access but violate the principle of least privilege. Note the Consumer Key and Consumer Secret these are your client_id and client_secret.
OAuth 2.0 Web Server Flow
Use the Web Server Flow when your integration acts on behalf of a Salesforce user who needs to authorize your application. The user is redirected to Salesforce's authorization endpoint, approves access, and is redirected back to your callback with an authorization code. Your backend exchanges the code for an access token and a refresh token.
import jsforce from "jsforce";
const oauth2 = new jsforce.OAuth2({
loginUrl: "https://login.salesforce.com",
clientId: process.env.SF_CLIENT_ID!,
clientSecret: process.env.SF_CLIENT_SECRET!,
redirectUri: process.env.SF_REDIRECT_URI!,
});
// Step 1: Build authorization URL
export function getAuthorizationUrl(state: string) {
return oauth2.getAuthorizationUrl({ scope: "api refresh_token", state });
}
// Step 2: Handle callback and exchange code for tokens
export async function handleOAuthCallback(code: string) {
const conn = new jsforce.Connection({ oauth2 });
await conn.authorize(code);
return {
accessToken: conn.accessToken,
refreshToken: conn.refreshToken,
instanceUrl: conn.instanceUrl,
};
}
// Step 3: Build an authenticated connection from stored tokens
export async function getConnectionFromTokens(
accessToken: string,
refreshToken: string,
instanceUrl: string
) {
const conn = new jsforce.Connection({
oauth2,
accessToken,
refreshToken,
instanceUrl,
});
conn.on("refresh", (newAccessToken: string) => {
// Persist the new access token to your database
console.log("Salesforce token refreshed");
});
return conn;
}JWT Bearer Token Flow
The JWT Bearer Token flow is the correct choice for server-to-server integrations that run without user interaction. Generate an RSA key pair, upload the public certificate to your Connected App in Salesforce, and sign a JWT assertion with the private key. Exchange the signed JWT for an access token at Salesforce's token endpoint. This flow requires no user authorization step and is suitable for background jobs and API services.
import { createSign } from "crypto";
import { readFileSync } from "fs";
const PRIVATE_KEY = readFileSync(process.env.SF_PRIVATE_KEY_PATH!);
const CLAIM_URL = "https://login.salesforce.com";
function buildJWTAssertion(username: string): string {
const now = Math.floor(Date.now() / 1000);
const header = Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url");
const payload = Buffer.from(
JSON.stringify({
iss: process.env.SF_CLIENT_ID,
sub: username,
aud: CLAIM_URL,
exp: now + 300,
})
).toString("base64url");
const signingInput = `${header}.${payload}`;
const sign = createSign("RSA-SHA256");
sign.update(signingInput);
const signature = sign.sign(PRIVATE_KEY, "base64url");
return `${signingInput}.${signature}`;
}
export async function getAccessTokenJWT(username: string): Promise<string> {
const assertion = buildJWTAssertion(username);
const res = await fetch("https://login.salesforce.com/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 auth failed: " + await res.text());
const { access_token } = await res.json();
return access_token;
}Never store Salesforce private keys in source code or environment variable files committed to version control. Store them in a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler) and inject them at runtime. A leaked private key gives an attacker access to your entire Salesforce org with no expiration.
Webhooks and Event Processing
Salesforce does not use webhooks in the traditional sense. Instead, it provides three mechanisms for notifying external systems of changes: Platform Events, Change Data Capture, and Outbound Messages (legacy). Platform Events are the modern standard for event-driven Salesforce integrations.
Platform Events
Platform Events are custom Salesforce objects with an __e suffix that act as a publish-subscribe message bus. Your Salesforce org publishes events, and subscribers (external systems or Apex triggers) receive them asynchronously via the CometD streaming protocol. Platform Events decouple Salesforce from downstream systems and support retry, replay, and fan-out.
import jsforce from "jsforce";
const conn = new jsforce.Connection({
instanceUrl: process.env.SF_INSTANCE_URL,
accessToken: process.env.SF_ACCESS_TOKEN,
});
// Subscribe to a custom Platform Event
export function subscribeToPlatformEvents(eventName: string) {
conn.streaming.topic(`/event/${eventName}`).subscribe(async (event: unknown) => {
const payload = event as {
data: { payload: Record<string, unknown>; event: { replayId: number } };
};
console.log("Platform Event received, replayId:", payload.data.event.replayId);
await queue.add("sf-platform-event", {
eventName,
payload: payload.data.payload,
replayId: payload.data.event.replayId,
}, {
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
});
});
}
// Publish a Platform Event
export async function publishPlatformEvent(
eventName: string,
fields: Record<string, unknown>
) {
const sf = await getSalesforceClient();
return sf.sobject(eventName).create(fields);
}Change Data Capture
Change Data Capture (CDC) automatically publishes Platform Events for every create, update, delete, and undelete operation on selected Salesforce objects. Enable CDC for Accounts, Contacts, Leads, or Opportunities in Salesforce Setup and subscribe to the generated event channels to keep your application state synchronized with Salesforce without polling.
import jsforce from "jsforce";
export function subscribeToContactCDC(conn: jsforce.Connection) {
conn.streaming.topic("/data/ContactChangeEvent").subscribe((event: unknown) => {
const msg = event as {
data: {
payload: {
ChangeEventHeader: { changeType: string; recordIds: string[] };
Email?: string;
LastName?: string;
};
};
};
const { changeType, recordIds } = msg.data.payload.ChangeEventHeader;
queue.add("sf-contact-change", {
changeType, // "CREATE" | "UPDATE" | "DELETE" | "UNDELETE"
recordIds,
fields: msg.data.payload,
});
});
}Idempotency and Replay
Platform Events include a replayId that identifies each event's position in the event bus. Store the highest processed replayId in your database. On subscriber restart, pass the stored replayId to resume from where you left off rather than replaying all events from the beginning. Implement idempotency in your event processor using the combination of objectId, changeType, and replayId as the deduplication key.
Salesforce retains Platform Events for 72 hours. If your subscriber is offline for more than 72 hours, events are permanently lost. Design your integration with a fallback polling mechanism for critical data types: run a SOQL query against SystemModstamp on recovery to catch any events missed during an extended outage.
Automation Workflows
Workflow 1: Website Lead to Salesforce and Sales Assignment
- 1Prospect submits demo request form on your marketing website
- 2Frontend POSTs to your backend /api/crm/leads
- 3Backend upserts the Salesforce Lead with FirstName, LastName, Email, Company, LeadSource=Website, and custom product interest fields
- 4Salesforce Flow triggers on Lead creation and assigns the lead to the correct sales rep based on company size and territory rules
- 5The assigned rep receives a Salesforce task notification and a Slack message via Salesforce-to-Slack integration
- 6If the rep does not contact the lead within 48 hours, a Salesforce Flow escalates the task to their manager
Workflow 2: Trial Signup to Opportunity and Follow-up
- 1User completes trial signup in your product
- 2Your backend calls Salesforce to convert the matching Lead (by email) to a Contact + Account + Opportunity, or creates new records if no Lead exists
- 3The Opportunity is created in the Trial Qualification stage with a CloseDate 30 days out and Amount set to the target ARR
- 4A Salesforce Email Alert sends a personalized trial confirmation to the new contact from the assigned sales rep's address
- 5When the user activates a key product feature, your backend updates the Opportunity stage to Evaluation and logs a Salesforce Activity with the activation detail
- 6At trial day 7 and day 14, product usage data (active days, features used) is synced to Salesforce as custom Opportunity fields via your scheduled sync job
Workflow 3: Customer Upgrade to Opportunity Update and Forecasting
import { getSalesforceClient } from "../lib/salesforce";
export async function handleCustomerUpgrade(
userId: string,
newPlan: string,
mrr: number
) {
const user = await db.users.findUnique({ where: { id: userId } });
const sf = await getSalesforceClient();
// Find existing opportunity
const opps = await sf.query<{ Id: string; StageName: string }>(
`SELECT Id, StageName FROM Opportunity WHERE Contact_Email__c = '${user.email}' AND IsClosed = false LIMIT 1`
);
const annualValue = mrr * 12;
if (opps.records.length > 0) {
// Update existing opportunity to Closed Won
await sf.sobject("Opportunity").update({
Id: opps.records[0].Id,
StageName: "Closed Won",
Amount: annualValue,
CloseDate: new Date().toISOString().split("T")[0],
Plan__c: newPlan,
});
} else {
// Create new closed-won opportunity
const contact = await sf.query<{ AccountId: string; Id: string }>(
`SELECT Id, AccountId FROM Contact WHERE Email = '${user.email}' LIMIT 1`
);
if (contact.records.length > 0) {
await sf.sobject("Opportunity").create({
Name: `${user.company} - ${newPlan} Upgrade`,
AccountId: contact.records[0].AccountId,
StageName: "Closed Won",
CloseDate: new Date().toISOString().split("T")[0],
Amount: annualValue,
Plan__c: newPlan,
});
}
}
}Workflow 4: Support Ticket to Salesforce Case and Escalation
When a customer submits a support ticket in your product, create a corresponding Salesforce Case linked to the customer's Account and Contact. This gives your sales and customer success teams visibility into open support issues when reviewing account health in Salesforce, without requiring them to switch to a separate support tool.
import { getSalesforceClient } from "../lib/salesforce";
export async function syncTicketToSalesforce(ticket: {
customerEmail: string;
subject: string;
description: string;
priority: "Low" | "Medium" | "High";
}) {
const sf = await getSalesforceClient();
const contacts = await sf.query<{ Id: string; AccountId: string }>(
`SELECT Id, AccountId FROM Contact WHERE Email = '${ticket.customerEmail}' LIMIT 1`
);
if (!contacts.records.length) return;
const { Id: contactId, AccountId } = contacts.records[0];
return sf.sobject("Case").create({
Subject: ticket.subject,
Description: ticket.description,
Priority: ticket.priority,
Status: "New",
Origin: "Product",
ContactId: contactId,
AccountId,
});
}Workflow 5: ERP Order to Salesforce and Customer Record Update
Enterprise customers frequently run Salesforce alongside an ERP such as SAP, Oracle, or NetSuite. When an order is completed in the ERP, your integration layer receives the event, creates or updates the corresponding Salesforce Opportunity to Closed Won, updates Account financial fields (total contract value, renewal date), and creates a Salesforce Activity logging the order reference number. This keeps the CRM accurate without requiring sales to manually update records when orders close in a separate system.
Production Deployment
API Limits
Salesforce API limits are per-org on a 24-hour rolling basis, not per-second. Enterprise Edition orgs receive 150,000 API calls per day by default; Unlimited Edition receives 1,000,000. Professional Edition is limited to 15,000. Monitor your daily API usage via the Salesforce API Usage dashboard or the limits endpoint and alert when consumption exceeds 70% of the daily allocation. Use batch endpoints and SOQL queries that return only required fields to minimize call count.
export async function checkAPILimits(): Promise<{ remaining: number; max: number }> {
const sf = await getSalesforceClient();
const limits = await sf.limits();
const apiUsage = limits["DailyApiRequests"];
return {
remaining: apiUsage.Remaining,
max: apiUsage.Max,
};
}Bulk API for Large Operations
Use Salesforce Bulk API v2 for any operation involving more than 200 records. A single Bulk API v2 job can process up to 100 million records. Bulk jobs do not count against the per-request API limit they use the daily bulk API limit instead, which is typically much larger. Use bulk for initial data migrations, nightly reconciliation jobs, and large-scale contact or account updates.
Caching and Performance
- Cache Salesforce record ID lookups (email-to-Contact-ID, domain-to-Account-ID) in Redis with a 15-minute TTL to avoid repeated SOQL queries
- Cache org-level metadata (field definitions, picklist values, pipeline stage names) with a 1-hour TTL these rarely change
- Use SOQL projection to select only the fields you need; a SELECT * equivalent in Salesforce is expensive and increases response size unnecessarily
- Paginate large SOQL result sets using the nextRecordsUrl in the query response; do not use OFFSET for large data sets as it degrades performance significantly
Monitoring and Observability
- Track Salesforce API call count, error rate, and P95 latency as metrics in your monitoring stack
- Monitor daily API consumption against org limits alert at 70%, alert critically at 90%
- Track Platform Event subscriber lag and reconnection frequency
- Monitor bulk job completion rates and error counts
- Set up dead-letter queue alerts for failed Platform Event processing
- Log every outbound Salesforce API call with the endpoint, SOQL query, response status, and duration
Common Challenges
Challenge 1: Duplicate Records
Salesforce has native duplicate management rules, but they do not catch all cases, especially when records are created via API. Solution: implement external-ID-based upserts using Email for Contacts and Leads rather than separate create calls. Define a custom External ID field on your objects (e.g., External_System_ID__c) and use it as the upsert key. This guarantees idempotency on every write regardless of whether the record already exists in Salesforce.
Challenge 2: API Limits
Exceeding daily API limits causes all API calls to return 503 errors until the 24-hour window resets. Prevention is the only viable strategy: use SOQL queries with projections, use batch endpoints, cache lookups aggressively, use Bulk API for any operation above 200 records, and implement circuit breakers that stop new API calls when remaining capacity drops below a threshold. Never design an integration that makes one API call per user action without an aggregate or batch layer.
Challenge 3: Sync Conflicts
When both your product and Salesforce users can update the same fields, conflicts occur. The last write wins, which means manual Salesforce updates can be overwritten by your sync job minutes later. Solution: partition ownership at the field level. Define which fields are owned by your product (plan, usage metrics, activation status) and which are owned by Salesforce users (deal stage, notes, account owner). Your sync job only writes product-owned fields. Sales-owned fields are synchronized back via Change Data Capture.
Challenge 4: Field Mapping Issues
Salesforce has strict data type validation. Sending a string to a currency field, a value not in a picklist's allowed values, or a date in the wrong format returns an error. Solution: build a typed field mapping layer that validates and transforms data before sending it to Salesforce. Test mapping against a developer sandbox with production-like data to catch type mismatches early. Log every 400 validation error with the full payload so field mapping problems surface immediately.
Challenge 5: Authentication Failures
OAuth access tokens expire and refresh tokens can be revoked by a Salesforce admin. An integration that does not handle token expiration fails silently until someone notices stale CRM data. Solution: use jsforce's built-in refresh event handler to persist new access tokens automatically. For JWT-based integrations, implement proactive token refresh when remaining TTL drops below 60 seconds. Add monitoring that alerts when authentication errors occur they require immediate attention because they block all CRM synchronization.
Challenge 6: Large Data Migrations
Migrating millions of records from an existing system into Salesforce via the REST API exhausts API limits before the migration completes. Solution: always use Bulk API v2 for migrations. Split the migration into object-type batches, process each batch in a background job with progress tracking, implement checkpointing so a failed job resumes from the last successful batch rather than restarting, and run the migration during off-peak hours to avoid competing with production API traffic.
Challenge 7: Performance Bottlenecks
Complex SOQL queries against large orgs with millions of records can take seconds to return. SOQL with OFFSET on large result sets is particularly slow. Solution: use indexed fields in SOQL WHERE clauses (Email, External_System_ID__c, and standard Salesforce indexed fields). Avoid OFFSET for pagination use the nextRecordsUrl cursor pattern instead. Cache the results of expensive queries in Redis. For reports that aggregate large amounts of data, use Salesforce Reports and Dashboards APIs rather than raw SOQL.
Best Practices
- 1Use Platform Events for all Salesforce-initiated event flows they are more reliable, replayable, and scalable than Outbound Messages
- 2Use external ID fields (e.g., External_System_ID__c) for all upsert operations to guarantee idempotency
- 3Isolate all Salesforce API calls in a dedicated service class; never scatter jsforce or HTTP calls across business logic
- 4Use the JWT Bearer Token flow for server-to-server integrations; avoid username-password flow in production
- 5Store Salesforce credentials and private keys in a secrets manager, never in environment variable files
- 6Implement proactive token refresh rather than reactive refresh on 401 errors
- 7Use SOQL projections specify only the fields you need in SELECT; never select unnecessary fields
- 8Use the Bulk API v2 for any operation involving more than 200 records
- 9Monitor daily API consumption and alert at 70% to prevent hitting limits during business hours
- 10Cache Salesforce record ID lookups in Redis with a short TTL to reduce SOQL query volume
- 11Implement exponential backoff for all transient Salesforce API errors (429, 503, connection timeouts)
- 12Partition data ownership at the field level to prevent sync conflicts between your product and Salesforce users
- 13Subscribe to Change Data Capture rather than polling for Salesforce-initiated record changes
- 14Store the Platform Event replayId and resume from it on subscriber restart to avoid missing events during downtime
- 15Run a daily SOQL reconciliation job against SystemModstamp for high-value object types as a consistency guarantee independent of event delivery
Need help designing and building a Salesforce integration, implementing enterprise automation, synchronizing Salesforce with ERP and custom software systems, or modernizing a legacy CRM architecture? Nurture Technologies builds enterprise-grade Salesforce integrations that are reliable, observable, and built to scale. We design the architecture, implement the integration layer, set up monitoring, and ensure your CRM data is accurate and synchronized in real time. Talk to us about your Salesforce integration.