Making Your First Apex REST Callout
Learn how to call an external HTTP API from Apex with a HttpRequest, mock the call in a unit test, and ship the call in a Queueable so it stays inside governor limits.
TL;DR
A REST callout is an HTTP request fired from Apex at an external system. You build
a HttpRequest, set the method, endpoint, headers, and body, then send it with
new Http().send(req). The response is a HttpResponse whose getBody() gives
you a JSON string you deserialize into typed Apex. To stay inside the
Salesforce governor limits,
run the call from a Queueable so it gets its own limits, and write the unit
test with HttpCalloutMock so it does not actually hit the network.
Why a Callout Is Different From a Regular Method Call
Apex runs on Salesforce's servers. Anything outside the org — a public weather API, your company's billing system, an LLM endpoint — is a network call across the public internet. Salesforce treats that as a special kind of work for two reasons:
- It can fail in ways your code cannot catch synchronously. A 504 from the upstream, a TLS handshake error, a DNS lookup that takes 30 seconds. You have to design for retries and partial failure.
- It counts against callout-specific limits. A synchronous Apex request gets 10 callouts per transaction. Async contexts (Queueable, Batch, Scheduled, Future) get 100. Pick the wrong one and you run out of callouts before you run out of rows.
That's why the rest of this post is about wrapping the call correctly, not about the call itself.
The Three Pieces of Any Callout
Every REST callout, no matter what the API does, has the same shape:
- A
HttpRequestyou build up with the verb, endpoint, headers, and body. - A
HttpResponsethat comes back fromnew Http().send(request). - A way to deserialize the body into Apex you can actually read.
Here is the simplest possible version — a GET against a public API:
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.github.com/repos/salesforce/apex-utils');
req.setMethod('GET');
req.setHeader('Accept', 'application/json');
req.setTimeout(10000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() == 200) {
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
System.debug('stars: ' + body.get('stargazers_count'));
}Http is a one-shot client. Constructing it does nothing on its own; send
is what actually fires the request. setTimeout is in milliseconds — leaving
it out gives you the platform default of 10 seconds for a callout, and a
hung endpoint will burn that timeout inside the same transaction.
A POST looks almost the same. The differences are the method, a Content-Type
header, and a serialized body:
Account payload = new Account(Name = 'Acme', Industry = 'Technology');
String json = JSON.serialize(payload);
HttpRequest req = new HttpRequest();
req.setEndpoint('https://example.com/api/accounts');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(json);
HttpResponse res = new Http().send(req);
System.debug('status: ' + res.getStatusCode());JSON.serialize is the right tool when you control the receiving side or
when both sides agree on a schema. For the typed side of the response, the
two helpers you actually want are JSON.deserialize (typed) and
JSON.deserializeUntyped (a Map<String, Object> you walk by hand). You'll
see both in the wrapper service below.
Wrap the Callout in a Service Class
The example above is fine for a single ad-hoc call. The moment you have more
than one endpoint — or you want the call to be testable without the network
— put the call in a class and inject the Http it uses:
public class CountryService {
private final Http http;
public CountryService() {
this.http = new Http();
}
// Test-only constructor — the @TestVisible lets a unit test inject a mock Http.
@TestVisible
private CountryService(Http http) {
this.http = http;
}
public List<Map<String, Object>> findByCurrency(String code) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://restcountries.com/v3.1/currency/' + code);
req.setMethod('GET');
req.setHeader('Accept', 'application/json');
req.setTimeout(10000);
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException('restcountries returned ' + res.getStatusCode());
}
return (List<Map<String, Object>>) JSON.deserializeUntyped(res.getBody());
}
}Two things to notice. The first constructor uses the real Http. The second
one is @TestVisible and takes an Http as a parameter, which is how a
unit test swaps in a mock without touching the network. Salesforce does
not have constructor injection in the usual Spring/Angular sense, but a
package-private or @TestVisible constructor is the convention everyone
falls back on. The other thing: this is a great place to throw a real
CalloutException on a non-200, so the caller can decide whether to
retry instead of silently logging a 500 and moving on.
The class above is close to what you'd build in the rest-api-wrapper challenge on the platform.
Mock the Call in a Unit Test
Tests that hit a real network are slow, flaky, and will break the moment
your laptop is offline. Apex gives you a built-in shim: implement
HttpCalloutMock, return canned responses, and call Test.setMock:
@isTest
static void findByCurrency_returnsParsedCountries() {
MockHttp mock = new MockHttp();
mock.responseBody = '[{"name":{"common":"Austria"},"capital":["Vienna"]}]';
mock.responseCode = 200;
Test.setMock(HttpCalloutMock.class, mock);
Test.startTest();
List<Map<String, Object>> out = new CountryService(mock.getHttp()).findByCurrency('EUR');
Test.stopTest();
System.assertEquals(1, out.size());
System.assertEquals('Austria', ((Map<String, Object>) out[0].get('name')).get('common'));
}public class MockHttp implements HttpCalloutMock {
public Integer responseCode = 200;
public String responseBody = '[]';
private final Http http = new Http();
public Http getHttp() {
return this.http;
}
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
res.setStatusCode(responseCode);
res.setBody(responseBody);
return res;
}
}Test.setMock is global to the rest of the test, so the call inside
findByCurrency hits the mock instead of the real network. Wrap the actual
call in Test.startTest() / Test.stopTest() so the mock is wired in
before the call fires. Without that pair, Apex will run the production
Http.send and the test will fail with Methods defined as TestMethod do not support Web service callouts.
Ship It From a Queueable So You Stay Under the Limits
Synchronous Apex is capped at 10 callouts per transaction. If you are
processing a list of 50 records and each one needs an API hit, you will
hit the wall at record 10. A Queueable runs in its own transaction,
gets the 100-callout cap, and can chain more queueables for fan-out:
public class EnrichAccountsQueueable implements Queueable, Database.AllowsCallouts {
private List<Id> accountIds;
public EnrichAccountsQueueable(List<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext ctx) {
CountryService service = new CountryService();
for (Id acctId : accountIds) {
try {
List<Map<String, Object>> countries = service.findByCurrency('USD');
// ... write back to the account
} catch (CalloutException e) {
System.debug('skipped ' + acctId + ': ' + e.getMessage());
}
}
}
}Two contracts to know about. Database.AllowsCallouts is the marker
interface that tells Salesforce "this queueable is allowed to make
callouts" — without it, the call fails before it even starts. And
queueables chain the same way as batch jobs:
if 50 records is too many for one transaction, fire off another
EnrichAccountsQueueable from inside execute for the remainder.
The Three Things People Get Wrong
- Forgetting
Database.AllowsCalloutson a Queueable. The class compiles fine, theenqueueJobcall succeeds, then the queueable crashes the second it tries to send the request. The error log saysCallout from scheduled or batch apex not allowed— fix is the marker interface, not a try/catch. - Reading
getBody()once and forgetting it is aString. You deserialize the same body twice, the second time it fails because you already turned the JSON into a Map. Stash the parsed result in a variable. - Testing against the real network. Even a "harmless" public API
test breaks when you work from a coffee shop. Wire
HttpCalloutMockthe first time, every time.
Where To Practice Next
You have a working callout, a mocked unit test, and a queueable that stays inside the limits. The natural next step is to put it on a typed payload — the kind of code you would actually ship to a real customer. Two free challenges that go straight there:
- REST API Wrapper Class — build a typed Apex service around a public REST endpoint, with a mocked test class.
- JSON Deserialization — turn a JSON payload into strongly-typed Apex, including the nested-object traps that bite on the first try.
- JSON Serialization — the other half:
ship a typed Apex object to an API, including the
JSON.serializePrettyformat used in logs.
If you want the full path from "first callout" to "production integration with auth, retries, and a custom metadata type for endpoints", the Integration Builder path walks the whole journey end to end.
About Warren Walters
Salesforce MVP and transformative mentor with 8+ years in the Salesforce realm. Founder of Lightning Challenge, dedicated to nurturing the next generation of Salesforce talent through hands-on practice and real-world coding challenges.
Visit Profile →