Integration Developer Guide

A complete, leveled curriculum — basics, every OAuth flow, client apps, credentials, patterns, architect track — with worked examples and a full capstone architecture.

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 / ToolDirectionBuilt forSection
REST APIInboundCRUD + SOQL over HTTP/JSON; mobile & web appsREST Basics
Apex RESTInboundYour own custom endpoints with business logicApex REST
Composite RESTInboundMany operations in one round tripComposite API
SOAP APIInboundWSDL-contract enterprise/legacy systemsSOAP Basics
Bulk API 2.0InboundMillions of records (ETL, migrations)Bulk API
Platform Events / CDCBothEvent-driven, near-real-time messagingEvent Basics
Pub/Sub APIBothThe modern gRPC event stream for external subscribersStreaming & Pub/Sub
Apex CalloutsOutboundSalesforce calling external REST/SOAP servicesCallouts
External ServicesOutboundDeclarative callouts from an OpenAPI specExternal 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 → testdebugtune.

How to Learn This Guide (in order)

StageSections, in sequenceYou can now…
Start HereThis page → Plan & DesignPatternsName the pattern any requirement maps to
L1 · BasicsRESTSOAPCalloutsJSONLimitsBulkEventsMove data in and out, both directions, within limits
L2 · AuthenticationBuilding blocksevery OAuth flowPick and implement the right flow for any client
L3 · Client AppsExternal Client AppsRegister, police and migrate inbound clients
L4 · CredentialsNamed/External CredentialsAuth ProvidersOutbound auth with zero secrets in code
L5 · PatternsEvents/CDC → Apex RESTCompositeAsyncResilienceTestingBuild production-grade, failure-tolerant integrations
L6 · ArchitectMiddlewareMCP & HeadlessSecurity ReviewCapstoneOwn 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)

PieceJobDetails
External Client App (successor of the connected app)Registers an inbound client — issues the consumer key/secret an external system uses to get tokensExternal Client Apps
OAuth 2.0 flowThe handshake that turns those credentials into an access tokenOAuth 2.0 Flows — all of them, with examples
Named Credential (+ External Credential)Stores outbound endpoint + auth so callout code never touches secretsNamed Credentials
Auth ProviderLets Salesforce authenticate to third parties (Google, GitHub, another SF org) — powers per-user external auth and social loginAuth 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 /limits resource 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 GET query-string parameters, or gets forced into POST — 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 like POST, 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.
AspectGETPOSTQUERY
Request bodyDiscouraged / unreliableYesYes
SemanticsReadCreate / processRead (search)
SafeYesNoYes
IdempotentYesNoYes
# 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
}
Where this fits with Salesforce: QUERY is an emerging web standard, and client, proxy and server support is still rolling out — so treat it as a direction to recognize, not something to hard-code everywhere yet. Salesforce's own SOQL endpoint stays 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.

Source: RFC 10008 — The HTTP QUERY Method (IETF)

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

FactorRESTSOAP
PayloadJSON (lightweight, web-native)XML envelopes (verbose, schema-validated)
ContractOpenAPI (optional, by convention)WSDL (mandatory, machine-enforced)
Tooling fitEverything modern: mobile, JS, middleware, curlEnterprise stacks: Java/.NET clients generated from WSDL
Default for new workYes — including Composite and Bulk 2.0Only 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

  1. Navigate to Setup > API
  2. Download Enterprise WSDL or Partner WSDL
  3. 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 ChangeEventHeader plus 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:

PieceHoldsExample
Named CredentialThe endpoint URL + which external credential to usehttps://api.weatherapi.com/v1
External CredentialThe authentication protocol + principals (the actual secrets)OAuth 2.0 client-credentials against the vendor's token URL
PrincipalA 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)

  1. Setup → Named Credentials → External Credentials tab → New (pick protocol, create a principal)
  2. Add the external credential's principal to a permission set assigned to your integration/running users
  3. Named Credentials tab → New → set Label, Name, URL, and select your external credential
  4. 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 SettingNamed Credential
What it doesOnly whitelists a URL — auth is entirely your code's problemWhitelists and authenticates: the platform injects credentials at runtime
SecretsEnd up in code, custom settings, or metadataStored in the credential store, gated by permission sets
Environment changesEndpoint hardcoded in Apex — deploy to changeEndpoint is config — sandbox and production differ without code changes
Token refreshYou write itPlatform 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

  1. Navigate to Setup > External Services
  2. Click "New External Service"
  3. Upload your OpenAPI specification file
  4. Select a Named Credential
  5. Review the generated operations
  6. 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 q to 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

QuestionOptions → 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__e published 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__c external 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

FlowUse whenStatus
Web Server (Authorization Code) + PKCEA web app acts on behalf of a logged-in userRecommended default for user-context apps
Client CredentialsServer-to-server, no human involvedRecommended for system integrations
JWT BearerServer-to-server with certificate-based trust; CI/CDRecommended (powers CLI auth in pipelines)
Refresh TokenRenew an expired access token without re-loginCompanion to web server flow
Device FlowInput-constrained devices (kiosks, CLIs, IoT) — user approves on another deviceNiche but current
User-Agent FlowLegacy single-page/mobile apps (token in URL fragment)Legacy — use web server + PKCE instead
Username-PasswordBlocked 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=password ships 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)

