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-mappingEach 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: 101Cause:
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 outCause:
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.

Comments