ApexSensei

Blog

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.

Salesforce REST API Integration: Step-by-Step Guide to Connecting External Systems with Apex

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) or Queueable
  • โœ… Timeout set (max 120s)
  • โœ… Error handling with retry logic
  • โœ… HttpCalloutMock in tests

Inbound:

  • โœ… @RestResource with clear URL mapping
  • โœ… with sharing and WITH 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. ๐Ÿ’ฌ

Practice Apex free on ApexSensei