top of page

Salesforce REST API Integration: Complete Guide to Http Classes, @RestResource, Test Classes, and Exception Handling

  • Apr 11
  • 10 min read

REST API integration is at the heart of most modern Salesforce implementations. Whether your org needs to consume data from an external service or expose Salesforce data to the outside world, Apex provides a robust set of classes and annotations to make this seamless. This comprehensive guide covers everything — outbound HTTP callouts, inbound REST endpoints, writing complete test classes with mocks, and resolving the most common exceptions you will encounter in real projects.


1. Introduction: Two Directions of REST Integration

REST API integration in Salesforce works in two directions:

  • Outbound Integration: Salesforce calls an external REST API using the Http, HttpRequest, and HttpResponse classes.

  • Inbound Integration: An external system calls into Salesforce via a custom REST endpoint you expose using the @RestResource annotation.

Understanding both patterns — along with how to test them and handle errors — is essential for any mid-to-senior level Salesforce developer.


2. Outbound REST Integration — The Core HTTP Classes

Salesforce provides three classes for making outbound HTTP callouts:

  • Http — the executor that sends the request

  • HttpRequest — builds the request object (endpoint, method, headers, body)

  • HttpResponse — holds the response returned by the external server


2.1 HttpRequest — All Methods

HttpRequest is used to construct the outgoing request. Here is a reference of every available method:

  • setEndpoint(String endpoint) — Sets the URL of the external service. Must match an approved Remote Site Setting or Named Credential.

  • setMethod(String method) — Sets the HTTP verb: GET, POST, PUT, PATCH, DELETE, HEAD.

  • setHeader(String key, String value) — Sets a single request header. Call multiple times for multiple headers.

  • setBody(String body) — Sets the request body as a String. Used for POST and PUT requests.

  • setBodyAsBlob(Blob body) — Sets the request body as a Blob. Useful for binary data or file uploads.

  • setTimeout(Integer milliseconds) — Sets the request timeout in milliseconds. Maximum is 120,000 ms (120 seconds). Default is 10,000 ms.

  • setCompressed(Boolean flag) — If true, compresses the request body using gzip. Set the Content-Encoding header to gzip when using this.

  • getEndpoint() / getMethod() / getHeader(key) / getBody() — Getter equivalents for each setter above.


2.2 HttpResponse — All Methods

HttpResponse holds the data returned from the external service after Http.send() executes:

  • getStatusCode() — Returns the HTTP status code as an Integer (e.g., 200, 201, 400, 401, 404, 500).

  • getStatus() — Returns the status message as a String (e.g., 'OK', 'Not Found', 'Unauthorized').

  • getBody() — Returns the response body as a String. Most commonly used to parse JSON responses.

  • getBodyAsBlob() — Returns the response body as a Blob. Used when the response is binary (e.g., a PDF or image).

  • getHeader(String key) — Returns the value of a specific response header.

  • getHeaderKeys() — Returns a List of all header keys in the response.


2.3 Full Working Example — GET and POST Callouts

Below is a complete, copy-paste-ready Apex class demonstrating both a GET and a POST callout:

GET Callout Example

public class ExternalApiService {

    public static String getUserById(String userId) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('https://api.example.com/users/' + userId);
        req.setMethod('GET');
        req.setHeader('Content-Type', 'application/json');
        req.setHeader('Authorization', 'Bearer YOUR_TOKEN');
        req.setTimeout(10000);

        Http http = new Http();
        HttpResponse res = http.send(req);

        if (res.getStatusCode() == 200) {
            return res.getBody();
        } else {
            throw new CalloutException(
                'GET failed. Status: ' + res.getStatusCode() + ' - ' + res.getStatus()
            );
        }
    }
}

POST Callout Example

public static String createUser(String name, String email) {
    Map<String, String> payload = new Map<String, String>{
        'name'  => name,
        'email' => email
    };

    HttpRequest req = new HttpRequest();
    req.setEndpoint('https://api.example.com/users');
    req.setMethod('POST');
    req.setHeader('Content-Type', 'application/json');
    req.setHeader('Authorization', 'Bearer YOUR_TOKEN');
    req.setBody(JSON.serialize(payload));
    req.setTimeout(15000);

    Http http = new Http();
    HttpResponse res = http.send(req);

    if (res.getStatusCode() == 201) {
        return res.getBody();
    } else {
        throw new CalloutException(
            'POST failed. Status: ' + res.getStatusCode() + ' - ' + res.getStatus()
        );
    }
}

