Before the first callout: an Apex integration checklist
Credentials, limits, transaction order and failure handling — the decisions that are cheap to make before an integration is written and expensive after.
Sending an HTTP request from Apex takes five lines. Making that request safe to run in production takes a few decisions that those five lines do not force you to make. This is the list to go through before writing the first one.
1. Keep the endpoint and the secret out of the code
A URL in a class and a token in a custom setting work until the first sandbox refresh or credential rotation. Named Credentials exist for this. The Named Credential holds the base URL, an External Credential holds the authentication protocol and the secret, and a permission set decides who may use it. The code only refers to it by name:
request.setEndpoint('callout:ERP/customers');
Salesforce adds the authentication header at send time, so the secret never reaches Apex, a debug log or the repository. Each environment gets its own value for the same name, and deployments stop carrying endpoints with them.
2. Know the limits you are designing inside
- 100 callouts per transaction.
- A default timeout of 10 seconds per callout, adjustable up to 120 with
setTimeout. - 120 seconds in total for all callouts in one transaction.
- A request or response body of at most 6 MB in synchronous Apex, 12 MB in asynchronous.
If the design is “one callout per record”, the 101st record ends the transaction. Ask whether the other system has a bulk endpoint before anything else; if it does not, the work has to be split across transactions.
3. Callout first, DML second
Once a transaction has written to the database, it cannot call out. Apex throws
You have uncommitted work pending, because the platform will not hold a
database transaction open while it waits on someone else's server. So inside one
transaction the order is fixed: do the callouts, then save the results.
When the natural order is the other way round — save the record, then tell the other system — the callout belongs in a separate, asynchronous transaction.
4. From a trigger, always go asynchronous
A trigger runs in the middle of a save, so it cannot call out directly. Hand the work to
a Queueable that implements Database.AllowsCallouts:
public with sharing class AccountSyncJob implements Queueable, Database.AllowsCallouts {
private final Set<Id> accountIds;
public AccountSyncJob(Set<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext context) {
List<Account> accounts = [
SELECT Id, Name, BillingCountry
FROM Account
WHERE Id IN :accountIds
];
HttpRequest request = new HttpRequest();
request.setEndpoint('callout:ERP/customers');
request.setMethod('POST');
request.setHeader('Content-Type', 'application/json');
request.setTimeout(20000);
request.setBody(JSON.serialize(accounts));
try {
HttpResponse response = new Http().send(request);
if (response.getStatusCode() >= 300) {
logFailure('HTTP ' + response.getStatusCode());
}
} catch (CalloutException e) {
logFailure(e.getMessage());
}
}
private void logFailure(String reason) {
// Write the account ids and the reason to a log object that a person
// or a retry job will read. DML is fine here: the callout is behind us.
}
}
The trigger enqueues one job for the whole batch it received, not one per record:
System.enqueueJob(new AccountSyncJob(Trigger.newMap.keySet()));
The job sends all the records in one request. That keeps a 200-record data load at one callout instead of two hundred.
5. Decide what happens when it fails
It will fail: the other system is deploying, a certificate expired, the network dropped the response. Three questions need an answer before go-live, not after the first incident.
- Where is the failure recorded? A debug log is not an answer; it is short-lived and nobody is watching it. A log record, a status field on the record or a platform event that someone is subscribed to is.
- Who or what retries? A scheduled job that picks up failed rows is usually enough. Decide how many attempts, and what happens after the last one.
- Is a retry safe? A timeout means the response did not arrive, not that the request was not processed. Send a stable key with every request — the Salesforce record Id works — so the receiver can recognise a repeat and ignore it instead of creating a duplicate.
6. Ask whether it should be a callout at all
A callout couples Salesforce to the availability of the other system. If Salesforce does not need the answer straight away, publishing a Platform Event or enabling Change Data Capture turns the dependency around: Salesforce announces what changed, and middleware or the other system subscribes and catches up at its own pace, including after downtime within the event retention window.
7. Test without the network
Apex tests cannot make real callouts, which is a feature: the test controls the response.
Implement HttpCalloutMock and make the status code a parameter, so the
failure path gets a test as easily as the success path.
private class ErpMock implements HttpCalloutMock {
private final Integer statusCode;
public ErpMock(Integer statusCode) {
this.statusCode = statusCode;
}
public HttpResponse respond(HttpRequest request) {
Assert.areEqual('POST', request.getMethod());
HttpResponse response = new HttpResponse();
response.setStatusCode(statusCode);
return response;
}
}
Test.startTest();
Test.setMock(HttpCalloutMock.class, new ErpMock(503));
System.enqueueJob(new AccountSyncJob(accountIds));
Test.stopTest();
// The job has run by now. Assert that the failure was recorded.
Test at least a success, a server error and a timeout (have the mock throw a
CalloutException). The unhappy paths are the ones production will exercise
when nobody is looking.
In short
- Named Credentials for the endpoint and the secret.
- One bulk request, not one per record.
- Callouts before DML, or in their own asynchronous transaction.
- Failures recorded somewhere visible, with a retry that is safe to repeat.
- Events instead of callouts when the answer is not needed immediately.
- Mocked tests for the failure paths, not just the happy one.