Aller au contenu
17 février 2026·Mis à jour le ·19 min de lecture

Patterns d'intégration Apex : REST, callouts et Named Credentials

Construisez des intégrations Salesforce fiables — callouts REST avec Named Credentials, gestion des limites async, logique de retry avec Queueable, Platform Events et mock des services externes.

← Retour

Intégrer Salesforce avec des systèmes externes va au-delà d'un simple appel HTTP. Les governor limits, les frontières de transactions, les retries et la gestion des secrets demandent tous de l'attention. Voici comment construire des intégrations qui tiennent en production.


Prérequis

  • Bases Apex (classes, governor limits)
  • Compréhension des bases HTTP/REST
  • Une sandbox avec une API externe pour tester

Named Credentials — Ne jamais coder les endpoints en dur

Les Named Credentials stockent les URLs d'endpoints et les détails d'authentification de manière sécurisée, en dehors de votre code. Ils survivent aux rafraîchissements de sandbox et aux migrations d'org sans modification du code.

Setup → Security → Named Credentials → External Credentials → Nouveau

Configuration en deux objets — le modèle recommandé par Salesforce depuis Winter '23, les Named Credentials « héritées » étant dépréciées :

  1. External Credential Stripe
    • Protocole d'authentification : Custom
    • Principal : StripePrincipal (Named Principal), avec un paramètre d'authentification ApiKey dont la valeur — la clé secrète Stripe — est saisie uniquement dans Setup, jamais dans le code ni dans le dépôt
    • En-tête personnalisé : Authorization = {!'Bearer ' & $Credential.Stripe.ApiKey}
  2. Named Credential Stripe_API
    • URL : https://api.stripe.com
    • External Credential : Stripe
  3. Permission set : accorder l'accès au principal StripePrincipal (External Credential Principal Access) à l'utilisateur ou au processus qui exécute le callout
// Utilisation des Named Credentials — pas d'URL ni d'auth dans le 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('Erreur API Stripe : ' + res.getStatusCode() + ' ' + res.getBody());
    }
}

Gestion des governor limits sur les callouts

Un callout Apex attend 10 secondes par défaut (120 secondes au maximum, réglable avec setTimeout), et le temps cumulé des callouts d'une transaction est plafonné à 120 secondes. Surtout, un trigger ne peut pas faire de callout synchrone : il faut passer par l'asynchrone.

Pattern : mettre les callouts en file via Platform Events

// Au lieu d'appeler l'API externe dans un trigger (callout synchrone interdit),
// publiez un Platform Event et traitez de façon asynchrone
 
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);
}
 
// Un trigger séparé sur le Platform Event s'exécute dans sa propre transaction.
// Les événements y arrivent par lots (jusqu'à 2 000 par exécution), qui partagent
// les mêmes governor limits — et ce trigger ne peut pas non plus faire de callout :
// il délègue à un Queueable qui les autorise.
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) {
        // 100 callouts au plus par transaction : au-delà, découper en plusieurs jobs
        for (String recordId : recordIds) {
            ExternalCRMService.createDeal(recordId);
        }
    }
}

Logique de retry avec backoff exponentiel

Les APIs externes échouent. Construisez toujours une logique de retry pour les erreurs transitoires :

// Apex ne sait pas attendre dans une transaction : le délai entre deux tentatives
// est confié à la file asynchrone, avec le paramètre de délai de System.enqueueJob
// (0 à 10 minutes, depuis Spring '23). Premier appel : new RetryQueueable(id, 1).
public class RetryQueueable implements Queueable, Database.AllowsCallouts {
    private static final Integer MAX_ATTEMPTS = 4;
    // Backoff exponentiel : 1, 2 puis 4 minutes avant les tentatives 2, 3 et 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 {
                // Journaliser dans un objet personnalisé ou envoyer une alerte
                ErrorLog__c log = new ErrorLog__c(
                    Record_Id__c = recordId,
                    Error__c = e.getMessage()
                );
                insert log;
            }
        }
    }
}

Parser les réponses JSON

// Option 1 : deserializeUntyped — flexible mais non typé
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 : Classe typée — type-safe, recommandée pour les schémas connus
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 pour les réponses très grandes ou complexes
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();
    }
}

Tester les 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', 'Utilisateur Test');
    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, 'Exception attendue');
    } catch (CalloutException e) {
        System.assert(e.getMessage().contains('400'));
    }
    Test.stopTest();
}

Pièges courants

  • Callouts dans les triggers : les triggers synchrones ne peuvent pas faire de callouts — utilisez @future(callout=true) ou un Queueable avec Database.AllowsCallouts (un trigger de Platform Event ne peut pas non plus faire de callout)
  • Secrets dans le code : ne stockez jamais les clés API dans les classes Apex ou Custom Metadata — utilisez les Named Credentials
  • Pas de timeout défini : le timeout par défaut est 10s — définissez-le toujours explicitement avec req.setTimeout(5000) pour les chemins critiques
  • DML avant callout : Apex bloque les callouts après un DML dans la même transaction — restructurez ou passez en async

Ressources

Aïcha Imène DAHOUMANE
À propos de l'auteure
Aïcha Imène DAHOUMANE
Administratrice & Développeuse Salesforce

Développeuse Salesforce avec un background en infrastructures IT — je construis des solutions fiables et maintenables avec Apex, Flows, LWC et CI/CD.

PartagerPartager sur LinkedIn