Integration Overview
Salesforce provides multiple ways to integrate with external systems, enabling seamless data exchange and process automation across your enterprise.
The API Family at a Glance
| API / Tool | Direction | Built for | Section |
|---|---|---|---|
| REST API | Inbound | CRUD + SOQL over HTTP/JSON; mobile & web apps | REST Basics |
| Apex REST | Inbound | Your own custom endpoints with business logic | Apex REST |
| Composite REST | Inbound | Many operations in one round trip | Composite API |
| SOAP API | Inbound | WSDL-contract enterprise/legacy systems | SOAP Basics |
| Bulk API 2.0 | Inbound | Millions of records (ETL, migrations) | Bulk API |
| Platform Events / CDC | Both | Event-driven, near-real-time messaging | Event Basics |
| Pub/Sub API | Both | The modern gRPC event stream for external subscribers | Streaming & Pub/Sub |
| Apex Callouts | Outbound | Salesforce calling external REST/SOAP services | Callouts |
| External Services | Outbound | Declarative callouts from an OpenAPI spec | External Services |
# Your first inbound call: query Accounts via the REST API
# (replace vXX.X with your org's current API version)
# Get an access token first — see the OAuth 2.0 Flows section
curl "https://MyDomainName.my.salesforce.com/services/data/vXX.X/query?q=SELECT+Id,Name+FROM+Account+LIMIT+5" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Note the host: modern orgs use their My Domain URL (yourcompany.my.salesforce.com), not the old instance URLs. Everything in this guide follows the lifecycle: plan & design → build → test → debug → tune.
How to Learn This Guide (in order)
| Stage | Sections, in sequence | You can now… |
|---|---|---|
| Start Here | This page → Plan & Design → Patterns | Name the pattern any requirement maps to |
| L1 · Basics | REST → SOAP → Callouts → JSON → Limits → Bulk → Events | Move data in and out, both directions, within limits |
| L2 · Authentication | Building blocks → every OAuth flow | Pick and implement the right flow for any client |
| L3 · Client Apps | External Client Apps | Register, police and migrate inbound clients |
| L4 · Credentials | Named/External Credentials → Auth Providers | Outbound auth with zero secrets in code |
| L5 · Patterns | Events/CDC → Apex REST → Composite → Async → Resilience → Testing | Build production-grade, failure-tolerant integrations |
| L6 · Architect | Middleware → MCP & Headless → Security Review → Capstone | Own the whole architecture and defend every decision |
Integration Types
Choose the right integration pattern based on your requirements for latency, volume, and directionality.
Integration Patterns
-
Request-Reply:
- Synchronous calls with immediate response
- Best for real-time user interactions
- Example: REST API callouts
-
Fire-and-Forget:
- Asynchronous one-way messaging
- Good for non-critical background processes
- Example: Platform Events
-
Batch Data Sync:
- Scheduled large data transfers
- Ideal for nightly ETL processes
- Example: Bulk API
-
Event-Driven:
- Real-time notifications of data changes
- Perfect for decoupled architectures
- Example: Change Data Capture
Authentication Methods
Secure authentication is essential for all Salesforce integrations. Choose the appropriate method based on your use case.
The Four Building Blocks (and How They Fit)
| Piece | Job | Details |
|---|---|---|
| External Client App (successor of the connected app) | Registers an inbound client — issues the consumer key/secret an external system uses to get tokens | External Client Apps |
| OAuth 2.0 flow | The handshake that turns those credentials into an access token | OAuth 2.0 Flows — all of them, with examples |
| Named Credential (+ External Credential) | Stores outbound endpoint + auth so callout code never touches secrets | Named Credentials |
| Auth Provider | Lets Salesforce authenticate to third parties (Google, GitHub, another SF org) — powers per-user external auth and social login | Auth Providers |
Mental Model
- Inbound (they call you): External Client App defines the client → an OAuth flow issues a token → the token accompanies every REST/SOAP/Bulk/Pub-Sub call → the integration user's profile/permission sets decide what it can touch.
- Outbound (you call them): Named Credential holds endpoint + auth (often backed by an Auth Provider for OAuth services) → Apex/External Services reference
callout:Name→ platform injects credentials at runtime. - Session ID (
UserInfo.getSessionId()) works for quick org-internal API calls, but treat it as a last resort: sessions expire with the user, can't be scoped, and audits flag them. Real integrations use a dedicated integration user + OAuth. - Principle of least privilege: every integration gets its own integration user (the Salesforce Integration user license is designed for exactly this), its own app registration, and only the object permissions it needs.
REST API Basics
Salesforce REST API provides simple, resource-oriented access to your data using standard HTTP methods.
Key Concepts
- Resources: URIs like
/services/data/vXX.X/sobjects/Account/001... - HTTP Methods: GET (read), POST (create), PATCH (update), DELETE — note Salesforce uses PATCH, not PUT, for record updates
- Response Formats: JSON (default) or XML
- API Request Limits: a 24-hour rolling allocation that varies by edition and licenses (e.g., Enterprise Edition starts at 100,000/day plus per-user amounts) — check yours live via the
/limitsresource or Setup → System Overview. Details in Performance & Limits.
Endpoint Structure
# Base URL structure (My Domain host)
https://MyDomainName.my.salesforce.com/services/data/vXX.X/[resource]
# The resources you'll use constantly:
# /sobjects/Account → describe + CRUD on records
# /query?q=SELECT... → run SOQL (follow nextRecordsUrl for >2000 rows)
# /composite, /composite/graph → many operations, one call (see Composite API)
# /jobs/ingest → Bulk API 2.0 (see Bulk API)
# /limits → your org's live API allocations
# /services/apexrest/... → YOUR custom Apex REST endpoints
CRUD in Four Calls
# CREATE → returns {"id":"001...","success":true}
curl -X POST "$BASE/services/data/vXX.X/sobjects/Account" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"Name":"Falcon Corp","Industry":"Technology"}'
# READ (specific fields only — faster)
curl "$BASE/services/data/vXX.X/sobjects/Account/001xx000003GYcwAAG?fields=Name,Industry" \
-H "Authorization: Bearer $TOKEN"
# UPDATE (PATCH; 204 No Content on success)
curl -X PATCH "$BASE/services/data/vXX.X/sobjects/Account/001xx000003GYcwAAG" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"Industry":"Energy"}'
# UPSERT by external ID (creates or updates in one call — the integration workhorse)
curl -X PATCH "$BASE/services/data/vXX.X/sobjects/Account/ERP_Id__c/A-1042" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"Name":"Falcon Corp"}'
The New HTTP QUERY Method
HTTP gained a new method built specifically for reads that need a request body: QUERY, standardized as RFC 10008 (The HTTP QUERY Method). It closes a long-standing gap between GET and POST for search-style requests.
- The problem it solves: a rich search (deep filters, nested conditions) either overflows the URL when sent as
GETquery-string parameters, or gets forced intoPOST— which looks like a write and is neither safe nor idempotent, so caches and retry logic can't trust it. - What QUERY is: like
GET, it is safe (read-only) and idempotent, so automatic retries are sound — but likePOST, the query itself travels in the request body, so it can be as large and structured as you need. - Caching: QUERY responses are cacheable, but the cache key must include the request body (not just the URL), so caching is a little more involved than for
GET.
| Aspect | GET | POST | QUERY |
|---|---|---|---|
| Request body | Discouraged / unreliable | Yes | Yes |
| Semantics | Read | Create / process | Read (search) |
| Safe | Yes | No | Yes |
| Idempotent | Yes | No | Yes |
# A complex search sent as QUERY — the filter lives in the body, not the URL
QUERY /search HTTP/1.1
Host: api.example.com
Content-Type: application/json
{
"filters": { "status": ["open", "escalated"], "priority": { "gte": 3 } },
"sort": [{ "field": "createdDate", "dir": "desc" }],
"limit": 100
}
GET /query?q=... today, and Apex HttpRequest.setMethod() targets the classic verbs. Its real relevance right now is outbound: when you integrate with a modern external API, don't be surprised to see a QUERY endpoint where you'd expect a body-carrying GET or a search-flavored POST.Making Callouts
Callouts allow your Apex code to make HTTP requests to external services or the Salesforce REST API.
Callout Implementation
-
Setup:
- Define remote site settings or named credentials
- Handle callouts in future or queueable methods for async
-
Patterns:
- Immediate callouts from triggers/controllers
- Async processing with Queueable or Batch
- Callout chaining with continuation
Callout Example (Queueable + Named Credential)
The production pattern: Queueable (not @future — it can chain and take objects), Named Credential endpoint, all callouts before any DML, and a hard cap so a big batch can't blow the 100-callout limit:
public with sharing class WeatherSyncJob implements Queueable, Database.AllowsCallouts {
private List<Id> accountIds;
private static final Integer MAX_CALLOUTS_PER_RUN = 50;
public WeatherSyncJob(List<Id> accountIds) { this.accountIds = accountIds; }
public void execute(QueueableContext ctx) {
List<Id> thisRun = new List<Id>();
List<Id> remaining = new List<Id>();
for (Integer i = 0; i < accountIds.size(); i++) {
(i < MAX_CALLOUTS_PER_RUN ? thisRun : remaining).add(accountIds[i]);
}
List<Account> toUpdate = new List<Account>();
for (Account acc : [SELECT Id, BillingCity FROM Account WHERE Id IN :thisRun]) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:WeatherAPI/current.json?q='
+ EncodingUtil.urlEncode(acc.BillingCity, 'UTF-8'));
req.setMethod('GET');
req.setTimeout(20000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() == 200) {
Map<String, Object> data =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
acc.Weather_Description__c = (String) data.get('description');
toUpdate.add(acc);
}
}
update toUpdate; // DML only AFTER all callouts
if (!remaining.isEmpty()) {
System.enqueueJob(new WeatherSyncJob(remaining)); // chain the rest
}
}
}
For the full callout treatment — Named Credentials setup, POST bodies, typed JSON wrappers, and HttpCalloutMock testing — see the Apex guide's HTTP Callouts section; this guide's Testing section covers the integration-specific parts.
REST Best Practices
Follow these best practices to build efficient, reliable REST integrations.
Performance Tips
- Use composite resources to minimize API calls
- Implement proper caching mechanisms
- Batch operations when possible
- Use gzip compression for large payloads
Error Handling
// Example: Comprehensive REST error handling
public class APIResponseHandler {
public static void handleResponse(HttpResponse res) {
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
// Success case
processSuccess(res.getBody());
} else if (res.getStatusCode() == 401) {
// Unauthorized - refresh token
refreshOAuthToken();
retryRequest();
} else if (res.getStatusCode() == 429) {
// Rate limited - implement backoff
Integer retryAfter = Integer.valueOf(res.getHeader('Retry-After'));
System.enqueueJob(new RetryQueueable(retryAfter));
} else if (res.getStatusCode() >= 500) {
// Server error - log and notify
logError(res);
notifyAdmin(res);
} else {
// Other client errors
throw new APIException('API Error: ' + res.getStatus());
}
}
}
SOAP API Basics
Salesforce SOAP API provides a strongly typed, enterprise-grade web service interface for integration.
Key Features
- WSDL-based: Contract-first development
- Strong Typing: Compile-time checking
- Comprehensive: Full CRUD and metadata operations
- Stateful: Session maintained across calls
When to Use SOAP
- Enterprise systems requiring strict contracts
- Legacy systems that only support SOAP
- Complex operations with many related objects
- Transactions requiring all-or-nothing semantics (
AllOrNoneHeader)
REST vs SOAP: The Decision in One Table
| Factor | REST | SOAP |
|---|---|---|
| Payload | JSON (lightweight, web-native) | XML envelopes (verbose, schema-validated) |
| Contract | OpenAPI (optional, by convention) | WSDL (mandatory, machine-enforced) |
| Tooling fit | Everything modern: mobile, JS, middleware, curl | Enterprise stacks: Java/.NET clients generated from WSDL |
| Default for new work | Yes — including Composite and Bulk 2.0 | Only when the other side demands it |
Positioning today: SOAP API is fully supported but in maintenance mode — new integrations default to REST/Composite (and Pub/Sub for events); you'll meet SOAP mostly in older middleware and ERP connections. Note the SOAP login() call is username+password based, with the same security concerns as the retired OAuth username-password flow — prefer passing an OAuth access token in the SessionHeader instead.
WSDL Files
Web Service Definition Language (WSDL) files describe the SOAP API's operations, types, and endpoints.
WSDL Types
-
Enterprise WSDL:
- Strongly typed with your org's specific schema
- Generated per organization
- Best for stable, tightly coupled integrations
-
Partner WSDL:
- Loosely typed, works across all orgs
- Single WSDL for any Salesforce org
- Ideal for ISVs building packaged integrations
Generating WSDL
- Navigate to Setup > API
- Download Enterprise WSDL or Partner WSDL
- Use wsdl2apex or your language's WSDL importer
SOAP API Examples
These examples demonstrate common SOAP API integration patterns.
Exposing an Apex SOAP Service (webservice keyword)
The mirror image of consuming SOAP: expose your own WSDL-defined operations for legacy enterprise clients that must call Salesforce via SOAP contracts:
global with sharing class OrderSoapService {
global class OrderResult {
webservice String orderNumber;
webservice String status;
}
webservice static OrderResult getOrderStatus(String orderNumber) {
Order o = [SELECT OrderNumber, Status FROM Order
WHERE OrderNumber = :orderNumber WITH USER_MODE LIMIT 1];
OrderResult r = new OrderResult();
r.orderNumber = o.OrderNumber; r.status = o.Status;
return r;
}
}
// Setup → Apex Classes → Generate WSDL on this class → hand the WSDL
// to the caller. Auth is a standard OAuth token in the SessionHeader.
// New integrations should prefer Apex REST — offer this only when the
// consumer's toolchain genuinely requires WSDL.
Apex SOAP Callout (Consuming)
// Example: Consuming a SOAP service from Apex
public class SoapIntegrationExample {
public static void createCaseInExternalSystem(Case sfCase) {
// Generated from WSDL using wsdl2apex
ExternalSystemAPI.ExternalCaseServicePort port = new ExternalSystemAPI.ExternalCaseServicePort();
port.timeout_x = 120000; // Set timeout
// Create SOAP request object
ExternalSystemAPI.CaseRequest caseReq = new ExternalSystemAPI.CaseRequest();
caseReq.subject = sfCase.Subject;
caseReq.description = sfCase.Description;
caseReq.priority = mapPriority(sfCase.Priority);
try {
// Make SOAP call
ExternalSystemAPI.CaseResponse response = port.createCase(caseReq);
if (response.status == 'SUCCESS') {
sfCase.External_Id__c = response.caseId;
update sfCase;
}
} catch (Exception e) {
System.debug('SOAP call failed: ' + e.getMessage());
}
}
private static String mapPriority(String sfPriority) {
// Map Salesforce priority to external system values
if (sfPriority == 'High') return 'URGENT';
if (sfPriority == 'Medium') return 'NORMAL';
return 'LOW';
}
}
Platform Events Basics
Platform Events enable event-driven architectures by publishing and subscribing to messages in real-time.
Key Concepts
- Event Bus: Pub/sub messaging infrastructure
- Publishers: Apex, Flows, APIs, or external systems
- Subscribers: Triggers, Apex, CometD clients
- Delivery: At-least-once semantics
Defining a Platform Event (No Code — It's Metadata)
A platform event is defined in Setup → Platform Events → New Platform Event, exactly like a custom object: give it a label (API name ends in __e) and add custom fields (Text, Number, Date/Time, Checkbox). Two settings matter at design time:
- Publish Behavior: Publish After Commit (default — the event only goes out if the transaction succeeds; almost always what you want) vs Publish Immediately (goes out even if the transaction rolls back — for logging).
- Event Retention: standard-volume events are retained on the bus for 24 hours (72 for high-volume) — subscribers can rewind within that window using replay IDs.
Event Types on the Bus
- Custom Platform Events (
__e): your business messages — "OrderShipped", "PaymentFailed". - Change Data Capture (CDC) events: Salesforce-generated notifications of record create/update/delete/undelete on objects you enable (Setup → Change Data Capture) — each carries a
ChangeEventHeaderplus the changed fields. The zero-code way to keep an external copy of your data in sync. - Real-Time Event Monitoring events: security-relevant activity (logins, API anomalies) — subscribe-only.
Publish-Subscribe Pattern
Platform Events follow the publish-subscribe pattern for decoupled, scalable integrations.
Publishing Events
// Example: Publishing a platform event from Apex
public class EventPublisher {
public static void publishNotification(String subject, String message, String priority, Id recordId) {
NotificationEvent__e event = new NotificationEvent__e(
Subject__c = subject,
Message__c = message,
Priority__c = priority,
RelatedRecordId__c = recordId
);
// Publish immediately
Database.SaveResult sr = EventBus.publish(event);
if (!sr.isSuccess()) {
for(Database.Error err : sr.getErrors()) {
System.debug('Error publishing event: ' + err.getMessage());
}
}
}
}
Subscribing to Events
// Example: Platform Event trigger
trigger NotificationEventTrigger on NotificationEvent__e (after insert) {
List<Task> tasks = new List<Task>();
for (NotificationEvent__e event : Trigger.New) {
if (event.Priority__c == 'High') {
tasks.add(new Task(
Subject = 'High Priority: ' + event.Subject__c,
Description = event.Message__c,
WhatId = event.RelatedRecordId__c,
Priority = 'High'
));
}
}
if (!tasks.isEmpty()) {
insert tasks;
}
}
Event-Driven Applications
Build loosely coupled, reactive applications using Platform Events as the messaging backbone.
Architecture Patterns
-
Event Carried State Transfer:
- Events contain all necessary data
- Reduces need for follow-up queries
-
Event Sourcing:
- Persist events as system of record
- Reconstruct state by replaying events
-
CQRS:
- Separate read and write models
- Use events to synchronize models
Subscribing from the Salesforce UI (LWC)
// Inside a Lightning Web Component: the lightning/empApi module
import { subscribe, unsubscribe } from 'lightning/empApi';
const callback = (response) => {
console.log('New event received: ', response);
// e.g., refresh a dashboard tile when OrderShipped__e arrives
};
// Subscribe to the platform event channel
subscribe('/event/NotificationEvent__e', -1 /* new events only */, callback)
.then(response => console.log('Subscribed: ', response));
empApi is for components inside Salesforce. For external subscribers, the modern answer is the Pub/Sub API (gRPC) rather than legacy CometD clients — the full comparison and guidance is in Streaming & Pub/Sub API.
External Services Basics
External Services enable declarative integration with external APIs using OpenAPI specifications, allowing you to connect Salesforce with external systems without writing Apex code.
Key Features
- No-code Integration: Connect to REST APIs without writing Apex
- OpenAPI Support: Works with standard Swagger/OpenAPI 2.0 and 3.0 specifications
- Named Credentials: Secure authentication built-in
- Flow Actions: Call external services directly from Flows
- Apex Invocable: Use generated actions in Apex
- Schema Generation: Automatic creation of input/output types
When to Use External Services
- Integrating with well-documented REST APIs
- Building integrations without Apex developers
- Creating reusable API connections
- Rapid prototyping of integrations
// Example: OpenAPI 3.0 specification snippet for a weather API
openapi: 3.0.1
info:
title: Weather API
description: Provides current weather and forecasts
version: 1.0.0
servers:
- url: https://api.weatherapi.com/v1
paths:
/current.json:
get:
summary: Get current weather
parameters:
- name: q
in: query
description: Location query
required: true
schema:
type: string
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/WeatherResponse'
components:
schemas:
WeatherResponse:
type: object
properties:
location:
$ref: '#/components/schemas/Location'
current:
$ref: '#/components/schemas/CurrentWeather'
securitySchemes:
APIKeyHeader:
type: apiKey
in: header
name: Authorization
Named Credentials
Named Credentials securely store authentication details for external services, eliminating the need to manage credentials in your code.
The Current Model: Named + External Credentials
Modern ("secured endpoint") named credentials split the job in two — the old all-in-one style is now called a legacy named credential and still works, but new setups should use the split model:
| Piece | Holds | Example |
|---|---|---|
| Named Credential | The endpoint URL + which external credential to use | https://api.weatherapi.com/v1 |
| External Credential | The authentication protocol + principals (the actual secrets) | OAuth 2.0 client-credentials against the vendor's token URL |
| Principal | A set of credentials; access is granted via permission sets | "WeatherAPI Service Account" — only the integration permission set can use it |
-
Authentication Protocols:
- OAuth 2.0 (browser and client-credentials style, incl. JWT bearer), Basic Authentication, API-key style custom headers, AWS Signature v4, or No Authentication
-
Principal Types:
- Named Principal — one shared identity for the whole org (typical for system-to-system)
- Per-User Principal — each user authenticates individually (pairs with an Auth Provider; e.g., each rep connects their own Google account)
-
Why permission-set control matters:
- With external credentials, who can use the secret is explicit — a compromised user without the permission set can't trigger authenticated callouts
Creating One (Split Model)
- Setup → Named Credentials → External Credentials tab → New (pick protocol, create a principal)
- Add the external credential's principal to a permission set assigned to your integration/running users
- Named Credentials tab → New → set Label, Name, URL, and select your external credential
- Reference it in code as
callout:Your_Name— no secrets, no Remote Site Setting needed
Remote Site Settings vs Named Credentials (Why NC Won)
| Remote Site Setting | Named Credential | |
|---|---|---|
| What it does | Only whitelists a URL — auth is entirely your code's problem | Whitelists and authenticates: the platform injects credentials at runtime |
| Secrets | End up in code, custom settings, or metadata | Stored in the credential store, gated by permission sets |
| Environment changes | Endpoint hardcoded in Apex — deploy to change | Endpoint is config — sandbox and production differ without code changes |
| Token refresh | You write it | Platform handles it (OAuth protocols) |
Remote Site Settings still exist for the rare "no auth at all, URL varies at runtime" case — for everything else, Named Credentials are the answer and the security review will say so.
Named Credentials Beyond Apex
- External Services: register an OpenAPI spec against a Named Credential and Flow gets declarative, authenticated callout actions — no Apex (see External Services).
- HTTP Callout in Flow builds on the same foundation: the credential does auth, the flow does logic.
- One credential, every consumer — Apex, Flow, External Services — which is exactly the point: auth configured once, used everywhere.
Using Named Credentials
// Example: Using Named Credential in Apex
public with sharing class WeatherService {
@AuraEnabled
public static String getCurrentWeather(String location) {
HttpRequest req = new HttpRequest();
// Use 'callout:' prefix with your Named Credential name
req.setEndpoint('callout:WeatherAPI/current.json?q=' + EncodingUtil.urlEncode(location, 'UTF-8'));
req.setMethod('GET');
Http http = new Http();
HTTPResponse res = http.send(req);
if (res.getStatusCode() == 200) {
return res.getBody();
} else {
throw new AuraHandledException('Error fetching weather: ' + res.getStatus());
}
}
}
OpenAPI Integration
OpenAPI (formerly Swagger) specifications define RESTful APIs in a standardized format, enabling External Services to generate fully configured integrations.
OpenAPI Specification Requirements
- Supports OpenAPI 2.0 (Swagger) and 3.0
- Must include valid paths and operations
- Should define response schemas
- Can include security definitions
- Maximum file size of 2MB
Creating an External Service
- Navigate to Setup > External Services
- Click "New External Service"
- Upload your OpenAPI specification file
- Select a Named Credential
- Review the generated operations
- Save the External Service
Using External Services in Flow & Apex
- In Flow: every operation in your spec appears as an Action element (category "External Services") with typed input/output parameters generated from the schemas — drag it in, map
qto a flow variable, wire a fault path. - In Apex: the registration also generates
ExternalService.*Apex classes, so you can call the same operation in code with typed request/response objects instead of hand-parsing JSON. - In Agentforce: External Services actions can be added to agents — the OpenAPI descriptions become the instructions the agent reasons over (see the Agentforce guide).
Best Practices
-
API Design:
- Use clear, consistent operationIds in your OpenAPI spec
- Define comprehensive response schemas
- Include error response definitions
-
Error Handling:
- Implement fault paths in Flows
- Handle API rate limiting
- Log errors for troubleshooting
-
Performance:
- Use bulkified operations when possible
- Consider async processing for long-running calls
- Implement caching for frequently accessed data
Plan & Design: Integration Architecture First
Integrations fail in design far more often than in code: the wrong pattern for the volume, no error strategy, or credentials handled casually. Answer these five questions before building — they map straight onto Salesforce's official integration patterns.
The Five Design Questions
| Question | Options → consequences |
|---|---|
| 1. Direction? | Inbound (they call Salesforce: REST/Apex REST/Bulk) · Outbound (callouts/External Services) · Event-driven both ways (Platform Events/CDC/Pub-Sub) |
| 2. Timing? | Real-time request-reply (user waits — budget <2s) · Near-real-time events (seconds) · Batch (nightly Bulk jobs) |
| 3. Volume? | Single records → REST · Hundreds per call → Composite/sObject collections · Millions/day → Bulk API 2.0 |
| 4. Source of truth? | Who wins on conflict? Which system owns each field? (Write this down — it settles every future argument.) |
| 5. Failure plan? | Retry with backoff? Dead-letter log? Idempotency via external IDs and upsert? Design this before the happy path. |
Match to the Official Patterns
Salesforce's Integration Patterns and Practices catalog names the shapes — they're worth knowing by name:
- Remote Process Invocation — Request and Reply: Salesforce calls out and waits (synchronous callout from Apex/Flow).
- Remote Process Invocation — Fire and Forget: Salesforce raises a platform event / queues an async callout; no waiting.
- Remote Call-In: the external system calls Salesforce (REST, Apex REST, Composite, Bulk, SOAP).
- Batch Data Synchronization: scheduled high-volume sync (Bulk API + external ETL).
- UI Update Based on Data Changes: push changes to screens (CDC/platform events + empApi).
- Data Virtualization: don't copy the data at all — surface it live via Salesforce Connect external objects.
Worked Example: Planning an Order Sync with an ERP
- Requirement: orders activated in Salesforce appear in the ERP within a minute; ERP shipment updates flow back.
- Outbound leg: Fire-and-forget — platform event
OrderActivated__epublished on activation; the ERP's middleware subscribes via Pub/Sub API. Why not a callout? The ERP's maintenance windows would block order activation. - Inbound leg: Remote call-in — ERP PATCHes shipment status via upsert on
ERP_Order_Id__cexternal ID (idempotent, no duplicate logic), authenticated by an External Client App with the client credentials flow and a dedicated integration user. - Failure design: event retention window covers ERP downtime (replay ID resume); inbound errors return standard REST error codes the middleware retries with backoff; a nightly Bulk API reconciliation catches anything missed.
- Security review: integration user has CRUD on Order only; secrets held in the middleware's vault; PKCE/refresh-token policies set on the app.
Sources: Integration Patterns and Practices · Salesforce API Documentation
OAuth 2.0 Flows: The Complete Set
Every token that hits a Salesforce API came from one of these flows. Pick by who is authenticating: a human in a browser, a server with a certificate, or a device with no keyboard.
Which Flow When
| Flow | Use when | Status |
|---|---|---|
| Web Server (Authorization Code) + PKCE | A web app acts on behalf of a logged-in user | Recommended default for user-context apps |
| Client Credentials | Server-to-server, no human involved | Recommended for system integrations |
| JWT Bearer | Server-to-server with certificate-based trust; CI/CD | Recommended (powers CLI auth in pipelines) |
| Refresh Token | Renew an expired access token without re-login | Companion to web server flow |
| Device Flow | Input-constrained devices (kiosks, CLIs, IoT) — user approves on another device | Niche but current |
| User-Agent Flow | Legacy single-page/mobile apps (token in URL fragment) | Legacy — use web server + PKCE instead |
| Username-Password | — | Blocked by default in newer orgs; migrate to client credentials |
1. Web Server Flow with PKCE (User Context)
# Step 1 — send the user's browser to authorize (with a PKCE challenge)
https://MyDomainName.my.salesforce.com/services/oauth2/authorize
?response_type=code
&client_id=YOUR_CONSUMER_KEY
&redirect_uri=https://yourapp.example.com/callback
&code_challenge=BASE64URL(SHA256(code_verifier))
&code_challenge_method=S256
# Step 2 — user logs in & approves; Salesforce redirects back:
https://yourapp.example.com/callback?code=aPrx4sgoM2Nd...
# Step 3 — your server swaps the code (+ verifier) for tokens
curl -X POST https://MyDomainName.my.salesforce.com/services/oauth2/token \
-d "grant_type=authorization_code" \
-d "code=aPrx4sgoM2Nd..." \
-d "client_id=YOUR_CONSUMER_KEY" \
-d "client_secret=YOUR_CONSUMER_SECRET" \
-d "redirect_uri=https://yourapp.example.com/callback" \
-d "code_verifier=THE_ORIGINAL_RANDOM_VERIFIER"
# Response: {"access_token":"00D...","refresh_token":"5Aep...","instance_url":...}
Why PKCE: the verifier proves the token request comes from whoever started the login, which prevents authorization-code interception. External Client Apps let admins require PKCE — turn that on.
2. Client Credentials Flow (Server-to-Server)
# One call, no human. Requires "Enable Client Credentials Flow" on the app
# plus a designated "Run As" integration user.
curl -X POST https://MyDomainName.my.salesforce.com/services/oauth2/token \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CONSUMER_KEY" \
-d "client_secret=YOUR_CONSUMER_SECRET"
# Every API call then executes as the Run As user —
# scope its permission sets to exactly what the integration needs.
3. JWT Bearer Flow (Certificate Trust, No Secrets in Transit)
# You sign a short-lived JWT with your private key; Salesforce verifies it
# against the certificate uploaded to the app. No password, no client secret.
# JWT claims: iss = consumer key, sub = integration username,
# aud = https://login.salesforce.com, exp = now + 3 minutes
curl -X POST https://login.salesforce.com/services/oauth2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiIzTVZH..."
# The user must be pre-authorized (admin-approved profile/permission set).
# This is how CI/CD authenticates: sf org login jwt --client-id ... --jwt-key-file server.key --username ci@yourorg.com
From inside Salesforce (org-to-org), Apex can run this flow natively with Auth.JWT / Auth.JWS / Auth.JWTBearerTokenExchange — though a Named Credential usually does it for you with zero code.
4. Refresh Token Flow
# Access tokens expire (session timeout policy); refresh without bothering the user:
curl -X POST https://MyDomainName.my.salesforce.com/services/oauth2/token \
-d "grant_type=refresh_token" \
-d "refresh_token=5Aep861..." \
-d "client_id=YOUR_CONSUMER_KEY"
# Handle 401 INVALID_SESSION_ID by refreshing once, then retrying the original call.
5. Device Flow (No Browser on the Device)
- Device asks Salesforce for a user code + verification URL → shows them to the user ("visit salesforce.com/setup/connect, enter
XKPQ42") → user approves on their phone/laptop → device polls the token endpoint until tokens arrive. - Use for CLI tools, kiosks, and IoT — anything where typing a password is impossible or unsafe.
6. Username-Password Flow — Don't
grant_type=passwordships a real user's password to a third-party app — the anti-pattern OAuth exists to prevent.- Blocked by default in orgs created in recent years, and Salesforce's official migration guidance is: move to the client credentials flow.
- If you inherit an integration using it, that's a migration ticket, not a precedent.
7. SAML Bearer Assertion Flow
- The JWT bearer flow's SAML sibling: exchange a signed SAML assertion for an access token — the natural fit when your enterprise identity world already speaks SAML (an IdP issuing assertions) and you want tokens without user interaction.
- Same trust model as JWT bearer (certificate uploaded to the client app, pre-authorized users); choose JWT for new builds unless SAML infrastructure is already the standard in your company.
8. Access vs Refresh vs ID Tokens (OIDC)
| Token | Purpose | Lifetime & handling |
|---|---|---|
| Access token | The API key-card: sent as Authorization: Bearer on every call | Short-lived (session policy); never store long-term |
| Refresh token | Mints new access tokens without re-login | Long-lived; store encrypted, server-side only — leaking it leaks the account |
| ID token (OpenID Connect) | A signed JWT describing the user (who logged in) — for your app's login logic, not for API calls | Verify signature and audience; request via the openid scope |
The distinction that prevents a classic bug: an ID token proves identity; an access token grants access. Sending an ID token to an API — or trusting an access token as login proof — confuses authentication with authorization.
Sources: OAuth Authorization Flows (Help) · Migrate from Username-Password to Client Credentials (Help) · Block Authorization Flows (Help)
External Client Apps (Connected Apps' Successor)
This is the biggest integration change in years: you can no longer create connected apps (creation was disabled in phases; only package installs and support exceptions remain). External Client Apps (ECAs) are the current tool for registering OAuth clients.
What Changes vs Connected Apps
| Aspect | Connected App (legacy) | External Client App |
|---|---|---|
| Creation today | Blocked (phased out) | Setup → External Client App Manager |
| Secrets in metadata | Consumer secret could end up in metadata/packages | Credentials separated from packageable settings — safer packaging & source control |
| Structure | One monolithic definition | Split into global app + org-level policies (distribution-friendly, 2GP-packageable) |
| Security options | PKCE optional | Admins can require PKCE for web server / hybrid / code-and-credentials flows — non-PKCE attempts are blocked |
| Existing apps | Keep working (end-of-support to be announced) | Migration tool converts connected-app metadata to ECA format |
Creating an ECA for a Server Integration
- Setup → External Client App Manager → New External Client App.
- Enable OAuth, set the callback URL (or a placeholder for client-credentials-only apps), select scopes (
api,refresh_tokenas needed — grant the minimum). - Enable the flows you actually use (e.g., Client Credentials with a Run As user; require PKCE for browser flows) — everything else stays off.
- Retrieve the consumer key/secret from the app's settings; store them in your external system's secret manager, never in code.
- Under policies: restrict IP ranges, set token/session timeouts, and pre-authorize profiles/permission sets for JWT flows.
Local vs Packaged ECAs
- Local ECA: created in and for one org — the standard case for a company's own integrations.
- Packaged ECA: distributed in a second-generation package (2GP) so the same app installs across orgs — the ISV path, and the structural reason ECAs split global definition from org-level policies. (First-generation packaging belongs to the connected-app era; new packaged apps are 2GP.)
- Know the current feature gaps when planning a migration: ECAs don't support the username-password flow (by design — it's retired anyway) and some legacy connected-app capabilities (e.g., certain push-notification and provisioning configurations) still live on connected apps — check the official comparison before migrating an app that uses them.
OAuth Policies You Should Actually Set
- Permitted users: "Admin approved users are pre-authorized" + a permission set — an explicit allowlist beats "all users may self-authorize" for any serious integration.
- Refresh token validity: expire on revoke + a defined validity window; "valid until revoked" forever is how forgotten laptops keep org access for years.
- IP relaxation: keep IP restrictions enforced unless the client genuinely roams; document the exception if you relax it.
- Session policies: access-token timeout tuned to the integration's cadence — a nightly job doesn't need 12-hour tokens.
What To Do About Your Existing Connected Apps
- They keep working — this isn't an outage. But end-of-support is coming, so inventory them now (Setup → Connected Apps OAuth Usage shows what's actually being used by whom).
- Migrate deliberately, highest-risk first (apps with broad scopes or username-password usage). Salesforce ships a migration tool that converts connected-app metadata to the ECA format.
- Kill zombie apps: anything with no OAuth usage in 90 days gets investigated, then deleted.
- Our connected apps deep-dive blog covers the legacy model's anatomy if you're maintaining one.
Sources: New Connected Apps Can No Longer Be Created (Help) · External Client App OAuth Settings (Help)
Auth Providers: Salesforce as the OAuth Client
Everything so far covered systems authenticating into Salesforce. Auth Providers flip the direction: they let Salesforce (or its users) authenticate to an external service — Google, Microsoft, GitHub, another Salesforce org, or any OpenID Connect provider.
The Two Jobs They Do
-
1. Power outbound per-user / OAuth callouts:
- An External Credential with protocol OAuth 2.0 references an Auth Provider; Salesforce handles the token dance (initial consent, storage, refresh) automatically
- With a per-user principal, each user clicks "Authenticate" once and callouts run with their external identity — e.g., each rep's own Google Drive
-
2. Social sign-on / SSO into Salesforce or Experience Cloud:
- "Log in with Google" buttons on Experience Cloud sites; a registration handler Apex class maps the external identity to a Salesforce user
Setup in Five Steps (Google Example)
- Create an OAuth client in the external system (Google Cloud Console) — you'll get its client ID/secret.
- Setup → Auth. Providers → New → choose the provider type (Google, Microsoft, GitHub, Salesforce, or generic OpenID Connect / custom
Auth.AuthProviderPluginClass). - Paste the client ID/secret and scopes; save — Salesforce generates the callback URL.
- Register that callback URL back in the external system's OAuth client.
- Reference the Auth Provider from an External Credential (for callouts) or a login page (for SSO).
Custom Auth Providers (Apex Plug-In)
When the external service's OAuth implementation doesn't match any built-in provider type (non-standard token endpoints, extra parameters, odd response shapes), implement it yourself by extending Auth.AuthProviderPluginClass:
global class VendorAuthProvider extends Auth.AuthProviderPluginClass {
// 1. Which custom metadata type stores this provider's config
global String getCustomMetadataType() { return 'Vendor_Auth__mdt'; }
// 2. Where to send the user (or system) to authorize
global PageReference initiate(Map<String,String> config, String stateToPropagate) {
return new PageReference(config.get('Auth_URL__c') + '?state=' + stateToPropagate + '...');
}
// 3. Exchange the callback code for tokens (your custom HTTP logic lives here)
global Auth.AuthProviderTokenResponse handleCallback(
Map<String,String> config, Auth.AuthProviderCallbackState state) {
// callout to the vendor token endpoint, parse the response...
return new Auth.AuthProviderTokenResponse('VendorAuth', accessToken, refreshToken, state.queryParameters.get('state'));
}
// 4. Refresh + user info hooks complete the contract
global override Auth.OAuthRefreshResult refresh(Map<String,String> config, String refreshToken) { ... }
global Auth.UserData getUserInfo(Map<String,String> config, Auth.AuthProviderTokenResponse response) { ... }
}
Register the class as a provider type under Setup → Auth. Providers, and it becomes selectable exactly like the built-ins — External Credentials can then use it, which means any OAuth dialect ends up behind the same clean callout: interface.
The key distinction: External Client App = they authenticate to you. Auth Provider = you authenticate to them. Named/External Credentials are where an Auth Provider gets consumed for callouts.
Sources: Auth Providers (Help) · Named Credentials and External Credentials (Help)
Apex REST: Your Own Custom Endpoints
The standard REST API exposes records; Apex REST exposes business logic. Annotate a class with @RestResource and external systems get a custom endpoint at /services/apexrest/... that runs your Apex — validation, cross-object work, and a purpose-built response shape in one call.
A Complete Resource
@RestResource(urlMapping='/orders/*')
global with sharing class OrderRestService {
// GET /services/apexrest/orders/{orderNumber}
@HttpGet
global static OrderDto getOrder() {
String orderNo = RestContext.request.requestURI.substringAfterLast('/');
Order o = [SELECT Id, OrderNumber, Status, TotalAmount FROM Order
WHERE OrderNumber = :orderNo WITH USER_MODE LIMIT 1];
return new OrderDto(o);
}
// POST /services/apexrest/orders (JSON body auto-deserialized into params)
@HttpPost
global static OrderDto createOrder(String accountNumber, List<LineDto> lines) {
// ... find account by external number, validate stock, insert order + items ...
return new OrderDto(newOrder);
}
// PATCH /services/apexrest/orders/{orderNumber}
@HttpPatch
global static void updateStatus() {
RestRequest req = RestContext.request;
Map<String, Object> body =
(Map<String, Object>) JSON.deserializeUntyped(req.requestBody.toString());
String orderNo = req.requestURI.substringAfterLast('/');
Order o = [SELECT Id FROM Order WHERE OrderNumber = :orderNo WITH USER_MODE LIMIT 1];
o.Status = (String) body.get('status');
update as user o;
RestContext.response.statusCode = 204;
}
// DTOs give the caller a stable contract that won't break when fields change
global class OrderDto {
public String orderNumber; public String status; public Decimal total;
public OrderDto(Order o) {
orderNumber = o.OrderNumber; status = o.Status; total = o.TotalAmount;
}
}
global class LineDto { public String productCode; public Integer qty; }
}
Rules of the Road
- Callers authenticate with a normal OAuth token — Apex REST adds no auth of its own, so the integration user's permissions (and your
WITH USER_MODE) are the security model. - One class per
urlMapping; one method per HTTP verb (@HttpGet,@HttpPost,@HttpPut,@HttpPatch,@HttpDelete). RestContext.request/RestContext.responsegive you URI, headers, body, and status-code control. Return typed DTOs — don't leak raw sObjects (their shape changes when admins add fields).- Set meaningful status codes: 201 on create, 204 on empty success, 400 with an error body on bad input — your callers' retry logic depends on them.
- When to prefer it over standard REST: multi-object transactions, custom validation, or shielding callers from your data model. When not to: plain CRUD (standard REST already does it, with zero code to maintain).
Sources: Apex REST (Apex Developer Guide)
Composite REST: Many Operations, One Call
Every REST call costs latency and an API request. The composite family bundles operations — and its reference chaining lets later steps use earlier results, so "create account → create contact under it → create opportunity" is one round trip, not three.
The Family
| Resource | What it does | Atomic? |
|---|---|---|
/composite | Up to 25 chained subrequests with @{ref.id} references | Optional (allOrNone) |
/composite/graph | Multiple graphs of chained operations, up to 500 nodes per request | Each graph is all-or-nothing |
/composite/tree | Create a parent + children hierarchy (up to 200 records) in one POST | Yes |
/composite/sobjects | Bulk-ish CRUD on up to 200 records of the same shape (collections) | Optional (allOrNone) |
/composite/batch | Up to 25 independent subrequests (no chaining) | No — each stands alone |
Chained Composite Example
POST /services/data/vXX.X/composite
{
"allOrNone": true,
"compositeRequest": [
{
"method": "POST",
"url": "/services/data/vXX.X/sobjects/Account",
"referenceId": "newAccount",
"body": { "Name": "Falcon Manufacturing" }
},
{
"method": "POST",
"url": "/services/data/vXX.X/sobjects/Contact",
"referenceId": "newContact",
"body": {
"LastName": "Jha",
"AccountId": "@{newAccount.id}"
}
},
{
"method": "GET",
"url": "/services/data/vXX.X/query?q=SELECT+Id,Name+FROM+Contact+WHERE+AccountId='@{newAccount.id}'",
"referenceId": "verify"
}
]
}
sObject Collections Example (200 Records, One Call)
POST /services/data/vXX.X/composite/sobjects
{
"allOrNone": false,
"records": [
{ "attributes": {"type": "Lead"}, "LastName": "Rao", "Company": "Acme" },
{ "attributes": {"type": "Lead"}, "LastName": "Iyer", "Company": "Globex" }
]
}
# Response is an array of per-record results — same idea as Database.insert(recs, false)
Choosing Within the Family
- Related records with dependencies:
/composite(≤25 steps) or/composite/graph(bigger, transactional graphs). - Same-object volume up to 200:
/composite/sobjects— the sweet spot between single calls and a Bulk job. - Beyond ~thousands of records: stop compositing and use Bulk API 2.0.
- Composite subrequests count once for the top-level call toward API limits — a major reason mobile/middleware integrations use it.
Sources: Composite Resources (REST API Guide)
Streaming API & Pub/Sub API
Two generations of event delivery coexist. The classic Streaming API (CometD long-polling, JSON) still works and is still updated — but Pub/Sub API (gRPC + HTTP/2, Apache Avro payloads) is where Salesforce invests, and the official guidance is clear: new event integrations should target Pub/Sub.
The Landscape
| Channel type | Example channel | Status |
|---|---|---|
| Custom platform events | /event/OrderShipped__e | Current — the workhorse |
| Change Data Capture | /data/AccountChangeEvent (or /data/ChangeEvents for all) | Current — zero-code data sync |
| Custom channels | Grouped/filtered event streams (channel members + filter expressions) | Current — subscribers get only relevant events |
| PushTopics | /topic/AccountUpdates (SOQL-defined) | Legacy — don't build new; use CDC |
| Generic streaming | /u/notifications | Legacy — use platform events |
CometD vs Pub/Sub API
| Streaming API (CometD) | Pub/Sub API | |
|---|---|---|
| Transport | HTTP long-polling (Bayeux) | gRPC over HTTP/2 |
| Payload | JSON | Apache Avro binary (schema-first, compact) |
| Flow control | Server pushes; client keeps up or drops | Client pulls — you request N events when ready (backpressure built in) |
| Publishing | Not supported (separate REST/Apex publish) | Publish and subscribe over one API |
| Clients | CometD libraries, EMP Connector | Any gRPC language — official proto file + examples (Java, Python, Go, Node…) |
| Replay | Replay IDs (24–72h window) | Replay IDs (same semantics, plus managed subscriptions) |
Replay: The Concept That Makes Events Reliable
- Every event on the bus gets an incrementing replay ID. Subscribers store the last ID they processed; on reconnect they resume from it — no lost events during downtime (within the retention window).
- Special values:
-1= only new events;-2= everything still retained. Production subscribers persist the real replay ID;-2after a long outage can be a flood. - Design events to be idempotent anyway ("at-least-once" delivery means duplicates happen): consumers should upsert by key, not blind-insert.
Pub/Sub API in Practice (Python Sketch)
# Conceptual flow with the official pubsub_api.proto (grpc + avro libraries):
# 1. Authenticate (any OAuth flow) → access token + instance URL + org ID
# 2. Open a gRPC channel to api.pubsub.salesforce.com:7443 with those as metadata
# 3. GetTopic(topic_name='/event/OrderShipped__e') → topic info
# 4. GetSchema(schema_id) → Avro schema for decoding
# 5. Subscribe(FetchRequest(topic_name=..., replay_preset=LATEST, num_requested=10))
# → stream of ProducerEvents; decode payload with the Avro schema
# 6. After processing each batch, send the next FetchRequest (flow control)
# and persist the replay_id you've completed.
Salesforce publishes the proto file and full quick-starts — start from the official Pub/Sub API repo/docs rather than hand-rolling. Also notable: Event Relay forwards platform events/CDC to AWS EventBridge natively, no subscriber code at all.
Sources: Pub/Sub API (Developer Docs) · Streaming vs Pub/Sub Differences (Streaming API Guide) · Pub/Sub API (Trailhead)
Bulk API 2.0: Millions of Records
When the job is "load 3 million records" or "extract every Contact nightly," per-record REST calls don't scale. Bulk API 2.0 is job-based: submit CSV data (or a query), Salesforce processes it asynchronously in parallel batches it manages for you — that batch management is the big upgrade over Bulk 1.0.
Ingest Job Lifecycle (Load Data In)
# 1. Create the job (operation: insert | update | upsert | delete | hardDelete)
curl -X POST "$BASE/services/data/vXX.X/jobs/ingest" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"object":"Account","operation":"upsert","externalIdFieldName":"ERP_Id__c"}'
# → {"id":"750...","state":"Open","contentUrl":"services/data/vXX.X/jobs/ingest/750.../batches"}
# 2. Upload the CSV (up to 150MB per job)
curl -X PUT "$BASE/services/data/vXX.X/jobs/ingest/750.../batches" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: text/csv" \
--data-binary @accounts.csv
# 3. Close the job → Salesforce starts processing
curl -X PATCH "$BASE/services/data/vXX.X/jobs/ingest/750..." \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"state":"UploadComplete"}'
# 4. Poll status until JobComplete (or Failed)
curl "$BASE/services/data/vXX.X/jobs/ingest/750..." -H "Authorization: Bearer $TOKEN"
# 5. Fetch results — successful, failed, and unprocessed records as CSV
curl "$BASE/services/data/vXX.X/jobs/ingest/750.../failedResults" -H "Authorization: Bearer $TOKEN"
Query Jobs (Get Data Out)
curl -X POST "$BASE/services/data/vXX.X/jobs/query" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"operation":"query","query":"SELECT Id, Name, Industry FROM Account"}'
# Poll, then page through CSV results with the Sforce-Locator header.
Know the Numbers
- 150 million records per 24 hours across ingest jobs (bulk calls don't consume the normal API request allocation — they have their own limits).
- CSV upload ≤150MB per job; Salesforce splits processing into internal batches automatically (Bulk 1.0's manual batch management is the main reason it's legacy).
- Triggers still fire on bulk-loaded records, in chunks of 200 — a badly bulkified trigger turns a 2-hour load into a 2-day one. This is why the Apex guide hammers bulkification.
- Failed-record CSVs include the exact error per row — build your reconciliation around them instead of guessing.
- Choosing: ≤200 records → collections; thousands → composite graph or several collection calls; hundreds of thousands+ → Bulk 2.0. Inside Salesforce, the equivalent muscle is Batch Apex / Apex Cursors.
Large-Scale Sync: Bulk + CDC + Reconciliation
- Initial load: Bulk API ingest jobs seed the external system (or vice versa) — upsert on external IDs from day one.
- Ongoing deltas: Change Data Capture streams changes in near-real time — no polling, no missed edits (see Streaming & Pub/Sub).
- Reconciliation: a scheduled Bulk query job extracts key fields, the other side compares, and discrepancies land on an exception report. Streams should be complete; the nightly compare is what proves it.
- This trio — bulk seed, event deltas, periodic reconcile — is the reference architecture for keeping millions of records in sync without melting API limits.
Sources: Bulk API 2.0 Developer Guide
Testing Integrations
Integration code has a testing problem: Apex tests can't make real callouts, and external systems can't be trusted to behave in CI. The platform gives you a mock seam for every direction.
Outbound: Mock the Remote System
@isTest
private class WeatherSyncJobTest {
class WeatherMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
// Assert on the request your code built — endpoint, method, headers
Assert.isTrue(req.getEndpoint().contains('current.json'), 'Wrong endpoint');
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('{"description":"Sunny"}');
return res;
}
}
@isTest
static void updatesAccountsFromApi() {
Account a = new Account(Name = 'Test', BillingCity = 'Pune');
insert a;
Test.setMock(HttpCalloutMock.class, new WeatherMock());
Test.startTest();
System.enqueueJob(new WeatherSyncJob(new List<Id>{ a.Id }));
Test.stopTest(); // async job executes here, with the mock active
Assert.areEqual('Sunny',
[SELECT Weather_Description__c FROM Account WHERE Id = :a.Id].Weather_Description__c);
}
}
Also available: StaticResourceCalloutMock (serve a saved JSON file) and MultiStaticResourceCalloutMock (different responses per endpoint). Test the error paths too — have the mock return 500s and timeouts.
Inbound: Test Apex REST by Faking RestContext
@isTest
static void getOrderReturnsDto() {
Order o = TestDataFactory.createActivatedOrder();
RestRequest req = new RestRequest();
req.requestURI = '/services/apexrest/orders/' + o.OrderNumber;
req.httpMethod = 'GET';
RestContext.request = req;
RestContext.response = new RestResponse();
OrderRestService.OrderDto dto = OrderRestService.getOrder();
Assert.areEqual(o.OrderNumber, dto.orderNumber);
}
Events: Deliver Inside the Test
@isTest
static void notificationEventCreatesTask() {
Test.startTest();
EventBus.publish(new NotificationEvent__e(Priority__c = 'High', Subject__c = 'Hi'));
Test.getEventBus().deliver(); // force delivery to the trigger, mid-test
Test.stopTest();
Assert.areEqual(1, [SELECT COUNT() FROM Task WHERE Subject LIKE 'High Priority%']);
}
Beyond Unit Tests
- Exploratory calls: the official Salesforce Platform APIs Postman collection, or from the CLI:
sf api request rest /services/data/vXX.X/limits. - Sandbox end-to-end: point the external system's staging environment at a sandbox (Named Credential URLs differ per org — that's a feature, not a bug).
- Contract discipline: version your Apex REST DTOs and OpenAPI specs; breaking a field name breaks someone's parser at 2 a.m.
Sources: Testing HTTP Callouts (Apex Guide) · Testing Platform Event Triggers
Debugging Integrations
Integration bugs hide in the gap between two systems. The skill is knowing which side's evidence to pull — and Salesforce logs more than most people realize.
Your Evidence Sources
- Debug logs (outbound):
CALLOUT_REQUEST/CALLOUT_RESPONSElines show exactly what Apex sent and got back. Set a trace flag on the running (integration) user, reproduce, read. - Debug logs (inbound): trace the integration user and their REST/Apex REST calls generate logs like any other transaction — including the SOQL and DML your endpoint ran.
- Login History: first stop for auth failures — shows OAuth flow attempts and the precise failure reason (blocked flow, IP restriction, wrong secret).
- Setup → API Usage / System Overview + the
/limitsresource: are you being throttled rather than broken? - Event Monitoring (where licensed): API event logs give per-call latency, caller, and volume history.
- The other side: ask for the middleware's request log with timestamps — comparing timestamped logs from both sides usually settles the question quickly.
The Classic Errors, Decoded
| Symptom | Usual cause | Fix |
|---|---|---|
401 INVALID_SESSION_ID | Expired/revoked access token | Refresh-token flow (or new client-credentials token), then retry once |
403 REQUEST_LIMIT_EXCEEDED | Daily API allocation exhausted | Backoff + composite/bulk to reduce call count; check /limits |
400 invalid_grant at token endpoint | Blocked flow, un-authorized JWT user, bad clock (JWT exp), wrong My Domain host | Login History tells you which; verify app policies |
CalloutException: uncommitted work pending | Callout attempted after DML in one transaction | Reorder: callouts first, or move callout to a Queueable |
Read timed out | Remote slower than setTimeout | Raise timeout (≤120s), make the call async, add retry |
ENTITY_IS_LOCKED / UNABLE_TO_LOCK_ROW | Parallel loads hitting related records | Serialize by parent (sort your Bulk CSV by AccountId), reduce parallelism |
| Events published but never received | Transaction rolled back (after-commit publish), wrong channel name, or replay from -1 after downtime | Check publish behavior, channel spelling, and stored replay IDs |
A Debugging Playbook
- 1. Reproduce with the smallest possible call (curl/Postman with the same token) — isolates "integration broken" vs "data broken".
- 2. Check Login History (auth?) →
/limits(throttled?) → debug log (logic?) in that order; it's fastest. - 3. For intermittent failures, log correlation IDs on both sides (a
UUID.randomUUID()header from Apex — see the Apex guide's modern features). - 4. Keep a persistent
Integration_Log__cfor production failures — debug logs expire; your audit trail shouldn't.
Sources: REST API Status Codes & Errors · Monitor API Usage (Help)
Performance & API Limits
Integration performance is mostly arithmetic: calls × latency, records ÷ throughput, allocation − consumption. Know the numbers, then design to spend fewer of them.
The Limits That Shape Designs
| Limit | Value | Design consequence |
|---|---|---|
| Daily API requests | Edition + license based (Enterprise starts ~100k/day; check /limits) | Chatty per-record sync patterns die at scale — batch or composite |
| Concurrent long-running requests | 25 requests running >20s | Slow synchronous endpoints can lock out the whole org's API traffic |
| Apex callouts | 100 per transaction, ≤120s total | Fan-out callouts belong in chained Queueables |
| Bulk API 2.0 ingest | 150M records/day (own allocation) | Big loads don't eat your REST allocation — use it |
| Platform event publishing/delivery | Hourly allocations by license (high-volume events) | Don't publish per-field-change; publish meaningful business events |
| Streaming retention | 24h standard / 72h high-volume | Subscriber outages longer than retention need the nightly reconciliation job |
# See your real numbers, live:
curl "$BASE/services/data/vXX.X/limits" -H "Authorization: Bearer $TOKEN"
# → {"DailyApiRequests":{"Max":115000,"Remaining":103241}, "DailyBulkV2QueryJobs":..., ...}
Spend Fewer Calls
- Composite everything related: one
/compositecall replaces 3–25; collections replace up to 200. - Sync deltas, not snapshots: query with
SystemModstamp > last_run, or better, let CDC push the changes and skip polling entirely. - Upsert on external IDs: one idempotent call replaces query-then-insert-or-update pairs.
- Cache reference data: picklists and config don't need re-fetching every request.
- Compress:
Accept-Encoding: gzipon large responses; CSV over JSON for volume.
Latency Discipline
- User-facing callouts: set aggressive timeouts (5–10s) and design the failure UX; a spinner that hangs 120s is worse than an error.
- Move anything slow to async (Queueable + platform event back to the UI when done — the LWC guide's empApi pattern closes that loop).
- Measure before tuning: Event Monitoring API logs (or your middleware's metrics) tell you p95 latency per endpoint — optimize the endpoint that's actually slow.
Sources: API Request Limits and Allocations · Limits Resource (REST API Guide)
What's New in Integration
The integration stack is mid-generational-change on three fronts: app registration, auth flows, and event transport. Here's the verified state of play and your modernization list.
The Big Shifts
| When | Change | What it means for you |
|---|---|---|
| Earlier | OAuth username-password flow blocked by default in new orgs | Migrate stragglers to client credentials |
| Recent | Connected app creation disabled in new orgs | Learn External Client Apps now |
| Recent | Connected app creation disabled in all orgs (packages/support exceptions aside); existing apps keep working, end-of-support to be announced | Inventory + migrate with the CA→ECA tool |
| Latest | Older API versions are deprecated on a rolling retirement schedule | Audit hardcoded /services/data/vXX versions in every client |
| Ongoing | Pub/Sub API is the strategic event transport; CometD maintained for existing integrations | New subscribers speak gRPC |
| New surface | Agent API + MCP servers expose Agentforce to external apps | See the Agentforce guide — "headless agent" is an integration pattern now |
| Web standard | The HTTP QUERY method is standardized (RFC 10008) — a safe, idempotent read that carries a request body | Recognize it on modern external APIs; see REST API Basics |
Modernization Checklist
- Connected apps → inventory usage (Connected Apps OAuth Usage), plan ECA migrations, delete zombies.
grant_type=passwordanywhere → client credentials flow with a scoped Run As user.- PushTopics / generic streaming → Change Data Capture / platform events.
- New CometD subscribers being planned → build on Pub/Sub API instead.
- Bulk API 1.0 jobs → Bulk 2.0 (automatic batching, simpler lifecycle).
- Legacy all-in-one Named Credentials → External Credentials with permission-set-controlled principals.
- Very old API versions in any client URL → bump to a current version and retest before the announced retirement.
- Secrets in code or custom settings → Named Credentials / external secret managers, always.
Sources: Connected App Creation Has Ended (Help) · Pub/Sub API · Salesforce Release Notes
JSON in Apex: Parsing & Serialization
Every integration lives or dies on payload handling. Apex gives you three tools — typed deserialization, untyped maps, and manual streaming — and choosing the right one is a thirty-second decision that saves hours of string-wrangling.
The Three Approaches
| Tool | Use when | Trade-off |
|---|---|---|
JSON.deserialize(body, MyWrapper.class) | You know the payload shape — the default choice | Compile-time safety, readable code; needs wrapper classes |
JSON.deserializeUntyped(body) | Dynamic or unpredictable payloads | Everything is Map<String,Object> casting — verbose, runtime errors |
JSON.createParser(body) (streaming) | Huge payloads where you need a few fields | Fast and heap-friendly; most code to write |
Wrapper Classes: The Pattern That Scales
// The payload: {"order":{"id":"A-1042","lines":[{"sku":"X1","qty":2}],"total":149.5}}
public class OrderPayload {
public OrderInfo order;
public class OrderInfo {
public String id;
public List<LineItem> lines;
public Decimal total;
}
public class LineItem {
public String sku;
public Integer qty;
}
}
OrderPayload p = (OrderPayload) JSON.deserialize(res.getBody(), OrderPayload.class);
System.debug(p.order.lines[0].sku); // typed access, IDE completion, no casting
// Serializing outbound is the same classes in reverse:
String body = JSON.serialize(p); // includes nulls
String compact = JSON.serialize(p, true); // true = suppress null fields
The Gotchas Everyone Hits
- Reserved-word fields: a payload key like
"currency"or"case"can't be an Apex property name. UsedeserializeUntypedfor that field, or transform the JSON key first — and name your own outbound fields to avoid inflicting this on others. - Missing vs null: absent keys deserialize as
null— guard before dereferencing nested objects (p.order?.lines). - Dates: JSON has no date type; Salesforce expects ISO 8601 strings.
JSON.serializewrites Datetime as UTC ISO — let it, and never hand-format. - Large numbers: IDs that look numeric ("9007199254740999") belong in
Stringfields —Integeroverflows andDecimalhides the intent.
substringBetween, regex). It works until the API adds a field, reorders keys, or returns an array where you assumed an object. If you're splitting strings to read JSON, stop and write the wrapper class.Sources: JSON Class (Apex Reference)
Async Callout Patterns: Queueable & Batch
One transaction gets 100 callouts and 120 seconds — real integrations need more, and they need to not block users. Two async shapes cover nearly every volume: Queueable chains for "some records, sequential control" and Batch Apex with callouts for "many records, managed volume".
Queueable Chains: The Workhorse
public class SyncOrdersJob implements Queueable, Database.AllowsCallouts {
private List<Id> remaining;
private Integer attempt;
public SyncOrdersJob(List<Id> ids) { this(ids, 1); }
private SyncOrdersJob(List<Id> ids, Integer attempt) {
this.remaining = ids; this.attempt = attempt;
}
public void execute(QueueableContext ctx) {
List<Id> batch = new List<Id>();
while (!remaining.isEmpty() && batch.size() < 50) {
batch.add(remaining.remove(0)); // 50 callouts per run, safe margin
}
// ... callouts for `batch`, then DML ...
if (!remaining.isEmpty()) {
System.enqueueJob(new SyncOrdersJob(remaining, 1)); // chain the rest
}
}
}
Database.AllowsCalloutsis the marker interface that permits callouts; each chained run gets fresh limits.- Chains pass state through constructor fields — no re-query needed for the work list.
- Attach a Transaction Finalizer for guaranteed error logging even on uncatchable failures (pattern in the Apex guide's Exception Handling section).
Batch Apex + Callouts: Managed Volume
public class EnrichAccountsBatch implements Database.Batchable<SObject>, Database.AllowsCallouts {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Website FROM Account WHERE Enriched__c = false');
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
// scope size set at execute time: Database.executeBatch(new EnrichAccountsBatch(), 50)
// → 50 records per chunk, each chunk gets its own 100-callout allowance
for (Account a : scope) { /* one callout per account, then collect */ }
// single update after the loop
}
public void finish(Database.BatchableContext bc) {
// summary email / kick off reconciliation
}
}
Choosing the Shape
| Situation | Use |
|---|---|
| Triggered by a user action, tens–hundreds of records | Queueable chain |
| Scheduled sweep over thousands–millions of records | Batch with a tuned scope size |
| Strict ordering across steps (callout A result feeds callout B) | Queueable chain (state in constructor) |
| One quick callout after a trigger, no follow-up | Queueable (skip @future — it can't chain or take objects) |
Sources: Batch Apex Callouts (Apex Guide) · Queueable Apex (Apex Guide)
Resilience: Retries, Circuit Breakers & Idempotency
Remote systems fail — the design question is what your side does next. Three patterns turn "the API was down for ten minutes" from an incident into a non-event.
Retries with Backoff (and When Not to Retry)
- Retry: timeouts, 429 (honor
Retry-After), 502/503/504 — transient by nature. - Never retry blindly: 400/401/403/422 — the request is wrong; retrying repeats the mistake. Fix, don't hammer.
- Backoff: space attempts out (e.g., 1 → 5 → 25 minutes via chained Queueables or scheduled re-enqueue). Immediate tight-loop retries amplify outages — yours and theirs.
- Cap and park: after N attempts, write the payload to a dead-letter object (
Integration_Retry__c: payload, endpoint, attempts, last error) with a scheduled sweeper — nothing silently vanishes.
Circuit Breaker: Stop Calling a Down System
// State lives in one custom object / Platform Cache entry per endpoint
public class CircuitBreaker {
static final Integer THRESHOLD = 5; // failures to trip
static final Integer COOLDOWN_MIN = 10;
public static Boolean isOpen(String endpointKey) {
Endpoint_Health__c h = getHealth(endpointKey);
if (h.Consecutive_Failures__c < THRESHOLD) return false; // closed: call away
if (h.Last_Failure__c < System.now().addMinutes(-COOLDOWN_MIN)) return false; // half-open: try one
return true; // open: fail fast
}
public static void recordSuccess(String key) { /* reset counter */ }
public static void recordFailure(String key) { /* increment + timestamp */ }
}
// In the callout path:
if (CircuitBreaker.isOpen('ERP')) {
parkForRetry(payload); // dead-letter now, don't burn 120s waiting
return;
}
Why it matters on-platform: synchronous requests waiting on a dead API consume the org's concurrent long-running request pool — a slow external system can lock everyone out of the API. Failing fast protects the org, not just the integration.
Idempotency: Making Retries Safe
- Inbound: expose upserts keyed on external IDs (
PATCH .../Account/ERP_Id__c/A-1042) — the retried call updates instead of duplicating. This is the single highest-value integration design habit. - Outbound: send an idempotency key header (a
UUIDper logical operation, stored on the record) so the remote side can deduplicate your retries. - Events: platform events deliver at least once — subscribers must upsert by key, never blind-insert.
- Test it: the reliability test isn't "does it work once" — it's "run the same message twice; is the result identical?"
Sources: Integration Patterns and Practices
Middleware: Point-to-Point vs iPaaS vs ESB
Everything so far connects Salesforce to one system at a time. The architect question arrives with system number three: keep wiring point-to-point, or put something in the middle?
The Decision Table
| Approach | What it is | Right when | Wrong when |
|---|---|---|---|
| Point-to-point | Salesforce talks directly to each system (everything in this guide) | Few systems, stable interfaces, one team owns both ends | N systems start needing N×N connections and shared logic gets copy-pasted |
| iPaaS (MuleSoft and peers) | Cloud integration platform: prebuilt connectors, orchestration, monitoring, API management | Many systems, reuse across integrations, need central monitoring/governance | One simple integration — the platform overhead exceeds the problem |
| ESB (classic enterprise bus) | On-prem middleware routing/transforming messages | You already have one and it works | Greenfield — iPaaS is the modern successor for most cases |
The honest heuristic: count the systems and the teams. Two systems, one team → point-to-point with the patterns in this guide. Five systems, multiple teams, compliance reporting → middleware pays for itself in reuse and observability. MuleSoft's specific pull in Salesforce shops: native connectors both directions and (increasingly) exposing systems to agents via MCP.
Multi-Org: Hub-and-Spoke vs Mesh
- The problem: enterprises run multiple Salesforce orgs (acquisitions, regions) that must share data.
- Mesh (every org syncs with every org): simple at two orgs, unmanageable at four — each new org adds N-1 new integrations and conflict-resolution grows combinatorially.
- Hub-and-spoke: one hub (an iPaaS, a data platform, or a designated master org) owns routing and transformation; each org integrates once, with the hub. New org = one connection, not N.
- Whatever the topology: the source-of-truth table from Plan & Design matters double — per object, exactly one org may win conflicts.
Sources: Integration Patterns and Practices · MuleSoft (official)
MCP & Headless 360: Integration in the Agent Era
A new integration consumer has arrived: AI agents. The Model Context Protocol (MCP) is the open standard they use to discover and call tools — and Salesforce supports it in both directions.
The Paradigm Shift in One Table
| Classic API integration | MCP tool integration | |
|---|---|---|
| Consumer | Code you wrote, calling endpoints you chose at build time | An agent's reasoning engine, choosing tools at runtime from descriptions |
| Contract | OpenAPI/WSDL, enforced by a compiler or client | Tool name + typed schema + natural-language description the model reads |
| Governance | Per-integration credentials and review | Central registry + gateway policies: which agents may use which tools |
| New risk class | Injection, credential leaks (known playbook) | Tool poisoning — a malicious tool description manipulating the model |
Both Directions
- Salesforce as MCP client: Agentforce agents consume external MCP servers (warehouse, courier, analytics tools) through the trusted gateway — the deep dive lives in the Agentforce guide's MCP section.
- Salesforce as MCP server: hosted MCP server support exposes org capabilities as tools for external agents. Auth note that trips everyone: it requires an External Client App — connected apps aren't supported — with scopes granted least-privilege like any client (see ECAs).
- For development: the open-source DX MCP server lets AI coding assistants operate your org (query, deploy, test) — hands-on in our lab article.
Headless 360: Everything as an API
The strategic direction all of this rolls up to: every platform capability reachable programmatically — REST/Composite/Bulk for data, Pub/Sub for events, Agent API for conversations, MCP for agent tools, CLI for ops. "Headless" doesn't mean abandoning Salesforce UI; it means the UI is optional per use case. Our headless agents walkthrough shows the pattern end to end, and the capstone places it in a full architecture.
Sources: Agentforce Developer Guide · Salesforce API Documentation
Integration Security Review
The checklist to run before any integration goes live — and to re-run annually, because integrations rot quietly while nobody watches.
Identity & Access
- Dedicated integration user per integration (the Salesforce Integration license exists for this) — never a human's account, never one shared "API user" for everything.
- Least-privilege scopes on the client app:
apiand exactly what's needed — an integration that reads products doesn't getfullorrefresh_tokenit never uses. - Least-privilege permissions: the integration user's permission sets grant the objects/fields the integration touches, nothing more. "It's easier with Modify All Data" is how breaches get bigger.
- The right flow: client credentials or JWT for machines; web server + PKCE for humans; anything still on username-password is a finding, not a footnote (OAuth Flows).
Secrets
- No secrets in code, custom settings, or metadata — outbound secrets live in External Credentials; external systems keep theirs in a vault.
- Rotation is scheduled, not aspirational: consumer secrets and certificates get calendar entries. If rotating a secret requires a deploy, fix that before the 2 a.m. incident that forces it.
- Revocation drill: know how to kill a compromised integration in minutes — revoke tokens/sessions, disable the client app policy, disable the integration user. Write it down; test it once.
Audit & Monitoring
- Quarterly client-app census: Connected Apps OAuth Usage (and ECA equivalents) — every app maps to a known owner; zero-usage apps get investigated, then deleted.
- Login History for the integration users: new IPs, odd hours, failed-then-succeeded patterns.
- Baseline the volume: an integration that normally makes 5k calls/day suddenly making 500k is either a bug or an exfiltration — either way you want the alert (Debugging covers the tooling).
- Data-in-transit review: what fields actually leave the org? PII that isn't needed downstream gets dropped from the payload, not "filtered later".
Sources: Salesforce Security Guide · Monitor API Usage (Help)
Capstone: A Full Integration Architecture
Everything in this guide, assembled once. The scenario: a manufacturer runs Salesforce (orders, service), an ERP (inventory, invoicing), a courier API, and a customer portal — four systems, one coherent architecture. Every decision links to the section that teaches it.
1. The Flows and Their Patterns
| Flow | Pattern & transport | Sections applied |
|---|---|---|
| Order activated → ERP | Fire-and-forget: platform event, ERP subscribes via Pub/Sub API with stored replay IDs | Events, Pub/Sub |
| ERP shipment updates → Salesforce | Remote call-in: upsert on ERP_Order_Id__c external ID (idempotent by design) | REST, Idempotency |
| Courier tracking → customer portal | Salesforce callout via Named Credential from a Queueable chain, cached on the order record | Callouts, Async Patterns, Named Credentials |
| Portal order actions → Salesforce | Apex REST endpoint with DTOs — the portal never sees raw sObjects | Apex REST |
| Nightly reconciliation | Bulk API query job + delta compare + exception report — the safety net under the event streams | Bulk API |
2. The Auth Matrix
| Connection | Mechanism | Why |
|---|---|---|
| ERP middleware → Salesforce | External Client App + client credentials flow, dedicated integration user, scoped permission sets | Machine-to-machine, no human (Flows, ECA) |
| Portal backend → Salesforce | ECA + JWT bearer (certificate trust), pre-authorized profile | Server-side, secret-free assertions |
| Salesforce → courier API | Named Credential + External Credential (API-key protocol), principal gated by permission set | Outbound secrets never in code (NC) |
| Salesforce → ERP (rare direct reads) | Named Credential backed by an OAuth Auth Provider | Platform manages the token dance (Auth Providers) |
3. Failure Design (Decided Before Go-Live)
- Every event consumer is idempotent (upsert by key); every callout path has retry-with-backoff, a circuit breaker per endpoint, and a dead-letter object with a sweeper (Resilience).
- Errors surface in one place:
Integration_Log__cwith correlation IDs stamped on both sides — a support engineer traces any order across all four systems with one ID. - The reconciliation job is the honesty layer: streams are fast, but the nightly compare is what lets everyone trust the numbers.
4. What the Review Caught (Composite Lessons)
- The portal originally called Salesforce per-widget (six calls per page) — one composite request cut page latency and API consumption by five calls per view.
- The first design had the ERP polling every minute (~1,440 calls/day for mostly-empty responses) — the event stream replaced polling entirely, and the limits math is what made the argument.
- Security review moved shipment-webhook secrets out of a custom setting into the credential store, and deleted two zombie client apps nobody owned (Security Review).
- The one middleware decision: with only four systems and one team, point-to-point won — recorded in writing, with the trigger conditions ("fifth system or second team") that reopen it (Middleware).