Skip to content
February 17, 2026·Updated ·18 min read

Apex Integration Patterns: REST, Callouts, and Named Credentials

Build reliable Salesforce integrations — REST callouts with Named Credentials, handling async limits, retry logic with Queueable, Platform Events, and external service mocking.

← Back

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:

  1. External Credential Stripe
    • Authentication protocol: Custom
    • Principal: StripePrincipal (Named Principal), with an ApiKey authentication 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}
  2. Named Credential Stripe_API
    • URL: https://api.stripe.com
    • External Credential: Stripe
  3. Permission set: grant access to the StripePrincipal principal (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 with Database.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

Resources

Aïcha Imène DAHOUMANE
About the author
Aïcha Imène DAHOUMANE
Salesforce Administrator & Developer

Salesforce developer with a background in IT infrastructure — building reliable, maintainable solutions with Apex, Flows, LWC, and CI/CD.

ShareShare on LinkedIn