3. Named Credentials — The Right Way to Handle Endpoints

Hardcoding endpoint URLs and tokens in Apex is a security risk and a maintenance headache. Named Credentials solve both problems. They store the endpoint URL and authentication details securely in Salesforce Setup, and Apex references them using the callout: prefix.

To use a Named Credential, go to Setup > Named Credentials > New. Configure the URL, authentication protocol (No Auth, Password, OAuth 2.0, JWT, etc.), and save. Then reference it in your Apex like this:

req.setEndpoint('callout:My_Named_Credential/api/users/' + userId);

When you use Named Credentials, you do not need a Remote Site Setting. Salesforce automatically handles the authentication headers, token refresh, and SSL certificate validation. This is the strongly recommended approach for any production integration.


4. Inbound REST — The @RestResource Annotation

To expose Salesforce as a REST API so external systems can call in, you use the @RestResource annotation on a global Apex class. Salesforce creates a publicly accessible endpoint at:

https://yourInstance.salesforce.com/services/apexrest/your-url-mapping

Each HTTP method is handled by a separate static method annotated with @HttpGet, @HttpPost, @HttpPut, @HttpPatch, or @HttpDelete. You access the incoming request and outgoing response via RestContext.request and RestContext.response.


4.1 RestRequest and RestResponse — Key Properties

RestRequest properties:

  • requestURI — The full URI of the incoming request

  • httpMethod — The HTTP method used (GET, POST, etc.)

  • requestBody — The body of the request as a Blob

  • params — Map of URL query parameters

  • headers — Map of request headers

  • uriTemplateParameters — Map of parameters extracted from the URL template (e.g., {id} in the URL mapping)

RestResponse properties:

  • statusCode — Set this to control the HTTP response code returned to the caller (default is 200)

  • responseBody — Set this as a Blob to control the response body

  • headers — Map to set response headers (e.g., Content-Type)


4.2 Complete @RestResource Class — All 5 HTTP Methods

Below is a fully working @RestResource implementation exposing all five HTTP methods on the Account object:

@RestResource(urlMapping='/accounts/*')
global with sharing class AccountRestResource {

    // GET: Retrieve an Account by Id
    // Endpoint: GET /services/apexrest/accounts/{accountId}
    @HttpGet
    global static Account doGet() {
        RestRequest req = RestContext.request;
        String accountId = req.requestURI.substring(
            req.requestURI.lastIndexOf('/') + 1
        );
        Account acc = [
            SELECT Id, Name, Industry, Phone, BillingCity
            FROM Account
            WHERE Id = :accountId
            LIMIT 1
        ];
        return acc;
    }

    // POST: Create a new Account
    // Endpoint: POST /services/apexrest/accounts/
    // Body: { "name": "Acme Corp", "industry": "Technology" }
    @HttpPost
    global static String doPost(String name, String industry) {
        Account acc = new Account();
        acc.Name = name;
        acc.Industry = industry;
        insert acc;
        RestContext.response.statusCode = 201;
        return acc.Id;
    }

    // PUT: Replace an existing Account (full update)
    // Endpoint: PUT /services/apexrest/accounts/
    // Body: { "id": "001xx", "name": "New Name", "industry": "Finance" }
    @HttpPut
    global static String doPut(String id, String name, String industry) {
        Account acc = new Account();
        acc.Id = id;
        acc.Name = name;
        acc.Industry = industry;
        upsert acc;
        return acc.Id;
    }

    // PATCH: Partially update an existing Account
    // Endpoint: PATCH /services/apexrest/accounts/{accountId}
    @HttpPatch
    global static String doPatch() {
        RestRequest req = RestContext.request;
        String accountId = req.requestURI.substring(
            req.requestURI.lastIndexOf('/') + 1
        );
        Map<String, Object> params = (Map<String, Object>) JSON.deserializeUntyped(
            req.requestBody.toString()
        );
        Account acc = [SELECT Id FROM Account WHERE Id = :accountId LIMIT 1];
        if (params.containsKey('name')) {
            acc.Name = (String) params.get('name');
        }
        if (params.containsKey('industry')) {
            acc.Industry = (String) params.get('industry');
        }
        update acc;
        return acc.Id;
    }

    // DELETE: Delete an Account by Id
    // Endpoint: DELETE /services/apexrest/accounts/{accountId}
    @HttpDelete
    global static void doDelete() {
        RestRequest req = RestContext.request;
        String accountId = req.requestURI.substring(
            req.requestURI.lastIndexOf('/') + 1
        );
        Account acc = [SELECT Id FROM Account WHERE Id = :accountId LIMIT 1];
        delete acc;
        RestContext.response.statusCode = 204;
    }
}

