Integrar Salesforce con sistemas externos implica mucho más que hacer una llamada HTTP. Los governor limits, los límites transaccionales, los reintentos y la gestión de secretos requieren atención. Aquí tienes cómo construir integraciones que aguanten en producción.
Requisitos previos
- Fundamentos de Apex (clases, governor limits)
- Comprensión de los conceptos básicos de HTTP/REST
- Un sandbox con una API externa contra la que probar
Named Credentials — nunca hardcodees los endpoints
Las Named Credentials almacenan las URLs de los endpoints y los detalles de autenticación de forma segura, fuera de tu código. Sobreviven a los refresh de sandbox y a las migraciones de org sin necesidad de cambiar código.
Setup → Security → Named Credentials → External Credentials → New
La configuración usa dos objetos — el modelo que Salesforce recomienda desde Winter '23, ya que las Named Credentials «heredadas» están obsoletas:
- External Credential
Stripe- Protocolo de autenticación: Custom
- Principal:
StripePrincipal(Named Principal), con un parámetro de autenticaciónApiKeycuyo valor — la clave secreta de Stripe — se introduce únicamente en Setup, nunca en el código ni en el repositorio - Cabecera personalizada:
Authorization={!'Bearer ' & $Credential.Stripe.ApiKey}
- Named Credential
Stripe_API- URL:
https://api.stripe.com - External Credential:
Stripe
- URL:
- Permission set: conceder acceso al principal
StripePrincipal(External Credential Principal Access) al usuario o proceso que ejecuta el callout
// Usando Named Credentials en los callouts — sin URL ni auth en el código
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());
}
}Gestionar los governor limits de los callouts
Un callout Apex espera 10 segundos por defecto (120 segundos como máximo, configurable con setTimeout), y el tiempo acumulado de callouts de una transacción está limitado a 120 segundos. Sobre todo, un trigger no puede hacer un callout síncrono: hay que pasar a lo asíncrono.
Patrón: encolar callouts vía Platform Events
// En lugar de llamar a la API externa en un trigger (callout síncrono no permitido),
// publica un Platform Event y procesa de forma asíncrona
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 separado en el Platform Event se dispara en su propia transacción.
// Los eventos llegan por lotes (hasta 2.000 por ejecución), que comparten los
// mismos governor limits — y este trigger tampoco puede hacer callouts:
// delega en un Queueable que sí los permite.
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) {
// Como máximo 100 callouts por transacción: más allá, dividir en varios jobs
for (String recordId : recordIds) {
ExternalCRMService.createDeal(recordId);
}
}
}Lógica de reintento con backoff exponencial
Las API externas fallan. Construye siempre lógica de reintento para errores transitorios:
// Apex no puede esperar dentro de una transacción: el retraso entre intentos se
// delega en la cola asíncrona, con el parámetro de retraso de System.enqueueJob
// (0 a 10 minutos, desde Spring '23). Primera llamada: new RetryQueueable(id, 1).
public class RetryQueueable implements Queueable, Database.AllowsCallouts {
private static final Integer MAX_ATTEMPTS = 4;
// Backoff exponencial: 1, 2 y luego 4 minutos antes de los intentos 2, 3 y 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 {
// Registrar en un objeto personalizado o enviar una alerta
ErrorLog__c log = new ErrorLog__c(
Record_Id__c = recordId,
Error__c = e.getMessage()
);
insert log;
}
}
}
}Parsear respuestas JSON
// Opción 1: deserializeUntyped — flexible pero sin tipado
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');
// Opción 2: clase tipada — segura en cuanto a tipos, recomendada para esquemas conocidos
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;
// Opción 3: JSONParser para respuestas muy grandes o complejas
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();
}
}Probar los 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();
}Errores comunes a evitar
- Callouts en triggers: los triggers síncronos no pueden hacer callouts — usa
@future(callout=true)o un Queueable conDatabase.AllowsCallouts(un trigger de Platform Event tampoco puede hacer callouts) - Secretos en el código: nunca almacenes API keys en clases Apex ni en Custom Metadata — usa Named Credentials
- Timeout no configurado: el timeout de callout por defecto es de 10s — configúralo siempre explícitamente con
req.setTimeout(5000)para las rutas críticas - DML antes del callout: Apex bloquea los callouts después de un DML en la misma transacción — reestructura o usa asíncrono