Salesforce integrations fall into two categories: outbound (Salesforce calls an external API) and inbound (an external system calls Salesforce). This guide covers both, with a focus on production-ready patterns, credential management, and resilient error handling.
Named Credentials
Never hardcode API endpoints or credentials in Apex code. Named Credentials store the endpoint URL and authentication details securely in Setup, keeping secrets out of your codebase and making credential rotation trivial.
Security Best Practice: Named Credentials abstract authentication completely. Even developers with code access cannot see the stored credentials. They also automatically handle OAuth token refresh.
// Setup > Named Credentials > New
// Developer Name: MyExternalAPI
// URL: https://api.example.com
// Auth Protocol: OAuth 2.0 (configured in Setup)
public class ExternalApiService {
private static final String ENDPOINT = 'callout:MyExternalAPI';
public static HttpResponse get(String path) {
HttpRequest req = new HttpRequest();
// Named Credential handles the base URL + auth headers automatically
req.setEndpoint(ENDPOINT + path);
req.setMethod('GET');
req.setHeader('Content-Type', 'application/json');
req.setHeader('Accept', 'application/json');
req.setTimeout(30000); // 30 seconds
return new Http().send(req);
}
public static HttpResponse post(String path, String body) {
HttpRequest req = new HttpRequest();
req.setEndpoint(ENDPOINT + path);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(body);
req.setTimeout(30000);
return new Http().send(req);
}
}
HTTP Callout Pattern
Build a robust HTTP client with proper response handling, status code checking, and typed return values using the wrapper class pattern.
public class CustomerApiClient {
// Wrapper for API response
public class ApiResponse {
public Boolean success;
public Integer statusCode;
public String body;
public String errorMessage;
}
// Wrapper matching the API's JSON structure
public class Customer {
public String id;
public String name;
public String email;
public String status;
public Decimal balance;
}
public static Customer getCustomer(String customerId) {
ApiResponse response = makeCallout(
'GET',
'/v1/customers/' + EncodingUtil.urlEncode(customerId, 'UTF-8'),
null
);
if (!response.success) {
throw new CalloutException('Failed to get customer: ' + response.errorMessage);
}
return (Customer) JSON.deserialize(response.body, Customer.class);
}
public static Customer createCustomer(Customer c) {
String payload = JSON.serialize(c);
ApiResponse response = makeCallout('POST', '/v1/customers', payload);
if (!response.success) {
throw new CalloutException('Failed to create customer: ' + response.errorMessage);
}
return (Customer) JSON.deserialize(response.body, Customer.class);
}
private static ApiResponse makeCallout(String method, String path, String body) {
ApiResponse result = new ApiResponse();
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:CustomerAPI' + path);
req.setMethod(method);
req.setHeader('Content-Type', 'application/json');
req.setTimeout(30000);
if (body != null) req.setBody(body);
HttpResponse res = new Http().send(req);
result.statusCode = res.getStatusCode();
result.body = res.getBody();
// 2xx = success
result.success = result.statusCode >= 200 && result.statusCode < 300;
if (!result.success) {
result.errorMessage = 'HTTP ' + result.statusCode + ': ' + res.getBody();
}
} catch (System.CalloutException e) {
result.success = false;
result.errorMessage = e.getMessage();
System.debug(LoggingLevel.ERROR, 'Callout failed: ' + e.getMessage());
}
return result;
}
}
Exposing Apex as REST API
Use @RestResource to expose Apex classes as REST endpoints that external systems can call. Your org's connected app manages authentication for inbound calls.
// Accessible at: /services/apexrest/accounts/v1/*
@RestResource(urlMapping='/accounts/v1/*')
global class AccountRestService {
@HttpGet
global static Account getAccount() {
RestRequest req = RestContext.request;
String accountId = req.requestURI.substringAfterLast('/');
try {
return [
SELECT Id, Name, Industry, AnnualRevenue, Phone
FROM Account
WHERE Id = :accountId
LIMIT 1
];
} catch (QueryException e) {
RestContext.response.statusCode = 404;
return null;
}
}
@HttpPost
global static String createAccount(String name, String industry, Decimal revenue) {
try {
Account acc = new Account(
Name = name,
Industry = industry,
AnnualRevenue = revenue
);
insert acc;
RestContext.response.statusCode = 201;
return acc.Id;
} catch (DmlException e) {
RestContext.response.statusCode = 400;
return e.getMessage();
}
}
@HttpDelete
global static void deleteAccount() {
String accountId = RestContext.request.requestURI.substringAfterLast('/');
Account acc = new Account(Id = accountId);
delete acc;
RestContext.response.statusCode = 204;
}
}
Mocking Callouts in Tests
Apex tests cannot make real HTTP callouts. Use HttpCalloutMock to simulate API responses and test all code paths including errors.
// Mock class — simulates a successful API response
@isTest
global class CustomerApiMock implements HttpCalloutMock {
private Integer statusCode;
private String body;
global CustomerApiMock(Integer code, String responseBody) {
this.statusCode = code;
this.body = responseBody;
}
global HTTPResponse respond(HTTPRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
res.setBody(this.body);
res.setStatusCode(this.statusCode);
return res;
}
}
// Test class using the mock
@isTest
private class CustomerApiClientTest {
@isTest
static void testGetCustomer_success() {
String mockBody = '{"id":"C001","name":"Acme Corp","email":"info@acme.com","status":"active","balance":5000}';
Test.setMock(HttpCalloutMock.class, new CustomerApiMock(200, mockBody));
Test.startTest();
CustomerApiClient.Customer customer = CustomerApiClient.getCustomer('C001');
Test.stopTest();
System.assertNotEquals(null, customer);
System.assertEquals('Acme Corp', customer.name);
System.assertEquals(5000, customer.balance);
}
@isTest
static void testGetCustomer_notFound() {
Test.setMock(HttpCalloutMock.class, new CustomerApiMock(404, '{"error":"Not found"}'));
Test.startTest();
try {
CustomerApiClient.getCustomer('INVALID');
System.assert(false, 'Should have thrown');
} catch (CalloutException e) {
System.assert(e.getMessage().contains('404'));
}
Test.stopTest();
}
}
Retry & Resilience Patterns
External APIs are unreliable. Build retry logic with exponential backoff using Queueable chaining to handle transient failures gracefully.
Idempotency: Before retrying, ensure the operation is idempotent — retrying a POST that creates a record might create duplicates. Use idempotency keys or check-then-act patterns.
public class RetryableCalloutJob implements Queueable, Database.AllowsCallouts {
private String recordId;
private Integer attempts;
private static final Integer MAX_ATTEMPTS = 3;
public RetryableCalloutJob(String recordId, Integer attempts) {
this.recordId = recordId;
this.attempts = attempts;
}
public void execute(QueueableContext ctx) {
try {
// Attempt the callout
CustomerApiClient.Customer c = CustomerApiClient.getCustomer(recordId);
// Process successful result...
System.debug('Success on attempt ' + attempts);
} catch (Exception e) {
if (attempts < MAX_ATTEMPTS) {
// Schedule retry (Queueable chaining)
System.debug('Attempt ' + attempts + ' failed, retrying...');
System.enqueueJob(new RetryableCalloutJob(recordId, attempts + 1));
} else {
// Max attempts reached — log and alert
System.debug(LoggingLevel.ERROR, 'All ' + MAX_ATTEMPTS + ' attempts failed for ' + recordId);
// Insert an error log custom record or send email alert
}
}
}
}