5. Writing Test Classes for Salesforce REST API Integration Apex

Salesforce REST API integration Apex does not make real HTTP callouts during test execution. Any test method that triggers a callout will throw a System.CalloutException unless you register a mock. Salesforce provides the HttpCalloutMock interface for exactly this purpose.


5.1 Implementing HttpCalloutMock

Create a class that implements HttpCalloutMock and overrides the respond() method. This method receives the HttpRequest and returns a fake HttpResponse:

@isTest
global class MockHttpResponseSuccess implements HttpCalloutMock {
    global HTTPResponse respond(HTTPRequest req) {
        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', 'application/json');
        res.setStatusCode(200);
        res.setBody('{"id": "123", "name": "John Doe", "email": "john@example.com"}');
        return res;
    }
}

5.2 Full Test Class — Outbound Callouts (GET and POST)

@isTest
private class ExternalApiServiceTest {

    // Mock for successful GET (200)
    @isTest
    static void testGetUser_Success() {
        Test.setMock(HttpCalloutMock.class, new MockHttpResponseSuccess());

        Test.startTest();
        String result = ExternalApiService.getUserById('123');
        Test.stopTest();

        Map<String, Object> parsed = (Map<String, Object>) JSON.deserializeUntyped(result);
        System.assertEquals('123', parsed.get('id'), 'Id should match mock response');
        System.assertEquals('John Doe', parsed.get('name'), 'Name should match mock response');
    }

    // Mock for failed GET (404)
    @isTest
    static void testGetUser_NotFound() {
        Test.setMock(HttpCalloutMock.class, new MockHttpResponse404());

        Test.startTest();
        Boolean exceptionThrown = false;
        try {
            ExternalApiService.getUserById('999');
        } catch (CalloutException e) {
            exceptionThrown = true;
            System.assert(e.getMessage().contains('404'), 'Exception should mention 404');
        }
        Test.stopTest();

        System.assert(exceptionThrown, 'Exception should have been thrown for 404');
    }

    // Mock for successful POST (201)
    @isTest
    static void testCreateUser_Success() {
        Test.setMock(HttpCalloutMock.class, new MockHttpResponse201());

        Test.startTest();
        String result = ExternalApiService.createUser('Jane Doe', 'jane@example.com');
        Test.stopTest();

        System.assertNotEquals(null, result, 'Response body should not be null');
    }
}

// 404 Mock
@isTest
global class MockHttpResponse404 implements HttpCalloutMock {
    global HTTPResponse respond(HTTPRequest req) {
        HttpResponse res = new HttpResponse();
        res.setStatusCode(404);
        res.setStatus('Not Found');
        res.setBody('{"error": "User not found"}');
        return res;
    }
}

// 201 Mock
@isTest
global class MockHttpResponse201 implements HttpCalloutMock {
    global HTTPResponse respond(HTTPRequest req) {
        HttpResponse res = new HttpResponse();
        res.setStatusCode(201);
        res.setStatus('Created');
        res.setBody('{"id": "456", "name": "Jane Doe"}');
        return res;
    }
}

5.3 StaticResourceCalloutMock — For Large Response Bodies

When your mock response body is large or complex, store it as a Static Resource and use StaticResourceCalloutMock instead of hardcoding it:

@isTest
static void testWithStaticResource() {
    StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
    mock.setStaticResource('MockUserResponse'); // name of your Static Resource
    mock.setStatusCode(200);
    mock.setHeader('Content-Type', 'application/json');
    Test.setMock(HttpCalloutMock.class, mock);

    Test.startTest();
    String result = ExternalApiService.getUserById('123');
    Test.stopTest();

    System.assertNotEquals(null, result);
}

5.4 MultiStaticResourceCalloutMock — For Multiple Callouts in One Transaction

