Guides building Salesforce Apex integrations with REST/SOAP callouts, Named Credentials, inbound APIs, and External Services. Includes retry logic and auth patterns.
npx claudepluginhub jiten-singh-shahi/salesforce-claude-code --plugin salesforce-claude-codeThis skill uses the workspace's default tool permissions.
Procedures for building integrations between Salesforce and external systems. Limits, auth protocols, and pattern decision matrices live in the reference file.
Provides patterns for Salesforce platform development: Lightning Web Components (LWC), Apex triggers/classes, REST/Bulk APIs, Connected Apps, Salesforce DX with scratch orgs and 2GP.
Applies production-ready jsforce (TypeScript/Node.js) and simple-salesforce (Python) patterns for Salesforce integrations: singleton connections, typed queries, error handling, token refresh.
Writes and debugs Apex code, builds Lightning Web Components, optimizes SOQL queries, implements triggers, batch jobs, platform events, and Salesforce integrations. Use for CRM workflows, governor limits, bulk processing, and Salesforce DX CI/CD.
Share bugs, ideas, or general feedback.
Procedures for building integrations between Salesforce and external systems. Limits, auth protocols, and pattern decision matrices live in the reference file.
@../_reference/INTEGRATION_PATTERNS.md
public with sharing class OrderManagementIntegration {
private static final String NAMED_CREDENTIAL = 'OrderManagementAPI';
private static final Integer TIMEOUT_MS = 10000;
private static final Integer MAX_RETRIES = 2;
public static OrderResponse createExternalOrder(OrderRequest orderData) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:' + NAMED_CREDENTIAL + '/api/v2/orders');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('Accept', 'application/json');
req.setTimeout(TIMEOUT_MS);
req.setBody(JSON.serialize(orderData));
return executeWithRetry(req, MAX_RETRIES);
}
public static OrderResponse getOrderStatus(String externalOrderId) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:' + NAMED_CREDENTIAL +
'/api/v2/orders/' + EncodingUtil.urlEncode(externalOrderId, 'UTF-8'));
req.setMethod('GET');
req.setHeader('Accept', 'application/json');
req.setTimeout(TIMEOUT_MS);
return executeWithRetry(req, MAX_RETRIES);
}
/**
* In-transaction retry for transient network glitches.
* For true backoff, use Queueable chaining with
* AsyncOptions.minimumQueueableDelayInMinutes between attempts.
*/
private static OrderResponse executeWithRetry(HttpRequest req, Integer retries) {
Http http = new Http();
HttpResponse res;
Exception lastException;
for (Integer attempt = 0; attempt <= retries; attempt++) {
try {
res = http.send(req);
if (res.getStatusCode() == 200 || res.getStatusCode() == 201) {
return (OrderResponse) JSON.deserialize(
res.getBody(), OrderResponse.class);
}
if (res.getStatusCode() == 429) {
if (attempt == retries) {
throw new IntegrationException(
'Rate limited (429) after ' + (retries + 1) + ' attempts.');
}
continue;
}
if (res.getStatusCode() >= 500 && attempt < retries) continue;
throw new IntegrationException(
'HTTP ' + res.getStatusCode() + ': ' + res.getBody());
} catch (System.CalloutException e) {
lastException = e;
if (attempt == retries) {
throw new IntegrationException(
'Callout failed after ' + (retries + 1) +
' attempts: ' + e.getMessage(), e);
}
}
}
throw new IntegrationException('Unexpected retry loop exit');
}
public class OrderRequest {
public String externalAccountId;
public String productCode;
public Integer quantity;
public Decimal unitPrice;
public String currency_x;
}
public class OrderResponse {
public String orderId;
public String status;
public String message;
public String createdAt;
}
public class IntegrationException extends Exception {}
}
Step 1 -- Create External Credential (Setup > Security > Named Credentials > External Credentials)
Named Principal (shared credential) or Per User (each user authenticates separately)Step 2 -- Create Named Credential referencing the External Credential
OrderManagementAPIhttps://erp.example.comStep 3 -- Grant permission via Permission Set on the External Credential Principal
// Usage in Apex — identical to legacy Named Credentials
req.setEndpoint('callout:OrderManagementAPI/api/v2/orders');
// Auth headers injected automatically
Still valid for simpler use cases. Combine URL + auth in one config.
req.setEndpoint('callout:LegacyNamedCred/endpoint');
# Generate Apex stub from WSDL
sf generate apex from-wsdl \
--file path/to/service.wsdl \
--output-dir force-app/main/default/classes
public with sharing class SoapIntegration {
private static final String SOAP_ENDPOINT = 'callout:LegacySoapService/service';
public static String invokeMethod(String accountNumber) {
String safeAccountNumber = escapeXml(accountNumber);
String soapBody =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"' +
' xmlns:leg="http://legacy.example.com/service">' +
' <soapenv:Header/>' +
' <soapenv:Body>' +
' <leg:GetAccountDetails>' +
' <leg:AccountNumber>' + safeAccountNumber + '</leg:AccountNumber>' +
' </leg:GetAccountDetails>' +
' </soapenv:Body>' +
'</soapenv:Envelope>';
HttpRequest req = new HttpRequest();
req.setEndpoint(SOAP_ENDPOINT);
req.setMethod('POST');
req.setHeader('Content-Type', 'text/xml; charset=UTF-8');
req.setHeader('SOAPAction', '"GetAccountDetails"');
req.setBody(soapBody);
req.setTimeout(15000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException('SOAP error: ' + res.getStatus());
}
Dom.Document doc = res.getBodyDocument();
Dom.XmlNode root = doc.getRootElement();
Dom.XmlNode body = root.getChildElement(
'Body', 'http://schemas.xmlsoap.org/soap/envelope/');
Dom.XmlNode responseEl = body.getChildElements()[0];
Dom.XmlNode result = responseEl.getChildElement(
'Result', 'http://legacy.example.com/service');
return result != null ? result.getText() : null;
}
private static String escapeXml(String input) {
if (String.isBlank(input)) return input;
return input
.replace('&', '&')
.replace('<', '<')
.replace('>', '>')
.replace('"', '"')
.replace('\'', ''');
}
}
@RestResource(urlMapping='/v1/accounts/*')
global with sharing class AccountRestService {
@HttpGet
global static AccountDTO getAccount() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
String accountId = req.requestURI.substring(
req.requestURI.lastIndexOf('/') + 1);
if (String.isBlank(accountId)) {
res.statusCode = 400;
return new AccountDTO(null, null, 'Invalid Account ID');
}
try {
Id.valueOf(accountId);
} catch (StringException e) {
res.statusCode = 400;
return new AccountDTO(null, null, 'Invalid Account ID');
}
List<Account> accounts = [
SELECT Id, Name, Phone, BillingCity, BillingCountry
FROM Account WHERE Id = :accountId
WITH USER_MODE LIMIT 1
];
if (accounts.isEmpty()) {
res.statusCode = 404;
return new AccountDTO(null, null, 'Account not found');
}
res.statusCode = 200;
return new AccountDTO(accounts[0].Id, accounts[0].Name, null);
}
@HttpPost
global static AccountDTO createAccount() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
try {
Map<String, Object> body =
(Map<String, Object>) JSON.deserializeUntyped(
req.requestBody.toString());
Account acc = new Account();
acc.Name = (String) body.get('name');
acc.Phone = (String) body.get('phone');
acc.BillingCity = (String) body.get('billingCity');
if (String.isBlank(acc.Name)) {
res.statusCode = 400;
return new AccountDTO(null, null, 'Account name is required');
}
Database.insert(acc, AccessLevel.USER_MODE);
res.statusCode = 201;
return new AccountDTO(acc.Id, acc.Name, 'Account created');
} catch (DmlException e) {
res.statusCode = 400;
return new AccountDTO(null, null, 'DML error: ' + e.getDmlMessage(0));
} catch (JSONException e) {
res.statusCode = 400;
return new AccountDTO(null, null, 'Invalid JSON: ' + e.getMessage());
} catch (System.SecurityException e) {
res.statusCode = 403;
return new AccountDTO(null, null, 'Insufficient access: ' + e.getMessage());
}
}
global class AccountDTO {
global String id;
global String name;
global String message;
global AccountDTO(String id, String name, String message) {
this.id = id;
this.name = name;
this.message = message;
}
}
}
@IsTest
public class OrderIntegrationMock implements HttpCalloutMock {
private Integer statusCode;
private String body;
public OrderIntegrationMock(Integer statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(statusCode);
res.setHeader('Content-Type', 'application/json');
res.setBody(body);
return res;
}
}
@IsTest
public class MultiRequestMock implements HttpCalloutMock {
private Map<String, HttpCalloutMock> mocks = new Map<String, HttpCalloutMock>();
public void addMock(String endpoint, HttpCalloutMock mock) {
mocks.put(endpoint, mock);
}
public HttpResponse respond(HttpRequest req) {
String endpoint = req.getEndpoint();
for (String key : mocks.keySet()) {
if (endpoint.contains(key)) {
return mocks.get(key).respond(req);
}
}
throw new IllegalArgumentException('Unexpected callout to: ' + endpoint);
}
}
@IsTest
static void testCreateOrder_success_returnsOrderId() {
String mockResponse =
'{"orderId":"ERP-001","status":"PENDING","message":"Order created"}';
Test.setMock(HttpCalloutMock.class,
new OrderIntegrationMock(201, mockResponse));
Test.startTest();
OrderManagementIntegration.OrderRequest req =
new OrderManagementIntegration.OrderRequest();
req.productCode = 'PROD-001';
req.quantity = 5;
req.unitPrice = 99.99;
OrderManagementIntegration.OrderResponse res =
OrderManagementIntegration.createExternalOrder(req);
Test.stopTest();
System.assertEquals('ERP-001', res.orderId);
System.assertEquals('PENDING', res.status);
}
External Client Apps replace Connected Apps for new OAuth-based integrations.
| Feature | Connected App | External Client App |
|---|---|---|
| Status | Existing -- maintain as-is | New standard for Spring '26+ |
| Location | Setup > App Manager | Setup > External Client App Manager |
| Metadata type | ConnectedApp | ExternalClientApplication |
| Recommendation | Keep existing; do not migrate | Use for all new integrations |
<?xml version="1.0" encoding="UTF-8"?>
<ExternalClientApplication xmlns="http://soap.sforce.com/2006/04/metadata">
<contactEmail>integrations@example.com</contactEmail>
<description>OAuth integration with Warehouse Management System</description>
<distributionState>Global</distributionState>
<label>Warehouse Integration</label>
<name>WarehouseIntegration</name>
</ExternalClientApplication>
sf project deploy start \
--metadata ExternalClientApplication:WarehouseIntegration \
--target-org Production
For callouts with transient failures, implement a retry Queueable:
public class RetryCalloutQueueable implements Queueable, Database.AllowsCallouts {
private Id recordId;
private Integer attemptNumber;
private static final Integer MAX_ATTEMPTS = 3;
public RetryCalloutQueueable(Id recordId, Integer attemptNumber) {
this.recordId = recordId;
this.attemptNumber = attemptNumber;
}
public void execute(QueueableContext ctx) {
try {
ExternalIntegration.sync(recordId);
update new MyRecord__c(
Id = recordId,
Sync_Status__c = 'Success',
Last_Sync__c = Datetime.now()
);
} catch (Exception e) {
if (attemptNumber < MAX_ATTEMPTS) {
System.enqueueJob(
new RetryCalloutQueueable(recordId, attemptNumber + 1));
} else {
update new MyRecord__c(
Id = recordId,
Sync_Status__c = 'Failed',
Sync_Error__c = e.getMessage()
);
}
}
}
}
sf-integration-agent -- for interactive, in-depth guidance