TokenPurposeLifetime & handling
Access tokenThe API key-card: sent as Authorization: Bearer on every callShort-lived (session policy); never store long-term
Refresh tokenMints new access tokens without re-loginLong-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 callsVerify 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

AspectConnected App (legacy)External Client App
Creation todayBlocked (phased out)Setup → External Client App Manager
Secrets in metadataConsumer secret could end up in metadata/packagesCredentials separated from packageable settings — safer packaging & source control
StructureOne monolithic definitionSplit into global app + org-level policies (distribution-friendly, 2GP-packageable)
Security optionsPKCE optionalAdmins can require PKCE for web server / hybrid / code-and-credentials flows — non-PKCE attempts are blocked
Existing appsKeep working (end-of-support to be announced)Migration tool converts connected-app metadata to ECA format

Creating an ECA for a Server Integration

  1. Setup → External Client App Manager → New External Client App.
  2. Enable OAuth, set the callback URL (or a placeholder for client-credentials-only apps), select scopes (api, refresh_token as needed — grant the minimum).
  3. Enable the flows you actually use (e.g., Client Credentials with a Run As user; require PKCE for browser flows) — everything else stays off.
  4. Retrieve the consumer key/secret from the app's settings; store them in your external system's secret manager, never in code.
  5. 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)

  1. Create an OAuth client in the external system (Google Cloud Console) — you'll get its client ID/secret.
  2. Setup → Auth. Providers → New → choose the provider type (Google, Microsoft, GitHub, Salesforce, or generic OpenID Connect / custom Auth.AuthProviderPluginClass).
  3. Paste the client ID/secret and scopes; save — Salesforce generates the callback URL.
  4. Register that callback URL back in the external system's OAuth client.
  5. 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.response give 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

ResourceWhat it doesAtomic?
/compositeUp to 25 chained subrequests with @{ref.id} referencesOptional (allOrNone)
/composite/graphMultiple graphs of chained operations, up to 500 nodes per requestEach graph is all-or-nothing
/composite/treeCreate a parent + children hierarchy (up to 200 records) in one POSTYes
/composite/sobjectsBulk-ish CRUD on up to 200 records of the same shape (collections)Optional (allOrNone)
/composite/batchUp 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 typeExample channelStatus
Custom platform events/event/OrderShipped__eCurrent — the workhorse
Change Data Capture/data/AccountChangeEvent (or /data/ChangeEvents for all)Current — zero-code data sync
Custom channelsGrouped/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/notificationsLegacy — use platform events

CometD vs Pub/Sub API

Streaming API (CometD)Pub/Sub API
TransportHTTP long-polling (Bayeux)gRPC over HTTP/2
PayloadJSONApache Avro binary (schema-first, compact)
Flow controlServer pushes; client keeps up or dropsClient pulls — you request N events when ready (backpressure built in)
PublishingNot supported (separate REST/Apex publish)Publish and subscribe over one API
ClientsCometD libraries, EMP ConnectorAny gRPC language — official proto file + examples (Java, Python, Go, Node…)
ReplayReplay 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; -2 after 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_RESPONSE lines 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 /limits resource: 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

SymptomUsual causeFix
401 INVALID_SESSION_IDExpired/revoked access tokenRefresh-token flow (or new client-credentials token), then retry once
403 REQUEST_LIMIT_EXCEEDEDDaily API allocation exhaustedBackoff + composite/bulk to reduce call count; check /limits
400 invalid_grant at token endpointBlocked flow, un-authorized JWT user, bad clock (JWT exp), wrong My Domain hostLogin History tells you which; verify app policies
CalloutException: uncommitted work pendingCallout attempted after DML in one transactionReorder: callouts first, or move callout to a Queueable
Read timed outRemote slower than setTimeoutRaise timeout (≤120s), make the call async, add retry
ENTITY_IS_LOCKED / UNABLE_TO_LOCK_ROWParallel loads hitting related recordsSerialize by parent (sort your Bulk CSV by AccountId), reduce parallelism
Events published but never receivedTransaction rolled back (after-commit publish), wrong channel name, or replay from -1 after downtimeCheck 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__c for 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

LimitValueDesign consequence
Daily API requestsEdition + license based (Enterprise starts ~100k/day; check /limits)Chatty per-record sync patterns die at scale — batch or composite
Concurrent long-running requests25 requests running >20sSlow synchronous endpoints can lock out the whole org's API traffic
Apex callouts100 per transaction, ≤120s totalFan-out callouts belong in chained Queueables
Bulk API 2.0 ingest150M records/day (own allocation)Big loads don't eat your REST allocation — use it
Platform event publishing/deliveryHourly allocations by license (high-volume events)Don't publish per-field-change; publish meaningful business events
Streaming retention24h standard / 72h high-volumeSubscriber 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 /composite call 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: gzip on 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