When your code makes more than one callout in a single transaction (to different endpoints), use MultiStaticResourceCalloutMock to map each endpoint to its own mock response:

@isTest
static void testMultipleCallouts() {
    MultiStaticResourceCalloutMock multiMock = new MultiStaticResourceCalloutMock();
    multiMock.setStaticResource(
        'https://api.example.com/users/123',
        'MockUserResponse'
    );
    multiMock.setStaticResource(
        'https://api.example.com/orders/456',
        'MockOrderResponse'
    );
    multiMock.setHeader('Content-Type', 'application/json');
    Test.setMock(HttpCalloutMock.class, multiMock);

    Test.startTest();
    // your service method that makes 2 callouts
    Test.stopTest();
}

5.5 Testing @RestResource Endpoints

Testing inbound REST endpoints is different — no mock is needed. Instead, you manually populate RestContext.request and RestContext.response before calling the method directly:

@isTest
private class AccountRestResourceTest {

    @TestSetup
    static void setup() {
        Account acc = new Account(Name = 'Test Account', Industry = 'Technology');
        insert acc;
    }

    // Test GET
    @isTest
    static void testDoGet() {
        Account acc = [SELECT Id FROM Account WHERE Name = 'Test Account' LIMIT 1];

        RestRequest req = new RestRequest();
        RestResponse res = new RestResponse();
        req.requestURI = '/services/apexrest/accounts/' + acc.Id;
        req.httpMethod = 'GET';
        RestContext.request = req;
        RestContext.response = res;

        Test.startTest();
        Account result = AccountRestResource.doGet();
        Test.stopTest();

        System.assertEquals('Test Account', result.Name, 'Account name should match');
        System.assertEquals('Technology', result.Industry, 'Industry should match');
    }

    // Test POST
    @isTest
    static void testDoPost() {
        RestRequest req = new RestRequest();
        RestResponse res = new RestResponse();
        req.requestURI = '/services/apexrest/accounts/';
        req.httpMethod = 'POST';
        RestContext.request = req;
        RestContext.response = res;

        Test.startTest();
        String newId = AccountRestResource.doPost('New Corp', 'Finance');
        Test.stopTest();

        System.assertNotEquals(null, newId, 'New account Id should not be null');
        System.assertEquals(201, res.statusCode, 'Status code should be 201 Created');
    }

    // Test PATCH
    @isTest
    static void testDoPatch() {
        Account acc = [SELECT Id FROM Account WHERE Name = 'Test Account' LIMIT 1];

        RestRequest req = new RestRequest();
        RestResponse res = new RestResponse();
        req.requestURI = '/services/apexrest/accounts/' + acc.Id;
        req.httpMethod = 'PATCH';
        req.requestBody = Blob.valueOf('{"name": "Updated Account", "industry": "Finance"}');
        RestContext.request = req;
        RestContext.response = res;

        Test.startTest();
        String updatedId = AccountRestResource.doPatch();
        Test.stopTest();

        Account updated = [SELECT Name, Industry FROM Account WHERE Id = :updatedId LIMIT 1];
        System.assertEquals('Updated Account', updated.Name, 'Name should be updated');
        System.assertEquals('Finance', updated.Industry, 'Industry should be updated');
    }

    // Test DELETE
    @isTest
    static void testDoDelete() {
        Account acc = [SELECT Id FROM Account WHERE Name = 'Test Account' LIMIT 1];

        RestRequest req = new RestRequest();
        RestResponse res = new RestResponse();
        req.requestURI = '/services/apexrest/accounts/' + acc.Id;
        req.httpMethod = 'DELETE';
        RestContext.request = req;
        RestContext.response = res;

        Test.startTest();
        AccountRestResource.doDelete();
        Test.stopTest();

        System.assertEquals(204, res.statusCode, 'Status code should be 204 No Content');
        List<Account> deleted = [SELECT Id FROM Account WHERE Id = :acc.Id];
        System.assertEquals(0, deleted.size(), 'Account should be deleted');
    }
}

6. Common Exceptions and Resolutions


Exception 1: System.CalloutException — Unauthorized Endpoint

Error message:

System.CalloutException: Unauthorized endpoint, please check Setup>Security>Remote Site Settings.

Cause:

The endpoint URL has not been added to Remote Site Settings or Named Credentials.

Resolution:

