Salesforce REST API Integration: Step-by-Step Guide to Connecting External Systems with Apex
By Firus Hanov ยท ยท 10 min read
Build Salesforce REST integrations: outbound callouts with Named Credentials, inbound @RestResource endpoints, async patterns, error handling, and retry logic.

Modern Salesforce implementations almost never stand alone. Your org needs to talk to ERP systems, payment processors, marketing platforms, shipping providers โ the list goes on. And most communicate via REST APIs.
In this guide, I'll show you how to build both sides: calling external APIs from Salesforce (outbound), and exposing Salesforce data to external systems (inbound). ๐ฅ
Fundamentals: Named Credentials
Before writing callout code, set up a Named Credential. Named Credentials store endpoint URLs and authentication settings so your code never hard-codes credentials.
HttpRequest request = new HttpRequest();
request.setEndpoint('callout:External_CRM/contacts');
request.setMethod('GET');
Why Named Credentials matter:
- No hard-coded credentials in code
- URL changes are configuration, not deployments
- Authentication handled automatically (OAuth token refresh, etc.)
- Works across environments
Part 1: Outbound Callouts
The Basic Callout Pattern
public class ExternalAPIService {
private static final String NAMED_CREDENTIAL = 'External_CRM';
public static HttpResponse makeGetRequest(String path) {
Http http = new Http();
HttpRequest request = new HttpRequest();
request.setEndpoint('callout:' + NAMED_CREDENTIAL + path);
request.setMethod('GET');
request.setHeader('Content-Type', 'application/json');
request.setTimeout(30000);
return http.send(request);
}
public static HttpResponse makePostRequest(String path, String jsonBody) {
Http http = new Http();
HttpRequest request = new HttpRequest();
request.setEndpoint('callout:' + NAMED_CREDENTIAL + path);
request.setMethod('POST');
request.setHeader('Content-Type', 'application/json');
request.setBody(jsonBody);
request.setTimeout(30000);
return http.send(request);
}
}
Parsing the Response
public class ContactSyncService {
public static void syncContactToExternalCRM(Contact contact) {
Map<String, Object> payload = new Map<String, Object>{
'externalId' => contact.Id,
'firstName' => contact.FirstName,
'lastName' => contact.LastName,
'email' => contact.Email
};
String jsonBody = JSON.serialize(payload);
HttpResponse response = ExternalAPIService.makePostRequest('/contacts', jsonBody);
if (response.getStatusCode() == 200 || response.getStatusCode() == 201) {
Map<String, Object> responseBody = (Map<String, Object>)
JSON.deserializeUntyped(response.getBody());
String externalId = (String) responseBody.get('id');
contact.External_CRM_Id__c = externalId;
update contact;
} else {
throw new CalloutException('Sync failed: ' + response.getStatusCode());
}
}
}
Strongly-Typed JSON Parsing
public class ExternalContact {
public String id;
public String firstName;
public String lastName;
public String email;
public ExternalAddress address;
public class ExternalAddress {
public String street;
public String city;
public String state;
}
}
// Parsing:
ExternalContact ec = (ExternalContact) JSON.deserialize(response.getBody(), ExternalContact.class);
Async Callouts (Avoiding DML + Callout Conflicts)
// @Future approach
@Future(callout=true)
public static void syncContact(Id contactId) {
Contact c = [SELECT Id, FirstName, LastName, Email FROM Contact WHERE Id = :contactId];
ContactSyncService.syncContactToExternalCRM(c);
}
// Queueable approach โ more flexible
public class ContactSyncQueueable implements Queueable, Database.AllowsCallouts {
private Id contactId;
public ContactSyncQueueable(Id contactId) { this.contactId = contactId; }
public void execute(QueueableContext context) {
Contact c = [SELECT Id, FirstName, LastName, Email FROM Contact WHERE Id = :contactId];
ContactSyncService.syncContactToExternalCRM(c);
}
}
Error Handling and Retry Logic
public class ResilientCalloutService {
private static final Integer MAX_RETRIES = 3;
public static HttpResponse callWithRetry(String path, String method, String body) {
HttpResponse response;
Integer attempts = 0;
Exception lastException;
while (attempts < MAX_RETRIES) {
try {
Http http = new Http();
HttpRequest request = new HttpRequest();
request.setEndpoint('callout:External_CRM' + path);
request.setMethod(method);
request.setHeader('Content-Type', 'application/json');
if (String.isNotBlank(body)) request.setBody(body);
request.setTimeout(30000);
response = http.send(request);
if (response.getStatusCode() >= 500) {
attempts++;
continue;
}
return response;
} catch (Exception e) {
attempts++;
lastException = e;
}
}
throw lastException;
}
}
Part 2: Inbound REST โ Exposing Salesforce Data
@RestResource(urlMapping='/contacts/*')
global with sharing class ContactRESTService {
@HttpGet
global static Contact getContactById() {
RestRequest req = RestContext.request;
String contactId = req.requestURI.substringAfterLast('/');
if (String.isBlank(contactId)) {
RestContext.response.statusCode = 400;
return null;
}
try {
return [SELECT Id, FirstName, LastName, Email, Phone FROM Contact WHERE Id = :contactId WITH SECURITY_ENFORCED LIMIT 1];
} catch (QueryException e) {
RestContext.response.statusCode = 404;
return null;
}
}
@HttpPost
global static RestResponse createContact() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
RestResponse result = new RestResponse();
try {
Map<String, Object> params = (Map<String, Object>) JSON.deserializeUntyped(req.requestBody.toString());
Contact c = new Contact(
FirstName = (String) params.get('firstName'),
LastName = (String) params.get('lastName'),
Email = (String) params.get('email')
);
if (String.isBlank(c.LastName)) {
res.statusCode = 400;
result.message = 'lastName is required';
return result;
}
insert c;
res.statusCode = 201;
result.id = c.Id;
result.message = 'Contact created successfully';
return result;
} catch (Exception e) {
res.statusCode = 500;
result.message = e.getMessage();
return result;
}
}
global class RestResponse {
global String id;
global String message;
}
}
Authentication for Inbound Calls
External systems must authenticate via OAuth 2.0 Bearer Token or Connected App. The OAuth 2.0 Client Credentials flow is cleanest for server-to-server integration.
Testing Callouts
@IsTest
private class ContactSyncServiceTest {
@IsTest
static void testSyncContact_success() {
Test.setMock(HttpCalloutMock.class, new MockHttpCallout(201, '{"id": "EXT-12345"}'));
Contact c = new Contact(FirstName = 'Test', LastName = 'Contact', Email = '[email protected]');
insert c;
Test.startTest();
ContactSyncAsync.syncContact(c.Id);
Test.stopTest();
}
public class MockHttpCallout implements HttpCalloutMock {
private Integer statusCode;
private String body;
public MockHttpCallout(Integer statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
public HTTPResponse respond(HTTPRequest req) {
HttpResponse response = new HttpResponse();
response.setStatusCode(this.statusCode);
response.setBody(this.body);
return response;
}
}
}
Production-Ready Checklist
Outbound:
- โ Named Credentials for endpoint/auth
- โ
@Future(callout=true)orQueueable - โ Timeout set (max 120s)
- โ Error handling with retry logic
- โ
HttpCalloutMockin tests
Inbound:
- โ
@RestResourcewith clear URL mapping - โ
with sharingandWITH SECURITY_ENFORCED - โ Input validation with meaningful errors
- โ Proper HTTP status codes
- โ Connected App for authentication
Need help building an integration? Start practicing on ApexSensei โ
What system are you integrating with Salesforce? Tell me in the comments. ๐ฌ