WhenChangeWhat it means for you
EarlierOAuth username-password flow blocked by default in new orgsMigrate stragglers to client credentials
RecentConnected app creation disabled in new orgsLearn External Client Apps now
RecentConnected app creation disabled in all orgs (packages/support exceptions aside); existing apps keep working, end-of-support to be announcedInventory + migrate with the CA→ECA tool
LatestOlder API versions are deprecated on a rolling retirement scheduleAudit hardcoded /services/data/vXX versions in every client
OngoingPub/Sub API is the strategic event transport; CometD maintained for existing integrationsNew subscribers speak gRPC
New surfaceAgent API + MCP servers expose Agentforce to external appsSee the Agentforce guide — "headless agent" is an integration pattern now
Web standardThe HTTP QUERY method is standardized (RFC 10008) — a safe, idempotent read that carries a request bodyRecognize 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=password anywhere → 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

ToolUse whenTrade-off
JSON.deserialize(body, MyWrapper.class)You know the payload shape — the default choiceCompile-time safety, readable code; needs wrapper classes
JSON.deserializeUntyped(body)Dynamic or unpredictable payloadsEverything is Map<String,Object> casting — verbose, runtime errors
JSON.createParser(body) (streaming)Huge payloads where you need a few fieldsFast 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. Use deserializeUntyped for 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.serialize writes Datetime as UTC ISO — let it, and never hand-format.
  • Large numbers: IDs that look numeric ("9007199254740999") belong in String fields — Integer overflows and Decimal hides the intent.
Common mistake: parsing JSON with string methods (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.AllowsCallouts is 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

SituationUse
Triggered by a user action, tens–hundreds of recordsQueueable chain
Scheduled sweep over thousands–millions of recordsBatch 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-upQueueable (skip @future — it can't chain or take objects)
Common mistake: looping callouts synchronously inside a trigger path because "it's only a few records today." Volume grows, the 100-callout / 120-second wall arrives in production, and the fix is the async refactor you could have started with.

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 UUID per 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?"
Common mistake: building retries without idempotency. The retry succeeds — and so did the original, invisibly — and now there are two invoices. Retries and idempotent design are one feature, not two.

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

ApproachWhat it isRight whenWrong when
Point-to-pointSalesforce talks directly to each system (everything in this guide)Few systems, stable interfaces, one team owns both endsN systems start needing N×N connections and shared logic gets copy-pasted
iPaaS (MuleSoft and peers)Cloud integration platform: prebuilt connectors, orchestration, monitoring, API managementMany systems, reuse across integrations, need central monitoring/governanceOne simple integration — the platform overhead exceeds the problem
ESB (classic enterprise bus)On-prem middleware routing/transforming messagesYou already have one and it worksGreenfield — 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.
Common mistake: buying middleware to fix an unclear data model. If nobody can say which system owns the customer record, an iPaaS just moves the ambiguity faster. Ownership first, tooling second.

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 integrationMCP tool integration
ConsumerCode you wrote, calling endpoints you chose at build timeAn agent's reasoning engine, choosing tools at runtime from descriptions
ContractOpenAPI/WSDL, enforced by a compiler or clientTool name + typed schema + natural-language description the model reads
GovernancePer-integration credentials and reviewCentral registry + gateway policies: which agents may use which tools
New risk classInjection, 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.

Common mistake: treating MCP tools as "just APIs" in security review. The registry/gateway layer and tool-description review are new control points — an integration checklist written for REST doesn't cover a consumer that reads prose and decides.

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: api and exactly what's needed — an integration that reads products doesn't get full or refresh_token it 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".
Common mistake: reviewing security once, at go-live. The integration user's permissions grow ("just add this object"), scopes accumulate, secrets age, and the app registered by someone who left still works. The annual re-review is where those findings live.

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

FlowPattern & transportSections applied
Order activated → ERPFire-and-forget: platform event, ERP subscribes via Pub/Sub API with stored replay IDsEvents, Pub/Sub
ERP shipment updates → SalesforceRemote call-in: upsert on ERP_Order_Id__c external ID (idempotent by design)REST, Idempotency
Courier tracking → customer portalSalesforce callout via Named Credential from a Queueable chain, cached on the order recordCallouts, Async Patterns, Named Credentials
Portal order actions → SalesforceApex REST endpoint with DTOs — the portal never sees raw sObjectsApex REST
Nightly reconciliationBulk API query job + delta compare + exception report — the safety net under the event streamsBulk API

2. The Auth Matrix

ConnectionMechanismWhy
ERP middleware → SalesforceExternal Client App + client credentials flow, dedicated integration user, scoped permission setsMachine-to-machine, no human (Flows, ECA)
Portal backend → SalesforceECA + JWT bearer (certificate trust), pre-authorized profileServer-side, secret-free assertions
Salesforce → courier APINamed Credential + External Credential (API-key protocol), principal gated by permission setOutbound secrets never in code (NC)
Salesforce → ERP (rare direct reads)Named Credential backed by an OAuth Auth ProviderPlatform 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__c with 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).
The capstone habit: every integration architecture should be explainable as three tables — flows/patterns, auth matrix, failure design. If one of the three is hard to fill in, that's the gap production will find for you.