Go to Setup > Security > Remote Site Settings > New. Add the base URL of the external service (e.g., https://api.example.com). Alternatively, use a Named Credential — which automatically whitelists the endpoint and handles authentication.


Exception 2: System.CalloutException — Uncommitted Work Pending

Error message:

System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out.

Cause:

A DML operation (insert, update, delete) was performed in the same transaction before the callout. Salesforce does not allow callouts after uncommitted DML.

Resolution:

  • Move the callout before the DML in the same transaction, or

  • Move the callout to a @future(callout=true) method, or

  • Use a Queueable Apex class that implements Database.AllowsCallouts

public class MyQueueable implements Queueable, Database.AllowsCallouts {
    public void execute(QueueableContext ctx) {
        // safe to make callouts here after DML in the calling context
        ExternalApiService.getUserById('123');
    }
}

Exception 3: System.LimitException — Too Many Callouts

Error message:

System.LimitException: Too many callouts: 101

Cause:

Salesforce allows a maximum of 100 callouts per synchronous transaction and 100 per asynchronous transaction. Making callouts inside a loop that iterates more than 100 times will hit this limit.

Resolution:

  • Batch your data and use Batch Apex with Database.AllowsCallouts — each execute() call gets its own 100 callout limit

  • Redesign the integration so a single callout sends/receives bulk data (e.g., send a list of 200 records in one POST instead of 200 individual POSTs)


Exception 4: Read Timeout

Error message:

System.CalloutException: Read timed out

Cause:

The external service did not respond within the configured timeout period (default 10,000 ms).

Resolution:

  • Increase the timeout using req.setTimeout(120000) — maximum is 120,000 ms

  • If the service is consistently slow, move the callout to an async context (Queueable or @future) so it does not block the user transaction

  • Implement retry logic with exponential backoff for transient failures


Exception 5: JSONException on Parsing Response

Error message:

System.JSONException: Unexpected character (< at position 0)

Cause:

The external service returned HTML (often an error page) instead of JSON, or the response body was empty. The < character is the first character of an HTML tag.

Resolution:

Always check the status code before parsing. Never call JSON.deserialize() on an error response:

HttpResponse res = http.send(req);
if (res.getStatusCode() == 200 && String.isNotBlank(res.getBody())) {
    try {
        Map<String, Object> data = 
            (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
        // process data
    } catch (JSONException e) {
        System.debug('JSON parse error: ' + e.getMessage());
        System.debug('Raw response: ' + res.getBody());
    }
} else {
    System.debug('Unexpected response: ' + res.getStatusCode() + ' ' + res.getBody());
}

Exception 6: NullPointerException on getBody()

Cause:

Some HTTP responses (e.g., 204 No Content, 304 Not Modified) return no body. Calling .length() or parsing on a null body throws a NullPointerException.

Resolution:

Always null-check and blank-check the body before using it:

String body = res.getBody();
if (body != null && body.trim().length() > 0) {
    // safe to parse
}

Exception 7: Test.setMock() Not Called — Callout in Test Context

Error message:

System.CalloutException: You cannot make a callout from a test, please use Test.setMock().

Cause:

The test method triggered a callout but no mock was registered using Test.setMock().

Resolution:

Always call Test.setMock(HttpCalloutMock.class, new YourMockClass()) before Test.startTest() in any test that triggers a callout. Every callout path must be covered by a mock.


7. Conclusion

REST API integration in Salesforce is a broad and critical skill. To summarise the key decisions:

  • Use HttpRequest, HttpResponse, and Http for outbound callouts to external services

  • Always use Named Credentials in production instead of hardcoded endpoints

  • Use @RestResource with @HttpGet, @HttpPost, @HttpPut, @HttpPatch, @HttpDelete to expose Salesforce as an inbound REST API

  • Always mock callouts in tests using HttpCalloutMock and Test.setMock()

  • Guard against the DML-before-callout exception by using @future or Queueable with Database.AllowsCallouts

  • Always validate status code and null-check the response body before parsing JSON


Coming up next on SFDCPi: Part 2 of this series will cover OAuth 2.0 authentication flows in Salesforce integrations, including the Client Credentials Flow, JWT Bearer Flow, and how to handle token refresh. Follow the blog to stay updated.

Recent Posts

See All

Comments


Thanks for subscribing!

bottom of page