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 → New Named Credential
Configuration:
- Label:
Stripe API - Name:
Stripe_API - URL:
https://api.stripe.com - Identity Type: Named Principal
- Authentication Protocol: Password Authentication
- Username: (empty)
- Password:
sk_live_...(stored securely in Salesforce)
// 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
Apex has a 10-second callout timeout per call and a 120-second total per transaction. Bulk operations hit these limits fast.
Pattern: Queue callouts via Platform Events
// Instead of calling external API in a trigger (blocked by limits),
// 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);
}
// Separate trigger on the Platform Event fires in its own transaction
trigger IntegrationRequestTrigger on Integration_Request__e (after insert) {
for (Integration_Request__e event : Trigger.New) {
// Each event fires in its own async context — fresh governor limits
ExternalCRMService.createDeal(event.Record_Id__c);
}
}Retry Logic with Exponential Backoff
External APIs fail. Always build retry logic for transient errors:
public class RetryableCallout {
private static final Integer MAX_RETRIES = 3;
private static final List<Integer> BACKOFF_SECONDS = new List<Integer>{1, 3, 7};
public static HttpResponse callWithRetry(HttpRequest req) {
Http http = new Http();
Integer attempt = 0;
while (attempt < MAX_RETRIES) {
HttpResponse res = http.send(req);
// Success or non-retryable error
if (res.getStatusCode() < 500) return res;
attempt++;
if (attempt < MAX_RETRIES) {
// Note: you can't actually sleep in Apex — use Queueable for true delays
// This pattern is simplified; in production, re-queue as a Queueable job
}
}
throw new CalloutException('Max retries exceeded');
}
}
// Real retry with Queueable chaining
public class RetryQueueable implements Queueable, Database.AllowsCallouts {
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 < 3) {
System.enqueueJob(new RetryQueueable(recordId, attempt + 1));
} 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, Queueable, or Platform Events - 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