Integrating Salesforce with external systems involves more than making an HTTP call. Governor limits, transaction boundaries, retries, and secret management all need attention. Here's how to build integrations that hold up in production.
Prerequisites
- Apex fundamentals (classes, governor limits)
- Understanding of HTTP/REST basics
- A sandbox with an external API to test against
Named Credentials — Never Hardcode Endpoints
Named Credentials store endpoint URLs and authentication details securely, outside your code. They survive sandbox refreshes and org migrations without code changes.
Setup → Security → Named Credentials → External Credentials tab → New
Configuration uses two objects — the model Salesforce has recommended since Winter '23, legacy Named Credentials being deprecated:
- External Credential
Stripe- Authentication protocol: Custom
- Principal:
StripePrincipal(Named Principal), with anApiKeyauthentication parameter whose value — the Stripe secret key — is entered in Setup only, never in code or in the repository - Custom header:
Authorization={!'Bearer ' & $Credential.Stripe.ApiKey}
- Named Credential
Stripe_API- URL:
https://api.stripe.com - External Credential:
Stripe
- URL:
- Permission set: grant access to the
StripePrincipalprincipal (External Credential Principal Access) to the user or process that runs the callout
// Using Named Credentials in callouts — no URL or auth in code
public class StripeService {
public static Map<String, Object> createCustomer(String email, String name) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Stripe_API/v1/customers');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/x-www-form-urlencoded');
req.setBody('email=' + EncodingUtil.urlEncode(email, 'UTF-8') +
'&name=' + EncodingUtil.urlEncode(name, 'UTF-8'));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
return (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
}
throw new CalloutException('Stripe API error: ' + res.getStatusCode() + ' ' + res.getBody());
}
}Handling Callout Governor Limits
An Apex callout waits 10 seconds by default (120 seconds at most, configurable with setTimeout), and the cumulative callout time in a transaction is capped at 120 seconds. Above all, a trigger can't make a synchronous callout: you have to go asynchronous.
Pattern: Queue callouts via Platform Events
// Instead of calling the external API in a trigger (synchronous callouts aren't allowed),
// publish a Platform Event and process asynchronously
trigger OpportunityTrigger on Opportunity (after update) {
List<Integration_Request__e> events = new List<Integration_Request__e>();
for (Opportunity opp : Trigger.New) {
Opportunity old = Trigger.OldMap.get(opp.Id);
if (opp.StageName == 'Closed Won' && old.StageName != 'Closed Won') {
events.add(new Integration_Request__e(
Record_Id__c = opp.Id,
Action__c = 'CREATE_DEAL'
));
}
}
if (!events.isEmpty()) EventBus.publish(events);
}
// A separate trigger on the Platform Event fires in its own transaction.
// Events arrive in batches (up to 2,000 per execution) that share the same
// governor limits — and this trigger can't make callouts either:
// it hands off to a Queueable that allows them.
trigger IntegrationRequestTrigger on Integration_Request__e (after insert) {
List<String> recordIds = new List<String>();
for (Integration_Request__e event : Trigger.New) {
recordIds.add(event.Record_Id__c);
}
System.enqueueJob(new CreateDealQueueable(recordIds));
}
public class CreateDealQueueable implements Queueable, Database.AllowsCallouts {
private List<String> recordIds;
public CreateDealQueueable(List<String> recordIds) {
this.recordIds = recordIds;
}
public void execute(QueueableContext ctx) {
// At most 100 callouts per transaction: beyond that, split into several jobs
for (String recordId : recordIds) {
ExternalCRMService.createDeal(recordId);
}
}
}Retry Logic with Exponential Backoff
External APIs fail. Always build retry logic for transient errors:
// Apex can't wait inside a transaction: the delay between attempts is handed
// to the async queue, using the delay parameter of System.enqueueJob
// (0 to 10 minutes, since Spring '23). First call: new RetryQueueable(id, 1).
public class RetryQueueable implements Queueable, Database.AllowsCallouts {
private static final Integer MAX_ATTEMPTS = 4;
// Exponential backoff: 1, 2, then 4 minutes before attempts 2, 3 and 4
private static final List<Integer> BACKOFF_MINUTES = new List<Integer>{1, 2, 4};
private String recordId;
private Integer attempt;
public RetryQueueable(String recordId, Integer attempt) {
this.recordId = recordId;
this.attempt = attempt;
}
public void execute(QueueableContext ctx) {
try {
ExternalCRMService.createDeal(recordId);
} catch (Exception e) {
if (attempt < MAX_ATTEMPTS) {
Integer delayMinutes = BACKOFF_MINUTES[attempt - 1];
System.enqueueJob(new RetryQueueable(recordId, attempt + 1), delayMinutes);
} else {
// Log to custom object or send alert
ErrorLog__c log = new ErrorLog__c(
Record_Id__c = recordId,
Error__c = e.getMessage()
);
insert log;
}
}
}
}Parsing JSON Responses
// Option 1: deserializeUntyped — flexible but untyped
Map<String, Object> response = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String customerId = (String) response.get('id');
List<Object> items = (List<Object>) response.get('items');
// Option 2: Typed class — type-safe, recommended for known schemas
public class StripeCustomer {
public String id;
public String email;
public Long created;
public StripeAddress address;
public class StripeAddress {
public String city;
public String country;
}
}
StripeCustomer customer = (StripeCustomer) JSON.deserialize(res.getBody(), StripeCustomer.class);
String customerId = customer.id;
// Option 3: JSONParser for very large or complex responses
JSONParser parser = JSON.createParser(res.getBody());
while (parser.nextToken() != null) {
if (parser.getCurrentToken() == JSONToken.FIELD_NAME && parser.getText() == 'id') {
parser.nextToken();
String id = parser.getText();
}
}Testing Callouts
@isTest
global class StripeApiMock implements HttpCalloutMock {
private Integer statusCode;
private String body;
global StripeApiMock(Integer statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
global HTTPResponse respond(HTTPRequest req) {
HTTPResponse res = new HTTPResponse();
res.setStatusCode(statusCode);
res.setBody(body);
res.setHeader('Content-Type', 'application/json');
return res;
}
}
@isTest
static void testCreateCustomerSuccess() {
String mockBody = '{"id": "cus_123", "email": "test@example.com"}';
Test.setMock(HttpCalloutMock.class, new StripeApiMock(200, mockBody));
Test.startTest();
Map<String, Object> result = StripeService.createCustomer('test@example.com', 'Test User');
Test.stopTest();
System.assertEquals('cus_123', result.get('id'));
}
@isTest
static void testCreateCustomerError() {
Test.setMock(HttpCalloutMock.class, new StripeApiMock(400, '{"error": {"message": "Invalid email"}}'));
Test.startTest();
try {
StripeService.createCustomer('invalid', 'Test');
System.assert(false, 'Expected exception');
} catch (CalloutException e) {
System.assert(e.getMessage().contains('400'));
}
Test.stopTest();
}Common Pitfalls
- Callouts in triggers: synchronous triggers can't make callouts — use
@future(callout=true)or a Queueable withDatabase.AllowsCallouts(a Platform Event trigger can't make callouts either) - Secrets in code: never store API keys in Apex classes or Custom Metadata — use Named Credentials
- No timeout set: default callout timeout is 10s — always set explicitly with
req.setTimeout(5000)for critical paths - DML before callout: Apex blocks callouts after DML in the same transaction — restructure or use async