
Building Sf Integrations
- 2.4k installs
- 763 repo stars
- Updated July 24, 2026
- forcedotcom/sf-skills
building-sf-integrations designs Salesforce Named Credentials, External Services, callouts, Platform Events, and CDC integration patterns.
About
The building-sf-integrations skill covers Salesforce integration architecture and runtime plumbing including Named Credentials, External Credentials, External Services from OpenAPI specs, REST and SOAP callout patterns, Platform Events, and Change Data Capture. It owns metadata for namedCredential-meta.xml, outbound callouts, event-driven design, and sync versus async pattern selection while delegating Connected App OAuth, pure Apex logic, metadata deploy, and data import to sibling skills. The workflow chooses integration pattern by need, selects secure auth models without hardcoded secrets, generates from template assets under named-credentials, callouts, platform-events, and cdc folders, validates timeout retry and logging safety, and hands off deployment to deploying-metadata. High-signal rules forbid synchronous trigger callouts, require explicit timeouts, plan retries and dead-letter strategies, and prefer External Credentials for new development. Anti-patterns include missing request logging and mixing auth setup with runtime design.
- Owns Named Credentials, External Services, Platform Events, and CDC design.
- Never hardcode credentials; prefer Named and External Credential models.
- Forbid synchronous callouts from triggers; use async patterns instead.
- Template assets for callouts, platform events, CDC, and SOAP patterns.
- Delegates Connected App OAuth to configuring-connected-apps skill.
Building Sf Integrations by the numbers
- 2,395 all-time installs (skills.sh)
- +7 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #217 of 4,386 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
building-sf-integrations capabilities & compatibility
- Capabilities
- integration pattern selection sync vs async vs e · named and external credential metadata templates · external service openapi registration guidance · platform events and cdc architecture patterns · operational safety for timeout retry and logging
- Works with
- salesforce
- Use cases
- api development · orchestration
What building-sf-integrations says it does
never hardcode credentials
do not do synchronous callouts from triggers
prefer External Credentials architecture for new development when supported
npx skills add https://github.com/forcedotcom/sf-skills --skill building-sf-integrationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.4k |
|---|---|
| repo stars | ★ 763 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | forcedotcom/sf-skills ↗ |
How do I set up secure Salesforce outbound API callouts and event-driven integrations?
Design Salesforce integration plumbing with Named Credentials, External Services, Platform Events, and CDC patterns.
Who is it for?
Salesforce architects wiring authenticated callouts, External Services, or CDC subscribers.
Skip if: Skip for Connected App OAuth setup, pure SOQL, data import, or CDC channel membership metadata alone.
When should I use this skill?
User sets up Named Credentials, External Services, REST callouts, Platform Events, or CDC in Salesforce.
What you get
Integration pattern choice, credential metadata templates, async callout design, and operational safety checklist.
- Apex Callout retry handler class
- Retryable error classification logic
By the numbers
- Handles three failure classes: network timeouts, 5xx server errors, and 429 rate limiting
Files
building-sf-integrations: Salesforce Integration Patterns Expert
Use this skill when the user needs integration architecture and runtime plumbing: Named Credentials, External Credentials, External Services, REST/SOAP callout patterns, Platform Events, CDC, and event-driven integration design.
When This Skill Owns the Task
Use building-sf-integrations when the work involves:
.namedCredential-meta.xmlor External Credential metadata- outbound REST/SOAP callouts
- External Service registration from OpenAPI specs
- Platform Events, CDC, and event-driven architecture
- choosing sync vs async integration patterns
Delegate elsewhere when the user is:
- configuring the OAuth app itself → configuring-connected-apps
- writing Apex-only business logic → generating-apex
- deploying metadata → deploying-metadata
- importing/exporting data → handling-sf-data
---
Required Context to Gather First
Ask for or infer:
- integration style: outbound callout, inbound event, External Service, CDC, platform event
- auth method
- sync vs async requirement
- system endpoint / spec details
- rate limits, retry expectations, and failure tolerance
- whether this is net-new design or repair of an existing integration
---
Recommended Workflow
1. Choose the integration pattern
| Need | Default pattern |
|---|---|
| authenticated outbound API call | Named Credential / External Credential + Apex or Flow |
| spec-driven API client | External Service |
| trigger-originated callout | async callout pattern |
| decoupled event publishing | Platform Events |
| change-stream consumption | CDC |
2. Choose the auth model
Prefer secure runtime-managed auth:
- Named Credentials / External Credentials
- OAuth or JWT via the right credential model
- no hardcoded secrets in code
3. Generate from the right templates
Use the provided assets under:
assets/named-credentials/assets/external-credentials/assets/external-services/assets/callouts/assets/platform-events/assets/cdc/assets/soap/
4. Validate operational safety
Check:
- timeout and retry handling
- async strategy for trigger-originated work
- logging / observability
- event retention and subscriber implications
5. Hand off deployment or implementation details
Use:
- deploying-metadata for deployment
- generating-apex for deeper service / retry code
- generating-flow for declarative HTTP callout orchestration
---
High-Signal Rules
- never hardcode credentials
- do not do synchronous callouts from triggers
- define timeout behavior explicitly
- plan retries for transient failures
- use middleware / event-driven patterns when outbound volume is high
- prefer External Credentials architecture for new development when supported
Common anti-patterns:
- sync trigger callouts
- no retry or dead-letter strategy
- no request/response logging
- mixing auth setup responsibilities with runtime integration design
---
Output Format
When finishing, report in this order: 1. Integration pattern chosen 2. Auth model chosen 3. Files created or updated 4. Operational safeguards 5. Deployment / testing next step
Suggested shape:
Integration: <summary>
Pattern: <named credential / external service / event / cdc / callout>
Files: <paths>
Safety: <timeouts, retries, async, logging>
Next step: <deploy, register, test, or implement>---
Cross-Skill Integration
| Need | Delegate to | Reason |
|---|---|---|
| OAuth app setup | configuring-connected-apps | consumer key / cert / app config |
| advanced callout service code | generating-apex | Apex implementation |
| declarative HTTP callout / Flow wrapper | generating-flow | Flow orchestration |
| deploy integration metadata | deploying-metadata | validation and rollout |
| use integration from Agentforce | developing-agentforce | agent action composition |
---
Reference Map
Start here
- references/named-credentials-guide.md
- references/external-services-guide.md
- references/callout-patterns.md
- references/rest-callout-patterns.md
- references/security-best-practices.md
Event-driven / platform patterns
- references/event-patterns.md
- references/platform-events-guide.md
- references/cdc-guide.md
- references/event-driven-architecture-guide.md
- references/messaging-api-v2.md
CLI / automation / scoring
- references/cli-reference.md
- references/named-credentials-automation.md
- references/scoring-rubric.md
- scripts/README.md — automation scripts overview (configure-named-credential.sh, set-api-credential.sh)
Asset templates
assets/named-credentials/— Named Credential XML templates (OAuth, JWT, Certificate, Custom auth)assets/external-credentials/— External Credential XML templates (OAuth, JWT)assets/external-services/— External Service registration template and operations guideassets/callouts/— REST sync, Queueable, retry handler, and HTTP response handler Apex templatesassets/platform-events/— Platform Event definition, publisher, and subscriber templatesassets/cdc/— CDC handler and subscriber trigger templatesassets/soap/— SOAP callout service template and wsdl2apex guideassets/endpoint-security/— Remote Site Setting and CSP Trusted Site XML templates
Automation hooks
hooks/scripts/suggest_credential_setup.py— auto-suggests credential configuration steps when integration files are detectedhooks/scripts/validate_integration.py— validates integration patterns before agent responses
---
Output Expectations
When this skill completes an integration task, it produces:
1. Credential metadata — one or more files in assets/named-credentials/ or assets/external-credentials/ filled with org-specific values 2. Callout Apex class — a .cls file using the Named Credential pattern, with async/sync pattern chosen based on context 3. Event/CDC artifacts — Platform Event .object-meta.xml, subscriber trigger, or CDC config (when event-driven pattern is chosen) 4. Endpoint security metadata — Remote Site Setting and/or CSP Trusted Site XML files 5. Scoring report — 120-point score across 6 categories (Security, Error Handling, Bulkification, Architecture, Best Practices, Documentation) 6. Next step — a deployment or testing instruction for the generated artifacts
---
Score Guide
| Score | Meaning |
|---|---|
| 108+ | strong production-ready integration design |
| 90–107 | good design with some hardening left |
| 72–89 | workable but needs architectural review |
| < 72 | unsafe / incomplete for deployment |
/**
* @description Retry Handler with Exponential Backoff for HTTP Callouts
*
* Use Case: Handle transient failures with intelligent retry
* - Network timeouts
* - 5xx server errors
* - Rate limiting (429)
*
* Features:
* - Exponential backoff between retries
* - Configurable retry count
* - Jitter to prevent thundering herd
* - Distinguishes retryable vs non-retryable errors
*
* IMPORTANT: Apex doesn't have Thread.sleep(), so retry with backoff
* is implemented via Queueable job scheduling for async contexts.
* For sync contexts, this provides immediate retry without delay.
*
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class CalloutRetryHandler {
// Configuration
private static final Integer MAX_RETRIES = 3;
private static final Integer BASE_DELAY_MS = 1000; // 1 second
private static final Integer MAX_DELAY_MS = 30000; // 30 seconds
// Retryable status codes
private static final Set<Integer> RETRYABLE_STATUS_CODES = new Set<Integer>{
408, // Request Timeout
429, // Too Many Requests (Rate Limited)
500, // Internal Server Error
502, // Bad Gateway
503, // Service Unavailable
504 // Gateway Timeout
};
/**
* @description Execute HTTP request with retry logic (immediate retries)
* @param request HttpRequest to execute
* @return HttpResponse from successful call
* @throws CalloutException if all retries exhausted
*/
public static HttpResponse executeWithRetry(HttpRequest request) {
return executeWithRetry(request, MAX_RETRIES);
}
/**
* @description Execute HTTP request with configurable retry count
* @param request HttpRequest to execute
* @param maxRetries Maximum retry attempts
* @return HttpResponse from successful call
* @throws CalloutException if all retries exhausted
*/
public static HttpResponse executeWithRetry(HttpRequest request, Integer maxRetries) {
Integer retryCount = 0;
HttpResponse response;
Exception lastException;
while (retryCount <= maxRetries) {
try {
Http http = new Http();
response = http.send(request);
Integer statusCode = response.getStatusCode();
// Success - return immediately
if (statusCode >= 200 && statusCode < 300) {
return response;
}
// Client error (4xx except 408, 429) - don't retry
if (statusCode >= 400 && statusCode < 500 &&
!RETRYABLE_STATUS_CODES.contains(statusCode)) {
throw new NonRetryableException(
'Client Error (' + statusCode + '): ' + response.getBody()
);
}
// Retryable error - check if we should retry
if (RETRYABLE_STATUS_CODES.contains(statusCode)) {
retryCount++;
if (retryCount > maxRetries) {
throw new RetryExhaustedException(
'Max retries exhausted. Last status: ' + statusCode +
', Body: ' + response.getBody()
);
}
// Log retry attempt
System.debug(LoggingLevel.WARN,
'Retryable error (' + statusCode + '). Attempt ' + retryCount +
' of ' + maxRetries);
// For 429, check Retry-After header
if (statusCode == 429) {
String retryAfter = response.getHeader('Retry-After');
if (String.isNotBlank(retryAfter)) {
System.debug(LoggingLevel.WARN, 'Rate limited. Retry-After: ' + retryAfter);
}
}
// Continue to next retry (no delay in sync Apex)
continue;
}
// Other status codes - don't retry
return response;
} catch (CalloutException e) {
// Network/connection error
lastException = e;
retryCount++;
if (retryCount > maxRetries) {
throw new RetryExhaustedException(
'Max retries exhausted after CalloutException: ' + e.getMessage()
);
}
System.debug(LoggingLevel.WARN,
'CalloutException on attempt ' + retryCount + ': ' + e.getMessage());
}
}
// Should not reach here, but handle edge case
throw new RetryExhaustedException('Unexpected retry loop exit');
}
/**
* @description Calculate delay with exponential backoff and jitter
* (Useful for scheduled retry jobs)
* @param retryAttempt Current retry attempt (1-based)
* @return Delay in milliseconds
*/
public static Integer calculateBackoffDelay(Integer retryAttempt) {
// Exponential backoff: baseDelay * 2^(attempt-1)
Integer exponentialDelay = BASE_DELAY_MS * (Integer) Math.pow(2, retryAttempt - 1);
// Add jitter (random 0-25% of delay)
Double jitter = Math.random() * 0.25 * exponentialDelay;
Integer delayWithJitter = exponentialDelay + (Integer) jitter;
// Cap at max delay
return Math.min(delayWithJitter, MAX_DELAY_MS);
}
/**
* @description Check if HTTP status code is retryable
* @param statusCode HTTP status code
* @return True if request should be retried
*/
public static Boolean isRetryable(Integer statusCode) {
return RETRYABLE_STATUS_CODES.contains(statusCode);
}
/**
* @description Exception for non-retryable errors (4xx client errors)
*/
public class NonRetryableException extends Exception {}
/**
* @description Exception when all retry attempts exhausted
*/
public class RetryExhaustedException extends Exception {}
}
/**
* @description HTTP Response Handler Utility
*
* Use Case: Standardized response parsing and error handling
* - Parse JSON responses to typed objects
* - Handle different content types
* - Extract error messages from various API formats
* - Logging for debugging
*
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class HttpResponseHandler {
/**
* @description Parse HTTP response to Map
* @param response HttpResponse to parse
* @return Parsed response as Map<String, Object>
* @throws HttpResponseException for error status codes
*/
public static Map<String, Object> parseJsonResponse(HttpResponse response) {
validateSuccessResponse(response);
String body = response.getBody();
if (String.isBlank(body) || response.getStatusCode() == 204) {
return new Map<String, Object>();
}
try {
return (Map<String, Object>) JSON.deserializeUntyped(body);
} catch (JSONException e) {
throw new HttpResponseException(
'Failed to parse JSON response: ' + e.getMessage() +
'. Body: ' + body.left(500)
);
}
}
/**
* @description Parse HTTP response to List
* @param response HttpResponse to parse
* @return Parsed response as List<Object>
* @throws HttpResponseException for error status codes
*/
public static List<Object> parseJsonArrayResponse(HttpResponse response) {
validateSuccessResponse(response);
String body = response.getBody();
if (String.isBlank(body)) {
return new List<Object>();
}
try {
return (List<Object>) JSON.deserializeUntyped(body);
} catch (JSONException e) {
throw new HttpResponseException(
'Failed to parse JSON array response: ' + e.getMessage()
);
}
}
/**
* @description Parse HTTP response to specific type
* @param response HttpResponse to parse
* @param targetType Apex type to deserialize to
* @return Deserialized object
* @throws HttpResponseException for error status codes
*/
public static Object parseTypedResponse(HttpResponse response, Type targetType) {
validateSuccessResponse(response);
String body = response.getBody();
if (String.isBlank(body)) {
return null;
}
try {
return JSON.deserialize(body, targetType);
} catch (JSONException e) {
throw new HttpResponseException(
'Failed to deserialize response to ' + targetType.getName() + ': ' + e.getMessage()
);
}
}
/**
* @description Validate response is successful (2xx)
* @param response HttpResponse to validate
* @throws HttpResponseException for non-2xx status codes
*/
public static void validateSuccessResponse(HttpResponse response) {
Integer statusCode = response.getStatusCode();
// Log response for debugging
logResponse(response);
if (statusCode >= 200 && statusCode < 300) {
return; // Success
}
String errorMessage = extractErrorMessage(response);
if (statusCode >= 400 && statusCode < 500) {
throw new ClientErrorException(
'HTTP ' + statusCode + ': ' + errorMessage
);
}
if (statusCode >= 500) {
throw new ServerErrorException(
'HTTP ' + statusCode + ': ' + errorMessage
);
}
throw new HttpResponseException(
'Unexpected HTTP status ' + statusCode + ': ' + errorMessage
);
}
/**
* @description Extract error message from response body
* @param response HttpResponse containing error
* @return Error message string
*/
public static String extractErrorMessage(HttpResponse response) {
String body = response.getBody();
if (String.isBlank(body)) {
return 'No response body';
}
// Try to parse as JSON error
try {
Map<String, Object> errorObj = (Map<String, Object>) JSON.deserializeUntyped(body);
// Common error formats
// Format 1: { "error": "message" }
if (errorObj.containsKey('error')) {
Object error = errorObj.get('error');
if (error instanceof String) {
return (String) error;
}
if (error instanceof Map<String, Object>) {
Map<String, Object> errorMap = (Map<String, Object>) error;
if (errorMap.containsKey('message')) {
return (String) errorMap.get('message');
}
}
}
// Format 2: { "message": "error message" }
if (errorObj.containsKey('message')) {
return (String) errorObj.get('message');
}
// Format 3: { "errors": [{ "message": "..." }] }
if (errorObj.containsKey('errors')) {
Object errors = errorObj.get('errors');
if (errors instanceof List<Object>) {
List<Object> errorList = (List<Object>) errors;
if (!errorList.isEmpty()) {
Object firstError = errorList[0];
if (firstError instanceof Map<String, Object>) {
Map<String, Object> errMap = (Map<String, Object>) firstError;
if (errMap.containsKey('message')) {
return (String) errMap.get('message');
}
}
if (firstError instanceof String) {
return (String) firstError;
}
}
}
}
// Format 4: { "detail": "error detail" }
if (errorObj.containsKey('detail')) {
return (String) errorObj.get('detail');
}
// Fallback: return serialized error object
return JSON.serialize(errorObj);
} catch (Exception e) {
// Not JSON - return raw body (truncated)
return body.left(1000);
}
}
/**
* @description Log HTTP response for debugging
* @param response HttpResponse to log
*/
private static void logResponse(HttpResponse response) {
System.debug(LoggingLevel.DEBUG, '=== HTTP Response ===');
System.debug(LoggingLevel.DEBUG, 'Status: ' + response.getStatusCode() + ' ' + response.getStatus());
// Log important headers
String[] headerKeys = new String[]{ 'Content-Type', 'X-Request-Id', 'Retry-After', 'X-RateLimit-Remaining' };
for (String key : headerKeys) {
String value = response.getHeader(key);
if (String.isNotBlank(value)) {
System.debug(LoggingLevel.DEBUG, key + ': ' + value);
}
}
// Log body (truncated for large responses)
String body = response.getBody();
if (String.isNotBlank(body)) {
System.debug(LoggingLevel.DEBUG, 'Body: ' + body.left(5000));
}
}
/**
* @description Get response header value
* @param response HttpResponse
* @param headerName Header name to retrieve
* @return Header value or null
*/
public static String getHeader(HttpResponse response, String headerName) {
return response.getHeader(headerName);
}
/**
* @description Check if response indicates success
* @param response HttpResponse to check
* @return True if status code is 2xx
*/
public static Boolean isSuccess(HttpResponse response) {
Integer statusCode = response.getStatusCode();
return statusCode >= 200 && statusCode < 300;
}
/**
* @description Check if response indicates rate limiting
* @param response HttpResponse to check
* @return True if status code is 429
*/
public static Boolean isRateLimited(HttpResponse response) {
return response.getStatusCode() == 429;
}
/**
* @description Base exception for HTTP response errors
*/
public virtual class HttpResponseException extends Exception {}
/**
* @description Exception for 4xx client errors
*/
public class ClientErrorException extends HttpResponseException {}
/**
* @description Exception for 5xx server errors
*/
public class ServerErrorException extends HttpResponseException {}
}
/**
* @description Asynchronous REST Callout using Queueable
*
* Use Case: Callouts triggered from DML operations
* - Trigger-based integrations
* - Process Builder / Flow callouts
* - Any callout after DML (insert, update, delete)
*
* CRITICAL: Synchronous callouts are NOT allowed after DML.
* Always use Queueable with Database.AllowsCallouts for such scenarios.
*
* Features:
* - Implements Database.AllowsCallouts for HTTP requests
* - Processes records in batches
* - Includes error handling and logging
* - Supports job chaining for pagination
*
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{ServiceName}}QueueableCallout implements Queueable, Database.AllowsCallouts {
private static final String NAMED_CREDENTIAL = 'callout:{{NamedCredentialName}}';
private static final Integer DEFAULT_TIMEOUT = 120000;
private static final Integer BATCH_SIZE = 10; // Callouts per job (max 100 per transaction)
private List<Id> recordIds;
private String operation;
private Integer startIndex;
/**
* @description Constructor for processing records
* @param recordIds List of record IDs to process
* @param operation Operation type (CREATE, UPDATE, DELETE, SYNC)
*/
public {{ServiceName}}QueueableCallout(List<Id> recordIds, String operation) {
this(recordIds, operation, 0);
}
/**
* @description Constructor with pagination support
* @param recordIds List of record IDs to process
* @param operation Operation type
* @param startIndex Starting index for batch processing
*/
public {{ServiceName}}QueueableCallout(List<Id> recordIds, String operation, Integer startIndex) {
this.recordIds = recordIds;
this.operation = operation;
this.startIndex = startIndex;
}
/**
* @description Execute the queueable job
* @param context QueueableContext
*/
public void execute(QueueableContext context) {
if (recordIds == null || recordIds.isEmpty()) {
return;
}
// Calculate batch bounds
Integer endIndex = Math.min(startIndex + BATCH_SIZE, recordIds.size());
List<Id> batchIds = new List<Id>();
for (Integer i = startIndex; i < endIndex; i++) {
batchIds.add(recordIds[i]);
}
try {
// Query records for this batch
List<{{ObjectName}}> records = [
SELECT Id, Name{{AdditionalFields}}
FROM {{ObjectName}}
WHERE Id IN :batchIds
WITH USER_MODE
];
// Process each record
List<{{ObjectName}}> toUpdate = new List<{{ObjectName}}>();
for ({{ObjectName}} record : records) {
try {
processRecord(record, operation);
// Mark success on record if needed
record.Integration_Status__c = 'Success';
record.Integration_Date__c = Datetime.now();
toUpdate.add(record);
} catch (Exception e) {
// Log individual record failure
System.debug(LoggingLevel.ERROR,
'Failed to process record ' + record.Id + ': ' + e.getMessage());
record.Integration_Status__c = 'Error';
record.Integration_Error__c = e.getMessage().left(255);
toUpdate.add(record);
}
}
// Update records with sync status
if (!toUpdate.isEmpty()) {
update toUpdate;
}
// Chain next batch if more records remain
if (endIndex < recordIds.size()) {
System.enqueueJob(
new {{ServiceName}}QueueableCallout(recordIds, operation, endIndex)
);
}
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, '{{ServiceName}}QueueableCallout Error: ' + e.getMessage());
System.debug(LoggingLevel.ERROR, 'Stack Trace: ' + e.getStackTraceString());
// Consider: Create Integration_Log__c record, send notification, etc.
}
}
/**
* @description Process a single record with external API
* @param record The record to process
* @param operation The operation type
*/
private void processRecord({{ObjectName}} record, String operation) {
switch on operation {
when 'CREATE' {
createExternalRecord(record);
}
when 'UPDATE' {
updateExternalRecord(record);
}
when 'DELETE' {
deleteExternalRecord(record.Id);
}
when 'SYNC' {
syncExternalRecord(record);
}
when else {
throw new IntegrationException('Unknown operation: ' + operation);
}
}
}
/**
* @description Create record in external system
* @param record Salesforce record
*/
private void createExternalRecord({{ObjectName}} record) {
Map<String, Object> payload = buildPayload(record);
HttpRequest req = new HttpRequest();
req.setEndpoint(NAMED_CREDENTIAL + '/{{Endpoint}}');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(DEFAULT_TIMEOUT);
req.setBody(JSON.serialize(payload));
HttpResponse res = new Http().send(req);
handleResponse(res, record.Id);
}
/**
* @description Update record in external system
* @param record Salesforce record
*/
private void updateExternalRecord({{ObjectName}} record) {
if (String.isBlank(record.External_Id__c)) {
throw new IntegrationException('No external ID for record: ' + record.Id);
}
Map<String, Object> payload = buildPayload(record);
HttpRequest req = new HttpRequest();
req.setEndpoint(NAMED_CREDENTIAL + '/{{Endpoint}}/' + record.External_Id__c);
req.setMethod('PUT');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(DEFAULT_TIMEOUT);
req.setBody(JSON.serialize(payload));
HttpResponse res = new Http().send(req);
handleResponse(res, record.Id);
}
/**
* @description Delete record from external system
* @param recordId Salesforce record ID
*/
private void deleteExternalRecord(Id recordId) {
// Query for external ID
{{ObjectName}} record = [
SELECT External_Id__c
FROM {{ObjectName}}
WHERE Id = :recordId
WITH USER_MODE
LIMIT 1
];
if (String.isBlank(record.External_Id__c)) {
return; // Nothing to delete externally
}
HttpRequest req = new HttpRequest();
req.setEndpoint(NAMED_CREDENTIAL + '/{{Endpoint}}/' + record.External_Id__c);
req.setMethod('DELETE');
req.setTimeout(DEFAULT_TIMEOUT);
HttpResponse res = new Http().send(req);
handleResponse(res, recordId);
}
/**
* @description Sync record with external system (get latest and update)
* @param record Salesforce record
*/
private void syncExternalRecord({{ObjectName}} record) {
if (String.isBlank(record.External_Id__c)) {
createExternalRecord(record);
} else {
updateExternalRecord(record);
}
}
/**
* @description Build API payload from Salesforce record
* @param record Salesforce record
* @return Payload map for API
*/
private Map<String, Object> buildPayload({{ObjectName}} record) {
return new Map<String, Object>{
'salesforceId' => record.Id,
'name' => record.Name
// Add more field mappings as needed
};
}
/**
* @description Handle HTTP response
* @param res HTTP response
* @param recordId Salesforce record ID (for logging)
*/
private void handleResponse(HttpResponse res, Id recordId) {
Integer statusCode = res.getStatusCode();
if (statusCode >= 200 && statusCode < 300) {
System.debug(LoggingLevel.DEBUG, 'Success for ' + recordId + ': ' + res.getBody());
return;
}
if (statusCode >= 400 && statusCode < 500) {
throw new IntegrationException(
'Client Error (' + statusCode + ') for ' + recordId + ': ' + res.getBody()
);
}
if (statusCode >= 500) {
throw new IntegrationException(
'Server Error (' + statusCode + ') for ' + recordId + ': ' + res.getBody()
);
}
}
/**
* @description Custom exception for integration errors
*/
public class IntegrationException extends Exception {}
}
/**
* @description Synchronous REST Callout Service for {{ServiceName}}
*
* Use Case: Real-time API calls where immediate response is needed
* - User-initiated actions requiring feedback
* - API calls NOT triggered from DML operations
*
* IMPORTANT: Do NOT use synchronous callouts from triggers or DML contexts.
* Use rest-queueable-callout.cls template instead.
*
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{ServiceName}}Callout {
// Named Credential reference (configured in Setup)
private static final String NAMED_CREDENTIAL = 'callout:{{NamedCredentialName}}';
// Default timeout (max 120000 ms = 120 seconds)
private static final Integer DEFAULT_TIMEOUT = 120000;
/**
* @description Make HTTP request to external API
* @param method HTTP method (GET, POST, PUT, PATCH, DELETE)
* @param endpoint API endpoint path (appended to Named Credential base URL)
* @param body Request body (null for GET/DELETE)
* @return HttpResponse from external API
*/
public static HttpResponse makeRequest(String method, String endpoint, String body) {
HttpRequest req = new HttpRequest();
req.setEndpoint(NAMED_CREDENTIAL + endpoint);
req.setMethod(method);
req.setHeader('Content-Type', 'application/json');
req.setHeader('Accept', 'application/json');
req.setTimeout(DEFAULT_TIMEOUT);
if (String.isNotBlank(body) && (method == 'POST' || method == 'PUT' || method == 'PATCH')) {
req.setBody(body);
}
Http http = new Http();
return http.send(req);
}
/**
* @description GET request
* @param endpoint API endpoint path
* @return Parsed response as Map
*/
public static Map<String, Object> get(String endpoint) {
HttpResponse res = makeRequest('GET', endpoint, null);
return handleResponse(res);
}
/**
* @description GET request with query parameters
* @param endpoint API endpoint path
* @param params Query parameters
* @return Parsed response as Map
*/
public static Map<String, Object> get(String endpoint, Map<String, String> params) {
String queryString = buildQueryString(params);
String fullEndpoint = String.isNotBlank(queryString)
? endpoint + '?' + queryString
: endpoint;
return get(fullEndpoint);
}
/**
* @description POST request
* @param endpoint API endpoint path
* @param payload Request body as Map
* @return Parsed response as Map
*/
public static Map<String, Object> post(String endpoint, Map<String, Object> payload) {
HttpResponse res = makeRequest('POST', endpoint, JSON.serialize(payload));
return handleResponse(res);
}
/**
* @description PUT request (full resource update)
* @param endpoint API endpoint path
* @param payload Request body as Map
* @return Parsed response as Map
*/
public static Map<String, Object> put(String endpoint, Map<String, Object> payload) {
HttpResponse res = makeRequest('PUT', endpoint, JSON.serialize(payload));
return handleResponse(res);
}
/**
* @description PATCH request (partial resource update)
* @param endpoint API endpoint path
* @param payload Request body as Map
* @return Parsed response as Map
*/
public static Map<String, Object> patch(String endpoint, Map<String, Object> payload) {
HttpResponse res = makeRequest('PATCH', endpoint, JSON.serialize(payload));
return handleResponse(res);
}
/**
* @description DELETE request
* @param endpoint API endpoint path
* @return Parsed response as Map (may be empty for 204)
*/
public static Map<String, Object> del(String endpoint) {
HttpResponse res = makeRequest('DELETE', endpoint, null);
return handleResponse(res);
}
/**
* @description Handle HTTP response and parse JSON
* @param res HttpResponse from callout
* @return Parsed response as Map
* @throws CalloutException for error status codes
*/
private static Map<String, Object> handleResponse(HttpResponse res) {
Integer statusCode = res.getStatusCode();
String responseBody = res.getBody();
// Log for debugging (consider removing in production or use custom logging)
System.debug(LoggingLevel.DEBUG, 'Response Status: ' + statusCode);
System.debug(LoggingLevel.DEBUG, 'Response Body: ' + responseBody);
// Success (2xx)
if (statusCode >= 200 && statusCode < 300) {
if (String.isBlank(responseBody) || statusCode == 204) {
return new Map<String, Object>();
}
return (Map<String, Object>) JSON.deserializeUntyped(responseBody);
}
// Client Error (4xx)
if (statusCode >= 400 && statusCode < 500) {
String errorMessage = parseErrorMessage(responseBody);
throw new ClientException('Client Error (' + statusCode + '): ' + errorMessage);
}
// Server Error (5xx)
if (statusCode >= 500) {
throw new ServerException('Server Error (' + statusCode + '): ' + responseBody);
}
// Unexpected status
throw new CalloutException('Unexpected status code: ' + statusCode);
}
/**
* @description Parse error message from response body
* @param responseBody Raw response body
* @return Error message string
*/
private static String parseErrorMessage(String responseBody) {
try {
Map<String, Object> errorResponse = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
// Common error message field names
if (errorResponse.containsKey('error')) {
Object error = errorResponse.get('error');
if (error instanceof String) {
return (String) error;
} else if (error instanceof Map<String, Object>) {
Map<String, Object> errorObj = (Map<String, Object>) error;
return (String) errorObj.get('message');
}
}
if (errorResponse.containsKey('message')) {
return (String) errorResponse.get('message');
}
if (errorResponse.containsKey('errors')) {
return JSON.serialize(errorResponse.get('errors'));
}
return responseBody;
} catch (Exception e) {
return responseBody;
}
}
/**
* @description Build URL query string from parameters
* @param params Map of query parameters
* @return Encoded query string
*/
private static String buildQueryString(Map<String, String> params) {
if (params == null || params.isEmpty()) {
return '';
}
List<String> pairs = new List<String>();
for (String key : params.keySet()) {
String value = params.get(key);
if (String.isNotBlank(value)) {
pairs.add(EncodingUtil.urlEncode(key, 'UTF-8') + '=' +
EncodingUtil.urlEncode(value, 'UTF-8'));
}
}
return String.join(pairs, '&');
}
/**
* @description Client error (4xx) exception
*/
public class ClientException extends Exception {}
/**
* @description Server error (5xx) exception
*/
public class ServerException extends Exception {}
}
/**
* @description Change Data Capture (CDC) Handler for {{ObjectName}}
*
* Use Case: Business logic for processing CDC events
* - Sync changes to external systems
* - Maintain audit logs
* - Trigger downstream processes
*
* This class is called by the {{ObjectName}}CDCSubscriber trigger
* and contains all business logic for CDC event processing.
*
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{ObjectName}}CDCHandler {
// Fields to watch for sync (ignore changes to other fields)
private static final Set<String> SYNC_FIELDS = new Set<String>{
'Name',
'Status__c',
'Amount__c'
// Add fields that should trigger sync
};
/**
* @description Handle CREATE change events
* @param event The CDC event
* @param header The change event header
*/
public static void handleCreate({{ObjectName}}ChangeEvent event, EventBus.ChangeEventHeader header) {
List<String> recordIds = header.getRecordIds();
Datetime commitTime = header.getCommitTimestamp();
System.debug(LoggingLevel.DEBUG,
'CDC CREATE - Records: ' + recordIds + ' at ' + commitTime);
// Build payload from event field values
Map<String, Object> payload = buildPayloadFromEvent(event);
payload.put('recordIds', recordIds);
payload.put('operation', 'CREATE');
payload.put('timestamp', commitTime);
// Queue sync to external system
System.enqueueJob(new ExternalSyncQueueable(payload));
}
/**
* @description Handle UPDATE change events
* @param event The CDC event
* @param header The change event header
* @param changedFields List of changed field API names
*/
public static void handleUpdate(
{{ObjectName}}ChangeEvent event,
EventBus.ChangeEventHeader header,
List<String> changedFields
) {
List<String> recordIds = header.getRecordIds();
// Check if any sync-relevant fields changed
Boolean relevantChange = false;
List<String> relevantFields = new List<String>();
for (String field : changedFields) {
if (SYNC_FIELDS.contains(field)) {
relevantChange = true;
relevantFields.add(field);
}
}
if (!relevantChange) {
System.debug(LoggingLevel.DEBUG,
'CDC UPDATE - Ignoring (no sync fields changed): ' + changedFields);
return;
}
System.debug(LoggingLevel.DEBUG,
'CDC UPDATE - Records: ' + recordIds +
', Relevant changed fields: ' + relevantFields);
// Build payload with changed values
Map<String, Object> payload = buildPayloadFromEvent(event);
payload.put('recordIds', recordIds);
payload.put('operation', 'UPDATE');
payload.put('changedFields', relevantFields);
payload.put('timestamp', header.getCommitTimestamp());
// Queue sync to external system
System.enqueueJob(new ExternalSyncQueueable(payload));
}
/**
* @description Handle DELETE change events
* @param recordIds Deleted record IDs
* @param header The change event header
*/
public static void handleDelete(List<String> recordIds, EventBus.ChangeEventHeader header) {
System.debug(LoggingLevel.DEBUG,
'CDC DELETE - Records: ' + recordIds);
Map<String, Object> payload = new Map<String, Object>{
'recordIds' => recordIds,
'operation' => 'DELETE',
'timestamp' => header.getCommitTimestamp()
};
// Queue delete sync to external system
System.enqueueJob(new ExternalSyncQueueable(payload));
}
/**
* @description Handle UNDELETE change events
* @param event The CDC event
* @param header The change event header
*/
public static void handleUndelete({{ObjectName}}ChangeEvent event, EventBus.ChangeEventHeader header) {
// Treat undelete like create - sync full record
handleCreate(event, header);
}
/**
* @description Handle GAP events (missed events)
* @param recordIds Affected record IDs
* @param gapType Type of gap event
*/
public static void handleGap(List<String> recordIds, String gapType) {
System.debug(LoggingLevel.WARN,
'CDC GAP - Type: ' + gapType + ', Records: ' + recordIds);
// Query current state of records and sync
// This ensures we have accurate data despite missed events
/*
List<{{ObjectName}}> records = [
SELECT Id, Name, Status__c, Amount__c
FROM {{ObjectName}}
WHERE Id IN :recordIds
WITH USER_MODE
];
for ({{ObjectName}} record : records) {
Map<String, Object> payload = new Map<String, Object>{
'recordIds' => new List<String>{record.Id},
'operation' => 'SYNC', // Full sync due to gap
'data' => record
};
System.enqueueJob(new ExternalSyncQueueable(payload));
}
*/
}
/**
* @description Handle GAP_OVERFLOW (too many changes)
* @param entityName The object API name
*/
public static void handleOverflow(String entityName) {
System.debug(LoggingLevel.ERROR,
'CDC OVERFLOW for ' + entityName + ' - Full sync required!');
// Create alert/task for admin attention
/*
Task alertTask = new Task(
Subject = 'CDC Overflow - Full Sync Required for ' + entityName,
Description = 'Change Data Capture experienced overflow. ' +
'Some events were missed. Full sync is required.',
Priority = 'High',
Status = 'Open',
ActivityDate = Date.today()
);
insert alertTask;
*/
// Consider triggering a batch job for full sync
// Database.executeBatch(new FullSyncBatch(entityName));
}
/**
* @description Build payload map from CDC event field values
* @param event The CDC event
* @return Map of field values
*/
private static Map<String, Object> buildPayloadFromEvent({{ObjectName}}ChangeEvent event) {
Map<String, Object> payload = new Map<String, Object>();
// Map event fields to payload
// Only non-null values are changed fields
payload.put('Name', event.Name);
// payload.put('Status__c', event.Status__c);
// payload.put('Amount__c', event.Amount__c);
// Add more fields as needed
// Remove null values (unchanged fields)
Set<String> keysToRemove = new Set<String>();
for (String key : payload.keySet()) {
if (payload.get(key) == null) {
keysToRemove.add(key);
}
}
for (String key : keysToRemove) {
payload.remove(key);
}
return payload;
}
/**
* @description Queueable job for syncing to external system
*/
public class ExternalSyncQueueable implements Queueable, Database.AllowsCallouts {
private Map<String, Object> payload;
public ExternalSyncQueueable(Map<String, Object> payload) {
this.payload = payload;
}
public void execute(QueueableContext context) {
try {
// Call external system with payload
// Example: Use Named Credential + REST callout
/*
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ExternalSystem/sync');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(payload));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) {
System.debug('Sync successful');
} else {
System.debug('Sync failed: ' + res.getStatusCode());
}
*/
System.debug(LoggingLevel.DEBUG,
'External sync payload: ' + JSON.serialize(payload));
} catch (Exception e) {
System.debug(LoggingLevel.ERROR,
'External sync failed: ' + e.getMessage());
// Consider: Create error log, retry logic, DLQ
}
}
}
}
/**
* @description Change Data Capture (CDC) Subscriber Trigger for {{ObjectName}}
*
* Use Case: React to data changes in near real-time
* - Sync data to external systems
* - Audit logging
* - Cache invalidation
* - Event-driven integrations
*
* CDC Channel Name: {{ObjectName}}ChangeEvent
* - Standard objects: AccountChangeEvent, ContactChangeEvent, etc.
* - Custom objects: MyObject__ChangeEvent (append "ChangeEvent" to API name)
*
* Key Concepts:
* - ChangeEventHeader contains metadata (changeType, changedFields, recordIds)
* - Event contains changed field values (nulls for unchanged fields)
* - Supports CREATE, UPDATE, DELETE, UNDELETE change types
* - Gap events indicate missed events (handle replay)
*
* IMPORTANT:
* - Enable CDC for object in Setup → Integrations → Change Data Capture
* - Triggers fire in separate transaction from DML
* - Events are retained for 3 days (replay window)
*
* @author {{Author}}
* @date {{Date}}
*/
trigger {{ObjectName}}CDCSubscriber on {{ObjectName}}ChangeEvent (after insert) {
// Track replay ID for checkpoint
String lastReplayId = '';
for ({{ObjectName}}ChangeEvent event : Trigger.new) {
// Store replay ID
lastReplayId = event.ReplayId;
// Get change event header (metadata about the change)
EventBus.ChangeEventHeader header = event.ChangeEventHeader;
// Extract header information
String changeType = header.getChangeType();
List<String> changedFields = header.getChangedFields();
List<String> recordIds = header.getRecordIds();
String entityName = header.getEntityName();
Long commitNumber = header.getCommitNumber();
Datetime commitTimestamp = header.getCommitTimestamp();
String transactionKey = header.getTransactionKey();
// Log event details
System.debug(LoggingLevel.DEBUG,
'CDC Event - Type: ' + changeType +
', Entity: ' + entityName +
', Records: ' + recordIds +
', Changed Fields: ' + changedFields);
try {
// Route based on change type
switch on changeType {
when 'CREATE' {
{{ObjectName}}CDCHandler.handleCreate(event, header);
}
when 'UPDATE' {
{{ObjectName}}CDCHandler.handleUpdate(event, header, changedFields);
}
when 'DELETE' {
{{ObjectName}}CDCHandler.handleDelete(recordIds, header);
}
when 'UNDELETE' {
{{ObjectName}}CDCHandler.handleUndelete(event, header);
}
when 'GAP_CREATE', 'GAP_UPDATE', 'GAP_DELETE', 'GAP_UNDELETE' {
// Gap events indicate missed events
// Should trigger full sync for affected records
System.debug(LoggingLevel.WARN,
'GAP event detected - some events may have been missed. ' +
'Type: ' + changeType + ', Records: ' + recordIds);
{{ObjectName}}CDCHandler.handleGap(recordIds, changeType);
}
when 'GAP_OVERFLOW' {
// Too many changes to track - full sync needed
System.debug(LoggingLevel.ERROR,
'GAP_OVERFLOW - full sync required for ' + entityName);
{{ObjectName}}CDCHandler.handleOverflow(entityName);
}
}
} catch (Exception e) {
// Log error but continue processing
System.debug(LoggingLevel.ERROR,
'CDC processing error for ' + recordIds + ': ' + e.getMessage());
System.debug(LoggingLevel.ERROR, 'Stack: ' + e.getStackTraceString());
}
}
// Set resume checkpoint for durability
if (String.isNotBlank(lastReplayId)) {
EventBus.TriggerContext.currentContext().setResumeCheckpoint(lastReplayId);
}
}
/*
* ============================================================================
* CDC TRIGGER BEST PRACTICES
* ============================================================================
*
* 1. ENABLE CDC FOR OBJECT
* Setup → Integrations → Change Data Capture → Select Objects
*
* 2. HANDLE ALL CHANGE TYPES
* - CREATE: New record
* - UPDATE: Record modified
* - DELETE: Record deleted
* - UNDELETE: Record restored from recycle bin
* - GAP_*: Events were missed (sync required)
* - GAP_OVERFLOW: Too many changes (full sync needed)
*
* 3. USE CHANGEDEVENTHEAD FOR METADATA
* - getChangeType(): Operation type
* - getChangedFields(): List of changed field API names
* - getRecordIds(): Affected record IDs
* - getCommitTimestamp(): When change occurred
*
* 4. FIELD VALUES IN EVENT
* - Changed fields have new values
* - Unchanged fields are NULL
* - Use changedFields list to know what changed
*
* 5. IDEMPOTENT HANDLERS
* - Same event might fire multiple times
* - Use transactionKey to detect duplicates
* - Design handlers to be safe for replay
*
* 6. BULK CONSIDERATIONS
* - Multiple records can be in single event (batch DML)
* - Use getRecordIds() to get all affected IDs
* - Process all record IDs efficiently
*
* ============================================================================
*/
<?xml version="1.0" encoding="UTF-8"?>
<!--
CSP Trusted Site Template
Use this template when creating integration skills that make HTTP callouts
to external APIs.
RECOMMENDED: This is the MODERN approach (API 48+ / Spring '20+)
that replaces Remote Site Settings.
CSP (Content Security Policy) advantages:
- More granular control over security contexts
- Better alignment with web security standards
- Per-context security (Apex, Lightning, etc.)
- Future-proof
Setup:
1. Copy this file to your skill's assets/ directory
2. Replace {{placeholders}} with your values
3. Deploy to org OR let setup script deploy automatically
File Location: force-app/main/default/cspTrustedSites/{{APIName}}.cspTrustedSite-meta.xml
-->
<CspTrustedSite xmlns="http://soap.sforce.com/2006/04/metadata">
<!-- API Name - must be unique, alphanumeric only -->
<fullName>{{APIName}}</fullName>
<!-- Description - what this endpoint is for -->
<description>{{Description}}</description>
<!-- Endpoint URL - the base domain (no trailing slash) -->
<!-- Examples:
- https://api.stripe.com
- https://api.twilio.com
- https://graph.microsoft.com
-->
<endpointUrl>{{BaseURL}}</endpointUrl>
<!-- Active - set to true to enable -->
<isActive>true</isActive>
<!--
Context: Where this trusted site applies
Available contexts:
- All: Apply to all contexts (recommended for most integrations)
- Connect: Apex HTTP callouts only
- Frame: iframe embedding
- Img: Image resources
- Script: JavaScript resources
- Style: CSS resources
For Apex HTTP callouts (most common), use either:
- "All" (simplest, covers everything)
- "Connect" (most specific, Apex callouts only)
-->
<context>{{Context}}</context>
</CspTrustedSite>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Remote Site Setting Template
Use this template when creating integration skills that make HTTP callouts
to external APIs.
IMPORTANT:
- This is the LEGACY approach (still works in all API versions)
- For API 48+ (Spring '20+), prefer CSP Trusted Sites (see example.cspTrustedSite-meta.xml)
Setup:
1. Copy this file to your skill's assets/ directory
2. Replace {{placeholders}} with your values
3. Deploy to org OR let setup script deploy automatically
File Location: force-app/main/default/remoteSiteSettings/{{APIName}}.remoteSite-meta.xml
-->
<RemoteSiteSetting xmlns="http://soap.sforce.com/2006/04/metadata">
<!-- API Name - must be unique, alphanumeric only -->
<fullName>{{APIName}}</fullName>
<!-- Description - what this endpoint is for -->
<description>{{Description}}</description>
<!-- Security - ALWAYS keep this false (don't allow HTTP, only HTTPS) -->
<disableProtocolSecurity>false</disableProtocolSecurity>
<!-- Active - set to true to enable -->
<isActive>true</isActive>
<!-- URL - the base domain (no path, no trailing slash) -->
<!-- Examples:
- https://api.stripe.com
- https://api.twilio.com
- https://graph.microsoft.com
-->
<url>{{BaseURL}}</url>
</RemoteSiteSetting>
<?xml version="1.0" encoding="UTF-8"?>
<!--
External Credential Template: JWT (API 61+)
Use Case: JWT Bearer authentication for server-to-server
- Machine-to-machine authentication
- Service account integrations
- High-security API access
API Version: 61.0+ (Spring '24+) REQUIRED
Prerequisites:
1. Create certificate in Setup → Certificate and Key Management
2. Register public key with external system
3. Configure JWT claims as required by external API
Setup Steps:
1. Replace all {{placeholder}} values
2. Upload certificate to Salesforce
3. Deploy to org
4. Create Named Credential referencing this External Credential
File Location: force-app/main/default/externalCredentials/{{CredentialName}}.externalCredential-meta.xml
-->
<ExternalCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<label>{{CredentialLabel}}</label>
<!-- JWT Authentication Protocol -->
<authenticationProtocol>Jwt</authenticationProtocol>
<!--
JWT Configuration Parameters
-->
<!-- Issuer claim (iss) - typically your client ID or service identifier -->
<externalCredentialParameters>
<parameterName>issuer</parameterName>
<parameterType>SigningCertificate</parameterType>
<parameterValue>{{CertificateName}}</parameterValue>
</externalCredentialParameters>
<!-- Subject claim (sub) - the identity being asserted -->
<externalCredentialParameters>
<parameterName>subject</parameterName>
<parameterType>Custom</parameterType>
<parameterValue>{{SubjectIdentifier}}</parameterValue>
</externalCredentialParameters>
<!-- Audience claim (aud) - intended recipient of the JWT -->
<externalCredentialParameters>
<parameterName>audience</parameterName>
<parameterType>Custom</parameterType>
<parameterValue>{{AudienceUrl}}</parameterValue>
</externalCredentialParameters>
<!-- Token endpoint for JWT exchange -->
<externalCredentialParameters>
<parameterName>tokenEndpoint</parameterName>
<parameterType>AuthProviderTokenEndpoint</parameterType>
<parameterValue>{{TokenEndpoint}}</parameterValue>
</externalCredentialParameters>
<!-- JWT Expiration (in seconds) -->
<externalCredentialParameters>
<parameterName>expirationOffset</parameterName>
<parameterType>Custom</parameterType>
<parameterValue>3600</parameterValue>
</externalCredentialParameters>
<!-- Named Principal for JWT auth -->
<principals>
<principalName>{{PrincipalName}}</principalName>
<principalType>NamedPrincipal</principalType>
<sequenceNumber>1</sequenceNumber>
</principals>
<!--
JWT Token Flow:
1. Salesforce creates JWT with claims (iss, sub, aud, exp)
2. JWT signed with certificate private key
3. JWT sent to token endpoint
4. External system validates signature with public key
5. Access token returned
6. Access token used for API calls
Custom Claims:
Add additional externalCredentialParameters with parameterType="Custom"
to include custom claims required by your external system.
-->
</ExternalCredential>
<?xml version="1.0" encoding="UTF-8"?>
<!--
External Credential Template: OAuth 2.0 (API 61+)
Use Case: Modern OAuth 2.0 with Named Principal or Per-User authentication
- New integrations (recommended over legacy Named Credentials)
- APIs requiring per-user context
- Fine-grained permission control via Permission Sets
API Version: 61.0+ (Spring '24+) REQUIRED
Key Concepts:
- External Credential: Defines auth protocol and parameters
- Named Principal: Shared service account identity
- Per-User Principal: Individual user authentication
- Permission Set: Controls access to principals
Setup Steps:
1. Replace all {{placeholder}} values
2. Deploy to org
3. Create Named Credential referencing this External Credential
4. Assign Permission Set to users
File Location: force-app/main/default/externalCredentials/{{CredentialName}}.externalCredential-meta.xml
-->
<ExternalCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<label>{{CredentialLabel}}</label>
<!-- Authentication Protocol: Oauth, Custom, NoAuth, JWT, JwtExchange -->
<authenticationProtocol>Oauth</authenticationProtocol>
<!--
OAuth Parameters
These define how Salesforce obtains and refreshes tokens
-->
<externalCredentialParameters>
<parameterName>clientId</parameterName>
<parameterType>AuthProviderClientId</parameterType>
<parameterValue>{{ClientId}}</parameterValue>
</externalCredentialParameters>
<externalCredentialParameters>
<parameterName>clientSecret</parameterName>
<parameterType>AuthProviderClientSecret</parameterType>
<!-- Value entered via UI for security -->
</externalCredentialParameters>
<externalCredentialParameters>
<parameterName>scope</parameterName>
<parameterType>AuthProviderScope</parameterType>
<parameterValue>{{Scopes}}</parameterValue>
</externalCredentialParameters>
<externalCredentialParameters>
<parameterName>tokenEndpoint</parameterName>
<parameterType>AuthProviderTokenEndpoint</parameterType>
<parameterValue>{{TokenEndpoint}}</parameterValue>
</externalCredentialParameters>
<!--
Named Principal Configuration
Shared service account for all users with permission
-->
<principals>
<principalName>{{PrincipalName}}</principalName>
<principalType>NamedPrincipal</principalType>
<sequenceNumber>1</sequenceNumber>
</principals>
<!--
Optional: Per-User Principal
Each user authenticates individually
<principals>
<principalName>PerUserPrincipal</principalName>
<principalType>PerUserPrincipal</principalType>
<sequenceNumber>2</sequenceNumber>
</principals>
-->
<!--
Permission Set Integration:
After deployment, create Permission Set with:
- External Credential Principal Access: {{PrincipalName}}
- Assign to users who need API access
-->
</ExternalCredential>
External Service Operations Guide
Overview
External Services in Salesforce auto-generate Apex classes from OpenAPI specifications, providing type-safe API integrations without manual HTTP code.
Generated Class Structure
When you register an External Service named MyAPI, Salesforce generates:
ExternalService.MyAPI - Main service class
ExternalService.MyAPI_operationName_Request - Request wrapper for each operation
ExternalService.MyAPI_operationName_Response - Response wrapper for each operation
ExternalService.MyAPI_SchemaName - DTO for each schema in OpenAPIBasic Usage
1. Instantiate the Service
ExternalService.MyAPI api = new ExternalService.MyAPI();2. Call GET Operation
// Simple GET with path parameter
ExternalService.MyAPI_getCustomer_Response response = api.getCustomer('cust_123');
// Access response data
String customerId = response.id;
String customerName = response.name;3. Call POST Operation
// Create request object
ExternalService.MyAPI_createCustomer_Request request =
new ExternalService.MyAPI_createCustomer_Request();
request.name = 'Acme Corp';
request.email = 'contact@acme.com';
// Make the call
ExternalService.MyAPI_createCustomer_Response response = api.createCustomer(request);
// Access created resource
String newCustomerId = response.id;4. Call PUT/PATCH Operations
// Update request
ExternalService.MyAPI_updateCustomer_Request request =
new ExternalService.MyAPI_updateCustomer_Request();
request.customerId = 'cust_123'; // Path parameter
request.name = 'Acme Corporation'; // Body field
ExternalService.MyAPI_updateCustomer_Response response = api.updateCustomer(request);5. Call DELETE Operation
// DELETE usually returns void or simple confirmation
api.deleteCustomer('cust_123');Error Handling
try {
ExternalService.MyAPI api = new ExternalService.MyAPI();
ExternalService.MyAPI_getCustomer_Response response = api.getCustomer('invalid_id');
} catch (ExternalService.ExternalServiceException e) {
// API returned error status code
System.debug('API Error: ' + e.getMessage());
System.debug('Status Code: ' + e.getStatusCode());
System.debug('Response Body: ' + e.getBody());
} catch (CalloutException e) {
// Network/connection error
System.debug('Callout failed: ' + e.getMessage());
}Async Usage (Queueable)
External Service calls are still callouts, so use Queueable for trigger contexts:
public class CustomerSyncQueueable implements Queueable, Database.AllowsCallouts {
private List<Account> accounts;
public CustomerSyncQueueable(List<Account> accounts) {
this.accounts = accounts;
}
public void execute(QueueableContext context) {
ExternalService.MyAPI api = new ExternalService.MyAPI();
for (Account acc : accounts) {
try {
ExternalService.MyAPI_createCustomer_Request req =
new ExternalService.MyAPI_createCustomer_Request();
req.name = acc.Name;
req.email = acc.Email__c;
ExternalService.MyAPI_createCustomer_Response resp =
api.createCustomer(req);
// Store external ID on Account
acc.External_Customer_Id__c = resp.id;
} catch (Exception e) {
System.debug('Sync failed for ' + acc.Name + ': ' + e.getMessage());
}
}
update accounts;
}
}OpenAPI Schema Tips
Required Properties
{
"components": {
"schemas": {
"Customer": {
"type": "object",
"required": ["name", "email"],
"properties": {
"name": { "type": "string" },
"email": { "type": "string", "format": "email" }
}
}
}
}
}Nested Objects
{
"Customer": {
"type": "object",
"properties": {
"address": {
"$ref": "#/components/schemas/Address"
}
}
},
"Address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
}Arrays
{
"CustomerList": {
"type": "object",
"properties": {
"customers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Customer"
}
}
}
}
}Limitations
| Limitation | Workaround |
|---|---|
| 100 callouts per transaction | Use async (Queueable) with chaining |
| 120s max timeout | Use shorter timeout, implement retry |
| 6MB response size | Paginate responses, compress data |
| Some OpenAPI features not supported | Simplify schema, avoid oneOf/anyOf |
Refreshing External Service
When the API schema changes:
1. Download updated OpenAPI spec 2. Go to Setup → External Services 3. Edit the service 4. Upload new schema 5. Review generated operations 6. Save and validate
Or via metadata deployment: 1. Update the <schema> content in the .externalServiceRegistration-meta.xml 2. Deploy with sf project deploy start
Best Practices
1. Version Your APIs: Include version in Named Credential endpoint 2. Handle All Errors: Catch both ExternalServiceException and CalloutException 3. Log Requests/Responses: For debugging production issues 4. Use Async: Always use Queueable when called from DML contexts 5. Test Thoroughly: Mock External Service calls in test classes
<?xml version="1.0" encoding="UTF-8"?>
<!--
External Service Registration Template: OpenAPI/Swagger
Use Case: Auto-generate Apex from OpenAPI specification
- REST API integrations
- Type-safe API calls
- Automatic request/response serialization
Supported Schema Types:
- OpenApi3 (OpenAPI 3.0.x) - RECOMMENDED
- OpenApi (OpenAPI 2.0 / Swagger)
Prerequisites:
1. Named Credential configured for authentication
2. Valid OpenAPI specification (JSON or YAML)
Setup Steps:
1. Replace {{placeholder}} values
2. Embed OpenAPI schema OR reference URL
3. Deploy to org
4. Use generated Apex classes: ExternalService.{{ServiceName}}
File Location: force-app/main/default/externalServiceRegistrations/{{ServiceName}}.externalServiceRegistration-meta.xml
-->
<ExternalServiceRegistration xmlns="http://soap.sforce.com/2006/04/metadata">
<!-- Display name in Setup -->
<label>{{ServiceLabel}}</label>
<!-- Description of the service -->
<description>{{ServiceDescription}}</description>
<!--
Named Credential for authentication
Must be deployed BEFORE this External Service
-->
<namedCredential>{{NamedCredentialName}}</namedCredential>
<!--
Schema Type: OpenApi3 or OpenApi (2.0)
OpenAPI 3.0 is recommended for new integrations
-->
<schemaType>OpenApi3</schemaType>
<!--
Status: Complete, Draft, or Invalid
Set to Complete for production use
-->
<status>Complete</status>
<!--
OpenAPI Schema Content
Embed the full OpenAPI JSON schema here
OR use schemaUrl to reference external URL
Example minimal OpenAPI 3.0 schema:
-->
<schema>
{
"openapi": "3.0.0",
"info": {
"title": "{{ServiceTitle}}",
"version": "1.0.0",
"description": "{{ServiceDescription}}"
},
"servers": [
{
"url": "{{BaseUrl}}"
}
],
"paths": {
"/{{resourcePath}}": {
"get": {
"operationId": "get{{ResourceName}}",
"summary": "Get {{ResourceName}}",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/{{ResourceName}}"
}
}
}
}
}
},
"post": {
"operationId": "create{{ResourceName}}",
"summary": "Create {{ResourceName}}",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/{{ResourceName}}Input"
}
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/{{ResourceName}}"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"{{ResourceName}}": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
},
"{{ResourceName}}Input": {
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string"
}
}
}
}
}
}
</schema>
<!--
Alternative: Reference schema from URL
<schemaUrl>https://api.example.com/openapi.json</schemaUrl>
-->
<!--
Operations configuration
Define which operations to include/exclude
-->
<operations>
<active>true</active>
<name>get{{ResourceName}}</name>
</operations>
<operations>
<active>true</active>
<name>create{{ResourceName}}</name>
</operations>
<!--
Generated Apex Usage:
// Get instance of the service
ExternalService.{{ServiceName}} service = new ExternalService.{{ServiceName}}();
// Call GET operation
ExternalService.{{ServiceName}}_get{{ResourceName}}_Response resp =
service.get{{ResourceName}}('record-id-123');
// Call POST operation
ExternalService.{{ServiceName}}_create{{ResourceName}}_Request req =
new ExternalService.{{ServiceName}}_create{{ResourceName}}_Request();
req.name = 'New Item';
ExternalService.{{ServiceName}}_create{{ResourceName}}_Response resp =
service.create{{ResourceName}}(req);
-->
</ExternalServiceRegistration>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Named Credential Template: Certificate-Based Authentication (Mutual TLS)
Use Case: High-security integrations requiring client certificate
- Financial services APIs
- Government integrations
- Healthcare systems (HIPAA compliance)
- Any API requiring mutual TLS (mTLS)
Prerequisites:
1. Obtain client certificate from external system or CA
2. Import certificate to Setup → Certificate and Key Management
3. Ensure external system trusts Salesforce's certificate
Setup Steps:
1. Replace all {{placeholder}} values
2. Upload certificate to Salesforce
3. Deploy to org
4. Test connection
File Location: force-app/main/default/namedCredentials/{{CredentialName}}.namedCredential-meta.xml
-->
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>{{CredentialName}}</fullName>
<label>{{CredentialLabel}}</label>
<!-- Base URL for the external service (must be HTTPS) -->
<endpoint>{{BaseEndpoint}}</endpoint>
<!-- Authentication Configuration -->
<principalType>Anonymous</principalType>
<protocol>NoAuthentication</protocol>
<!--
Client Certificate for Mutual TLS
Certificate must be imported in Setup → Certificate and Key Management
The certificate's private key is used to authenticate the client
-->
<certificate>{{CertificateName}}</certificate>
<!-- Request Options -->
<generateAuthorizationHeader>false</generateAuthorizationHeader>
<allowMergeFieldsInBody>true</allowMergeFieldsInBody>
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
<!--
Mutual TLS Flow:
1. Salesforce initiates HTTPS connection
2. External server presents its certificate
3. Salesforce validates server certificate
4. Salesforce presents client certificate
5. External server validates Salesforce's certificate
6. Encrypted channel established
7. API requests proceed
IMPORTANT:
- Certificate must have valid chain to trusted CA
- Certificate must not be expired
- External system must have Salesforce's public cert in truststore
-->
</NamedCredential>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Named Credential Template: Custom Authentication (API Key / Basic Auth)
Use Case: Simple APIs with API key or username/password
- Internal APIs
- Legacy systems
- APIs with simple authentication
- Development/testing environments
Authentication Types:
- Basic Auth: Username + Password in Authorization header
- API Key: Custom header with API key value
Setup Steps:
1. Replace all {{placeholder}} values
2. Deploy to org
3. Enter credentials via Setup → Named Credentials UI
File Location: force-app/main/default/namedCredentials/{{CredentialName}}.namedCredential-meta.xml
-->
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>{{CredentialName}}</fullName>
<label>{{CredentialLabel}}</label>
<!-- Base URL for the external service -->
<endpoint>{{BaseEndpoint}}</endpoint>
<!--
Authentication Configuration
For Basic Auth:
- principalType: NamedUser
- protocol: Password
- generateAuthorizationHeader: true
For API Key (via custom header):
- principalType: Anonymous
- protocol: NoAuthentication
- Use calloutOptions or Apex to add header
-->
<principalType>NamedUser</principalType>
<protocol>Password</protocol>
<!--
Username for Basic Auth
Password is entered via UI after deployment (NOT stored in metadata)
-->
<username>{{Username}}</username>
<!-- Request Options -->
<generateAuthorizationHeader>true</generateAuthorizationHeader>
<allowMergeFieldsInBody>true</allowMergeFieldsInBody>
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
<!--
SECURITY WARNING:
- Password is stored securely by Salesforce, NOT in this file
- After deployment, configure password via:
Setup → Named Credentials → Edit → Enter Password
- Never commit passwords to source control
For API Key pattern (in Apex):
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:{{CredentialName}}/endpoint');
req.setHeader('X-API-Key', '{!$Credential.Password}');
Or use External Credentials (API 61+) with Custom headers for cleaner API key handling.
-->
</NamedCredential>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Named Credential Template: OAuth 2.0 Client Credentials Flow
Use Case: Server-to-server integration without user context
- Machine-to-machine authentication
- Backend service integrations
- Scheduled jobs calling external APIs
Setup Steps:
1. Replace all {{placeholder}} values
2. Create corresponding Auth Provider if using OAuth
3. Deploy to org
4. Store client credentials in Setup → Named Credentials
File Location: force-app/main/default/namedCredentials/{{CredentialName}}.namedCredential-meta.xml
-->
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>{{CredentialName}}</fullName>
<label>{{CredentialLabel}}</label>
<!-- Base URL for the external service (without trailing slash) -->
<endpoint>{{BaseEndpoint}}</endpoint>
<!-- Authentication Configuration -->
<principalType>NamedUser</principalType>
<protocol>Oauth</protocol>
<!-- OAuth Settings -->
<!-- Token endpoint where Salesforce requests access tokens -->
<oauthTokenEndpoint>{{TokenEndpoint}}</oauthTokenEndpoint>
<!-- Space-separated OAuth scopes (e.g., "read write") -->
<oauthScope>{{Scopes}}</oauthScope>
<!-- Auth Provider reference (create this first in Setup → Auth Providers) -->
<!-- <authProvider>{{AuthProviderName}}</authProvider> -->
<!-- Request Options -->
<generateAuthorizationHeader>true</generateAuthorizationHeader>
<allowMergeFieldsInBody>true</allowMergeFieldsInBody>
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
<!--
Client ID and Secret are stored via UI, NOT in metadata for security.
After deployment:
1. Go to Setup → Named Credentials
2. Edit the credential
3. Enter Client ID and Client Secret
-->
</NamedCredential>
<?xml version="1.0" encoding="UTF-8"?>
<!--
Named Credential Template: OAuth 2.0 JWT Bearer Flow
Use Case: Server-to-server with certificate-based authentication
- CI/CD pipelines
- Backend service integrations
- Automated jobs requiring strong authentication
Prerequisites:
1. Create certificate in Setup → Certificate and Key Management
2. Create Connected App with JWT Bearer enabled
3. Upload public certificate to external system
4. Pre-authorize Connected App for users
Setup Steps:
1. Replace all {{placeholder}} values
2. Ensure certificate is uploaded
3. Configure Connected App for JWT
4. Deploy to org
File Location: force-app/main/default/namedCredentials/{{CredentialName}}.namedCredential-meta.xml
-->
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>{{CredentialName}}</fullName>
<label>{{CredentialLabel}}</label>
<!-- Base URL for the external service -->
<endpoint>{{BaseEndpoint}}</endpoint>
<!-- Authentication Configuration -->
<principalType>NamedUser</principalType>
<protocol>Oauth</protocol>
<!-- JWT Bearer Settings -->
<oauthTokenEndpoint>{{TokenEndpoint}}</oauthTokenEndpoint>
<oauthScope>{{Scopes}}</oauthScope>
<!--
Certificate for signing JWT assertions
Must exist in Setup → Certificate and Key Management
-->
<certificate>{{CertificateName}}</certificate>
<!-- Auth Provider (configure for JWT Bearer) -->
<!-- <authProvider>{{AuthProviderName}}</authProvider> -->
<!-- Request Options -->
<generateAuthorizationHeader>true</generateAuthorizationHeader>
<allowMergeFieldsInBody>true</allowMergeFieldsInBody>
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
<!--
JWT Bearer Flow Details:
1. Salesforce creates JWT assertion signed with certificate
2. JWT is sent to token endpoint
3. External system validates signature
4. Access token returned
5. Access token used for API calls
JWT Claims (automatically generated):
- iss: Client ID
- sub: Username (for SF-to-SF) or service account
- aud: Token endpoint
- exp: Expiration timestamp
-->
</NamedCredential>
/**
* @description Platform Event Publisher for {{EventName}}__e
*
* Use Case: Publish events from Apex
* - Trigger-based event publishing
* - Service layer event notifications
* - Batch job completion events
*
* Features:
* - Single and bulk event publishing
* - Error handling and logging
* - Partial success handling for bulk publishes
*
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{EventName}}Publisher {
/**
* @description Publish a single event
* @param recordId ID of the triggering record
* @param operation Operation type (CREATE, UPDATE, DELETE)
* @param payload JSON payload data
* @throws EventPublishException if publish fails
*/
public static void publishEvent(Id recordId, String operation, Map<String, Object> payload) {
{{EventName}}__e event = new {{EventName}}__e();
event.Record_Id__c = recordId;
event.Operation__c = operation;
event.Payload__c = JSON.serialize(payload);
event.Correlation_Id__c = generateCorrelationId();
event.Event_Timestamp__c = Datetime.now();
Database.SaveResult result = EventBus.publish(event);
if (!result.isSuccess()) {
String errorMessage = getErrorMessage(result);
System.debug(LoggingLevel.ERROR, 'Event publish failed: ' + errorMessage);
throw new EventPublishException('Failed to publish event: ' + errorMessage);
}
System.debug(LoggingLevel.DEBUG, 'Event published successfully. Correlation ID: ' + event.Correlation_Id__c);
}
/**
* @description Publish multiple events in bulk
* @param events List of events to publish
* @return List of publish results
*/
public static List<PublishResult> publishEvents(List<{{EventName}}__e> events) {
if (events == null || events.isEmpty()) {
return new List<PublishResult>();
}
// Ensure correlation IDs and timestamps
for ({{EventName}}__e event : events) {
if (String.isBlank(event.Correlation_Id__c)) {
event.Correlation_Id__c = generateCorrelationId();
}
if (event.Event_Timestamp__c == null) {
event.Event_Timestamp__c = Datetime.now();
}
}
List<Database.SaveResult> results = EventBus.publish(events);
List<PublishResult> publishResults = new List<PublishResult>();
for (Integer i = 0; i < results.size(); i++) {
Database.SaveResult sr = results[i];
{{EventName}}__e event = events[i];
PublishResult pr = new PublishResult();
pr.correlationId = event.Correlation_Id__c;
pr.success = sr.isSuccess();
if (!sr.isSuccess()) {
pr.errorMessage = getErrorMessage(sr);
System.debug(LoggingLevel.ERROR,
'Event publish failed for correlation ' + pr.correlationId + ': ' + pr.errorMessage);
}
publishResults.add(pr);
}
return publishResults;
}
/**
* @description Publish events for record changes (use in triggers)
* @param records Changed records
* @param operation Operation type
*/
public static void publishForRecords(List<SObject> records, String operation) {
if (records == null || records.isEmpty()) {
return;
}
List<{{EventName}}__e> events = new List<{{EventName}}__e>();
for (SObject record : records) {
{{EventName}}__e event = new {{EventName}}__e();
event.Record_Id__c = record.Id;
event.Operation__c = operation;
event.Payload__c = JSON.serialize(record);
event.Correlation_Id__c = generateCorrelationId();
event.Event_Timestamp__c = Datetime.now();
events.add(event);
}
List<PublishResult> results = publishEvents(events);
// Log any failures
for (PublishResult pr : results) {
if (!pr.success) {
System.debug(LoggingLevel.ERROR, 'Publish failed: ' + pr.errorMessage);
}
}
}
/**
* @description Create event from Map (flexible payload)
* @param eventData Map containing event fields
* @return Constructed event
*/
public static {{EventName}}__e createEvent(Map<String, Object> eventData) {
{{EventName}}__e event = new {{EventName}}__e();
if (eventData.containsKey('recordId')) {
event.Record_Id__c = (String) eventData.get('recordId');
}
if (eventData.containsKey('operation')) {
event.Operation__c = (String) eventData.get('operation');
}
if (eventData.containsKey('payload')) {
Object payload = eventData.get('payload');
event.Payload__c = payload instanceof String
? (String) payload
: JSON.serialize(payload);
}
if (eventData.containsKey('correlationId')) {
event.Correlation_Id__c = (String) eventData.get('correlationId');
} else {
event.Correlation_Id__c = generateCorrelationId();
}
event.Event_Timestamp__c = Datetime.now();
return event;
}
/**
* @description Generate unique correlation ID
* @return UUID-like correlation ID
*/
private static String generateCorrelationId() {
Blob b = Crypto.generateAesKey(128);
String h = EncodingUtil.convertToHex(b);
return h.substring(0, 8) + '-' +
h.substring(8, 12) + '-' +
h.substring(12, 16) + '-' +
h.substring(16, 20) + '-' +
h.substring(20);
}
/**
* @description Extract error message from SaveResult
* @param result Database.SaveResult
* @return Error message string
*/
private static String getErrorMessage(Database.SaveResult result) {
List<String> messages = new List<String>();
for (Database.Error err : result.getErrors()) {
messages.add(err.getStatusCode() + ': ' + err.getMessage());
}
return String.join(messages, '; ');
}
/**
* @description Result wrapper for bulk publish operations
*/
public class PublishResult {
public String correlationId;
public Boolean success;
public String errorMessage;
}
/**
* @description Exception for event publish failures
*/
public class EventPublishException extends Exception {}
}
/**
* @description Platform Event Handler/Action Class for {{EventName}}__e
*
* Use Case: Business logic for processing Platform Events
* - Separated from trigger for testability
* - TAF-compatible action pattern
* - Bulkified processing
*
* This class is called by the {{EventName}}Subscriber trigger
* and contains all business logic for event processing.
*
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{EventName}}Handler {
/**
* @description Handle CREATE operation events
* @param event The platform event
*/
public static void handleCreate({{EventName}}__e event) {
Map<String, Object> payload = parsePayload(event.Payload__c);
// Example: Create a record based on event data
/*
{{TargetObject}}__c newRecord = new {{TargetObject}}__c();
newRecord.Name = (String) payload.get('name');
newRecord.External_Id__c = event.Record_Id__c;
newRecord.Source_System__c = 'Platform Event';
insert newRecord;
*/
System.debug(LoggingLevel.DEBUG,
'Processed CREATE event: ' + event.Correlation_Id__c);
}
/**
* @description Handle UPDATE operation events
* @param event The platform event
*/
public static void handleUpdate({{EventName}}__e event) {
Map<String, Object> payload = parsePayload(event.Payload__c);
String recordId = event.Record_Id__c;
// Example: Update existing record
/*
List<{{TargetObject}}__c> records = [
SELECT Id, Name
FROM {{TargetObject}}__c
WHERE External_Id__c = :recordId
WITH USER_MODE
LIMIT 1
];
if (!records.isEmpty()) {
{{TargetObject}}__c record = records[0];
record.Name = (String) payload.get('name');
record.Last_Sync__c = Datetime.now();
update record;
}
*/
System.debug(LoggingLevel.DEBUG,
'Processed UPDATE event: ' + event.Correlation_Id__c);
}
/**
* @description Handle DELETE operation events
* @param event The platform event
*/
public static void handleDelete({{EventName}}__e event) {
String recordId = event.Record_Id__c;
// Example: Delete or archive record
/*
List<{{TargetObject}}__c> records = [
SELECT Id
FROM {{TargetObject}}__c
WHERE External_Id__c = :recordId
WITH USER_MODE
LIMIT 1
];
if (!records.isEmpty()) {
delete records;
}
*/
System.debug(LoggingLevel.DEBUG,
'Processed DELETE event: ' + event.Correlation_Id__c);
}
/**
* @description Handle bulk events (for optimized processing)
* Call this from trigger instead of individual handlers
* when processing high volumes
* @param events List of events to process
*/
public static void handleBulk(List<{{EventName}}__e> events) {
// Separate events by operation
List<{{EventName}}__e> createEvents = new List<{{EventName}}__e>();
List<{{EventName}}__e> updateEvents = new List<{{EventName}}__e>();
List<{{EventName}}__e> deleteEvents = new List<{{EventName}}__e>();
for ({{EventName}}__e event : events) {
switch on event.Operation__c {
when 'CREATE' {
createEvents.add(event);
}
when 'UPDATE' {
updateEvents.add(event);
}
when 'DELETE' {
deleteEvents.add(event);
}
}
}
// Process each operation type in bulk
if (!createEvents.isEmpty()) {
handleBulkCreate(createEvents);
}
if (!updateEvents.isEmpty()) {
handleBulkUpdate(updateEvents);
}
if (!deleteEvents.isEmpty()) {
handleBulkDelete(deleteEvents);
}
}
/**
* @description Bulk create handler
* @param events CREATE events
*/
private static void handleBulkCreate(List<{{EventName}}__e> events) {
List<{{TargetObject}}__c> newRecords = new List<{{TargetObject}}__c>();
for ({{EventName}}__e event : events) {
Map<String, Object> payload = parsePayload(event.Payload__c);
{{TargetObject}}__c record = new {{TargetObject}}__c();
// Map fields from payload
// record.Name = (String) payload.get('name');
// record.External_Id__c = event.Record_Id__c;
newRecords.add(record);
}
if (!newRecords.isEmpty()) {
// Use Database.insert for partial success handling
Database.SaveResult[] results = Database.insert(newRecords, false);
logDmlResults(results, 'CREATE');
}
}
/**
* @description Bulk update handler
* @param events UPDATE events
*/
private static void handleBulkUpdate(List<{{EventName}}__e> events) {
// Collect external IDs to query
Set<String> externalIds = new Set<String>();
Map<String, {{EventName}}__e> eventsByExternalId = new Map<String, {{EventName}}__e>();
for ({{EventName}}__e event : events) {
externalIds.add(event.Record_Id__c);
eventsByExternalId.put(event.Record_Id__c, event);
}
// Query existing records
/*
Map<String, {{TargetObject}}__c> recordsByExternalId = new Map<String, {{TargetObject}}__c>();
for ({{TargetObject}}__c record : [
SELECT Id, External_Id__c, Name
FROM {{TargetObject}}__c
WHERE External_Id__c IN :externalIds
WITH USER_MODE
]) {
recordsByExternalId.put(record.External_Id__c, record);
}
// Update records
List<{{TargetObject}}__c> toUpdate = new List<{{TargetObject}}__c>();
for (String extId : externalIds) {
if (recordsByExternalId.containsKey(extId)) {
{{TargetObject}}__c record = recordsByExternalId.get(extId);
{{EventName}}__e event = eventsByExternalId.get(extId);
Map<String, Object> payload = parsePayload(event.Payload__c);
// Update fields
// record.Name = (String) payload.get('name');
toUpdate.add(record);
}
}
if (!toUpdate.isEmpty()) {
Database.SaveResult[] results = Database.update(toUpdate, false);
logDmlResults(results, 'UPDATE');
}
*/
}
/**
* @description Bulk delete handler
* @param events DELETE events
*/
private static void handleBulkDelete(List<{{EventName}}__e> events) {
Set<String> externalIds = new Set<String>();
for ({{EventName}}__e event : events) {
externalIds.add(event.Record_Id__c);
}
/*
List<{{TargetObject}}__c> toDelete = [
SELECT Id
FROM {{TargetObject}}__c
WHERE External_Id__c IN :externalIds
WITH USER_MODE
];
if (!toDelete.isEmpty()) {
Database.DeleteResult[] results = Database.delete(toDelete, false);
logDeleteResults(results, 'DELETE');
}
*/
}
/**
* @description Parse JSON payload to Map
* @param payloadJson JSON string
* @return Parsed Map
*/
private static Map<String, Object> parsePayload(String payloadJson) {
if (String.isBlank(payloadJson)) {
return new Map<String, Object>();
}
try {
return (Map<String, Object>) JSON.deserializeUntyped(payloadJson);
} catch (JSONException e) {
System.debug(LoggingLevel.ERROR, 'Failed to parse payload: ' + e.getMessage());
return new Map<String, Object>();
}
}
/**
* @description Log DML results for monitoring
* @param results Database.SaveResult array
* @param operation Operation name
*/
private static void logDmlResults(Database.SaveResult[] results, String operation) {
Integer successCount = 0;
Integer failCount = 0;
for (Database.SaveResult sr : results) {
if (sr.isSuccess()) {
successCount++;
} else {
failCount++;
for (Database.Error err : sr.getErrors()) {
System.debug(LoggingLevel.ERROR,
operation + ' failed: ' + err.getStatusCode() + ' - ' + err.getMessage());
}
}
}
System.debug(LoggingLevel.INFO,
operation + ' results - Success: ' + successCount + ', Failed: ' + failCount);
}
/**
* @description Log delete results for monitoring
* @param results Database.DeleteResult array
* @param operation Operation name
*/
private static void logDeleteResults(Database.DeleteResult[] results, String operation) {
Integer successCount = 0;
Integer failCount = 0;
for (Database.DeleteResult dr : results) {
if (dr.isSuccess()) {
successCount++;
} else {
failCount++;
for (Database.Error err : dr.getErrors()) {
System.debug(LoggingLevel.ERROR,
operation + ' failed: ' + err.getStatusCode() + ' - ' + err.getMessage());
}
}
}
System.debug(LoggingLevel.INFO,
operation + ' results - Success: ' + successCount + ', Failed: ' + failCount);
}
}
/**
* @description Platform Event Subscriber Trigger for {{EventName}}__e
*
* Use Case: React to published Platform Events
* - Process incoming events from other systems
* - Sync data based on events
* - Trigger downstream processes
*
* Key Concepts:
* - Platform Event triggers run in their own execution context
* - Events are processed in batches (up to 2000 for High Volume)
* - ReplayId enables resuming from failures
* - SetResumeCheckpoint ensures durability
*
* IMPORTANT:
* - Triggers on Platform Events only fire on AFTER INSERT
* - Events are immutable (cannot be updated or deleted)
* - Triggers run as Automated Process user
*
* @author {{Author}}
* @date {{Date}}
*/
trigger {{EventName}}Subscriber on {{EventName}}__e (after insert) {
// Track last processed replay ID for durability
String lastReplayId = '';
// Process each event
for ({{EventName}}__e event : Trigger.new) {
// Store replay ID for checkpoint
lastReplayId = event.ReplayId;
try {
// Log event receipt
System.debug(LoggingLevel.DEBUG,
'Processing event: ' + event.Correlation_Id__c +
' Operation: ' + event.Operation__c +
' ReplayId: ' + event.ReplayId);
// Route to handler based on operation
switch on event.Operation__c {
when 'CREATE' {
{{EventName}}Handler.handleCreate(event);
}
when 'UPDATE' {
{{EventName}}Handler.handleUpdate(event);
}
when 'DELETE' {
{{EventName}}Handler.handleDelete(event);
}
when else {
// Unknown operation - log and continue
System.debug(LoggingLevel.WARN,
'Unknown operation: ' + event.Operation__c +
' for event: ' + event.Correlation_Id__c);
}
}
} catch (Exception e) {
// Log error but continue processing other events
// Don't throw - that would cause retry of ALL events in batch
System.debug(LoggingLevel.ERROR,
'Error processing event ' + event.Correlation_Id__c + ': ' + e.getMessage());
System.debug(LoggingLevel.ERROR, 'Stack: ' + e.getStackTraceString());
// Consider: Create error log record for monitoring
// {{EventName}}ErrorLogger.logError(event, e);
}
}
// Set resume checkpoint for durability
// If trigger fails after this point, processing resumes from this ReplayId
if (String.isNotBlank(lastReplayId)) {
EventBus.TriggerContext.currentContext().setResumeCheckpoint(lastReplayId);
}
}
/*
* ============================================================================
* PLATFORM EVENT TRIGGER BEST PRACTICES
* ============================================================================
*
* 1. ALWAYS set resume checkpoint
* - Call setResumeCheckpoint() with the last processed ReplayId
* - Ensures events aren't lost if trigger fails mid-batch
*
* 2. DON'T throw exceptions
* - Unhandled exceptions cause entire batch to retry
* - Catch errors per-event and log them
* - Continue processing remaining events
*
* 3. Keep processing lightweight
* - Avoid SOQL/DML in loops
* - Collect data, then do bulk DML outside loop
* - Consider queueing heavy work via Queueable
*
* 4. Handle duplicates
* - At-least-once delivery means duplicates possible
* - Use Correlation_Id__c to detect/dedupe
* - Design handlers to be idempotent
*
* 5. Monitor failures
* - Log errors to custom object for visibility
* - Set up alerts for processing failures
* - Review Event Delivery Failures in Setup
*
* ============================================================================
*/
<?xml version="1.0" encoding="UTF-8"?>
<!--
Platform Event Definition Template
Use Case: Asynchronous, event-driven communication
- Real-time notifications
- Decoupled system integrations
- High-volume data streaming
- Trigger external processes
Event Types:
- StandardVolume: ~2,000 events/hour, standard delivery
- HighVolume: Millions/day, at-least-once delivery, 24-hour retention
Publish Behaviors:
- PublishAfterCommit: Event published after transaction commits (default)
- PublishImmediately: Event published immediately (use for rollback scenarios)
Setup Steps:
1. Replace all {{placeholder}} values
2. Add fields as needed
3. Deploy to org
4. Create publisher (Apex, Flow, Process Builder)
5. Create subscriber (Trigger, Flow, External via CometD)
File Location: force-app/main/default/objects/{{EventName}}__e/{{EventName}}__e.object-meta.xml
-->
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
<!-- Deployment status -->
<deploymentStatus>Deployed</deploymentStatus>
<!--
Event Type:
- StandardVolume: Up to 2,000 events per hour (included in licenses)
- HighVolume: Up to millions per day (may require additional entitlement)
Use HighVolume for:
- Large-scale integrations
- IoT data streaming
- High-throughput scenarios
-->
<eventType>HighVolume</eventType>
<!-- Display labels -->
<label>{{EventLabel}}</label>
<pluralLabel>{{EventPluralLabel}}</pluralLabel>
<!--
Publish Behavior:
- PublishAfterCommit: Publish when transaction commits (RECOMMENDED)
Ensures event only fires if data save succeeds
- PublishImmediately: Publish right away
Use when you need event even if transaction rolls back
-->
<publishBehavior>PublishAfterCommit</publishBehavior>
<!-- Event Fields -->
<!-- Record ID field - link to triggering record -->
<fields>
<fullName>Record_Id__c</fullName>
<label>Record ID</label>
<description>ID of the Salesforce record that triggered this event</description>
<type>Text</type>
<length>18</length>
<externalId>false</externalId>
</fields>
<!-- Operation type field -->
<fields>
<fullName>Operation__c</fullName>
<label>Operation</label>
<description>The operation that occurred: CREATE, UPDATE, DELETE</description>
<type>Text</type>
<length>20</length>
<externalId>false</externalId>
</fields>
<!-- Payload field - JSON data -->
<fields>
<fullName>Payload__c</fullName>
<label>Payload</label>
<description>JSON payload containing event data</description>
<type>LongTextArea</type>
<length>131072</length>
<visibleLines>5</visibleLines>
</fields>
<!-- Correlation ID for tracking -->
<fields>
<fullName>Correlation_Id__c</fullName>
<label>Correlation ID</label>
<description>Unique ID for tracking related events across systems</description>
<type>Text</type>
<length>50</length>
<externalId>true</externalId>
</fields>
<!-- Timestamp field -->
<fields>
<fullName>Event_Timestamp__c</fullName>
<label>Event Timestamp</label>
<description>When the event occurred</description>
<type>DateTime</type>
</fields>
<!--
Add more fields as needed for your use case.
Field Types Supported:
- Text (up to 255 chars)
- LongTextArea (up to 131,072 chars)
- Number
- Checkbox
- DateTime
- Date
NOT Supported:
- Lookup/Master-Detail (no relationships)
- Formula
- Roll-up Summary
- Picklist (use Text instead)
-->
</CustomObject>
/**
* @description SOAP Web Service Callout Service
*
* Use Case: Integrations with SOAP/XML-based web services
* - Legacy enterprise systems
* - Government APIs
* - Financial services (often SOAP-based)
*
* Prerequisites:
* 1. Generate Apex from WSDL (Setup → Apex Classes → Generate from WSDL)
* 2. Configure Named Credential or Remote Site Setting for endpoint
*
* This template wraps WSDL-generated stub classes with:
* - Error handling
* - Timeout configuration
* - Logging
*
* @author {{Author}}
* @date {{Date}}
*/
public with sharing class {{ServiceName}}SoapService {
// Named Credential for SOAP endpoint (recommended over Remote Site Setting)
private static final String NAMED_CREDENTIAL = 'callout:{{NamedCredentialName}}';
// Timeout in milliseconds (max 120000)
private static final Integer TIMEOUT_MS = 120000;
/**
* @description Call SOAP operation with request object
*
* Replace {{WsdlStubClass}} with your WSDL-generated class name
* Replace {{PortType}} with the port/binding type from WSDL
* Replace {{OperationName}} with the SOAP operation to call
*
* @param request Request object (WSDL-generated type)
* @return Response object (WSDL-generated type)
* @throws SoapCalloutException on errors
*/
public static {{ResponseType}} callService({{RequestType}} request) {
try {
// Instantiate the WSDL-generated stub
{{WsdlStubClass}}.{{PortType}} stub = new {{WsdlStubClass}}.{{PortType}}();
// Configure endpoint
// Option 1: Use Named Credential (recommended)
stub.endpoint_x = NAMED_CREDENTIAL;
// Option 2: Direct endpoint (requires Remote Site Setting)
// stub.endpoint_x = '{{SoapEndpoint}}';
// Set timeout
stub.timeout_x = TIMEOUT_MS;
// Set custom SOAP headers if needed
// stub.inputHttpHeaders_x = new Map<String, String>{
// 'X-Custom-Header' => 'value'
// };
// Make the SOAP call
{{ResponseType}} response = stub.{{OperationName}}(request);
// Log success
System.debug(LoggingLevel.DEBUG, 'SOAP call successful: {{OperationName}}');
return response;
} catch (CalloutException e) {
// Network/timeout error
System.debug(LoggingLevel.ERROR, 'SOAP CalloutException: ' + e.getMessage());
throw new SoapCalloutException('Connection error: ' + e.getMessage(), e);
} catch (Exception e) {
// SOAP fault or other error
System.debug(LoggingLevel.ERROR, 'SOAP Error: ' + e.getMessage());
System.debug(LoggingLevel.ERROR, 'Stack: ' + e.getStackTraceString());
throw new SoapCalloutException('SOAP service error: ' + e.getMessage(), e);
}
}
/**
* @description Async SOAP call via Queueable
* Use this when calling from DML context (triggers)
* @param request Request object
* @param callbackId Optional ID for tracking the async job
*/
public static void callServiceAsync({{RequestType}} request, Id callbackId) {
System.enqueueJob(new SoapCalloutQueueable(request, callbackId));
}
/**
* @description Queueable implementation for async SOAP calls
*/
public class SoapCalloutQueueable implements Queueable, Database.AllowsCallouts {
private {{RequestType}} request;
private Id callbackId;
public SoapCalloutQueueable({{RequestType}} request, Id callbackId) {
this.request = request;
this.callbackId = callbackId;
}
public void execute(QueueableContext context) {
try {
{{ResponseType}} response = callService(request);
// Process response and update Salesforce record if needed
if (callbackId != null) {
processCallback(response, callbackId);
}
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, 'Async SOAP call failed: ' + e.getMessage());
// Consider: create error log, send notification
}
}
}
/**
* @description Process async callback result
* @param response SOAP response
* @param callbackId Record ID to update with result
*/
private static void processCallback({{ResponseType}} response, Id callbackId) {
// TODO: Implement callback processing
// Example: Update a record with the response data
/*
SObject record = callbackId.getSObjectType().newSObject(callbackId);
record.put('Integration_Status__c', 'Complete');
record.put('Integration_Response__c', JSON.serialize(response));
update record;
*/
}
/**
* @description Custom exception for SOAP callout errors
*/
public class SoapCalloutException extends Exception {}
}
/*
* ============================================================================
* WSDL2APEX GENERATION GUIDE
* ============================================================================
*
* Step 1: Obtain WSDL
* -------------------
* Get the WSDL file from the external system. It should be an XML file
* defining the service, operations, and data types.
*
* Step 2: Generate Apex Classes
* -----------------------------
* 1. Go to Setup → Apex Classes
* 2. Click "Generate from WSDL"
* 3. Upload the WSDL file
* 4. Choose a namespace (or accept default)
* 5. Click "Generate Apex code"
*
* Step 3: Review Generated Classes
* --------------------------------
* Salesforce generates:
* - Stub class ({{WsdlStubClass}}) with methods for each operation
* - Request/Response wrapper classes
* - Data type classes matching WSDL schema
*
* Step 4: Configure Access
* ------------------------
* Option A: Named Credential (Recommended)
* - Create Named Credential with SOAP endpoint
* - Set stub.endpoint_x = 'callout:NamedCredentialName'
*
* Option B: Remote Site Setting
* - Create Remote Site Setting with SOAP endpoint domain
* - Set stub.endpoint_x to full URL
*
* Step 5: Test the Integration
* ----------------------------
* Execute anonymous Apex:
*
* {{RequestType}} req = new {{RequestType}}();
* req.field1 = 'value';
* {{ResponseType}} resp = {{ServiceName}}SoapService.callService(req);
* System.debug(resp);
*
* ============================================================================
*/
WSDL to Apex Generation Guide
Overview
Salesforce can automatically generate Apex classes from WSDL (Web Services Description Language) files, enabling integration with SOAP-based web services.
Step-by-Step Process
1. Obtain the WSDL File
Get the WSDL from your external system. Common sources:
- API documentation portal
- Endpoint URL with
?wsdlsuffix (e.g.,https://api.example.com/service?wsdl) - Direct download from vendor
2. Review WSDL for Compatibility
Salesforce WSDL2Apex has limitations. Check for:
Supported:
- Document/literal and RPC/encoded styles
- Simple types (string, integer, boolean, date, etc.)
- Complex types (objects with properties)
- Arrays and lists
- Basic SOAP headers
Not Supported / Problematic:
- Very large WSDLs (may hit Apex class size limits)
- Certain complex inheritance patterns
- Some advanced XSD features
- WS-Security (requires manual implementation)
3. Generate Apex Classes
1. Navigate to Setup → Apex Classes 2. Click Generate from WSDL 3. Click Choose File and upload the WSDL 4. Review the parse results 5. Modify class names if needed (keep them short to avoid limits) 6. Click Generate Apex code
4. Generated Class Structure
For a WSDL defining CustomerService with operation getCustomer:
AsyncCustomerService.cls - Async version of service
CustomerService.cls - Main stub class with methods
GetCustomerRequest.cls - Request wrapper
GetCustomerResponse.cls - Response wrapper
Customer.cls - Data type from schema
Address.cls - Nested data type5. Configure Endpoint Access
Option A: Named Credential (Recommended)
1. Create Named Credential in Setup 2. Set endpoint to SOAP service URL 3. Configure authentication (Basic, Certificate, OAuth) 4. In Apex: stub.endpoint_x = 'callout:MyNamedCredential';
Option B: Remote Site Setting
1. Create Remote Site Setting with domain 2. In Apex: stub.endpoint_x = 'https://api.example.com/service';
6. Basic Usage Example
public class CustomerServiceCaller {
public static Customer getCustomer(String customerId) {
// Instantiate the generated stub
CustomerService.CustomerServicePort stub = new CustomerService.CustomerServicePort();
// Configure endpoint (Named Credential)
stub.endpoint_x = 'callout:CustomerServiceNC';
// Set timeout (max 120 seconds)
stub.timeout_x = 120000;
// Create request
GetCustomerRequest request = new GetCustomerRequest();
request.customerId = customerId;
// Make the call
GetCustomerResponse response = stub.getCustomer(request);
return response.customer;
}
}7. Error Handling
try {
GetCustomerResponse response = stub.getCustomer(request);
// Process response
} catch (CalloutException e) {
// Network error, timeout, SSL issues
System.debug('Callout failed: ' + e.getMessage());
} catch (Exception e) {
// SOAP fault (error from service)
// The exception message contains SOAP fault details
System.debug('SOAP error: ' + e.getMessage());
}8. Common Issues and Solutions
| Issue | Cause | Solution |
|---|---|---|
Web service callout failed | Network/SSL issue | Check Remote Site Setting, verify endpoint |
Read timed out | Service slow to respond | Increase timeout_x (max 120000ms) |
Apex class size limit | WSDL too large | Split WSDL, use fewer operations |
Unable to parse callout response | Response doesn't match WSDL | Check service version, update WSDL |
Methods defined as Webservice | Conflict with reserved keywords | Rename operations in WSDL or generated class |
9. Testing SOAP Callouts
Use Test.setMock() with WebServiceMock:
@isTest
public class CustomerServiceTest {
@isTest
static void testGetCustomer() {
// Set mock
Test.setMock(WebServiceMock.class, new CustomerServiceMock());
// Call service
Test.startTest();
Customer result = CustomerServiceCaller.getCustomer('CUST001');
Test.stopTest();
// Assert
System.assertEquals('Test Customer', result.name);
}
// Mock implementation
public class CustomerServiceMock implements WebServiceMock {
public void doInvoke(
Object stub,
Object request,
Map<String, Object> response,
String endpoint,
String soapAction,
String requestName,
String responseNS,
String responseName,
String responseType
) {
// Create mock response
GetCustomerResponse mockResponse = new GetCustomerResponse();
mockResponse.customer = new Customer();
mockResponse.customer.name = 'Test Customer';
response.put('response_x', mockResponse);
}
}
}10. Async SOAP Calls
For calls from triggers or after DML:
public class CustomerSyncQueueable implements Queueable, Database.AllowsCallouts {
private String customerId;
public CustomerSyncQueueable(String customerId) {
this.customerId = customerId;
}
public void execute(QueueableContext context) {
try {
Customer customer = CustomerServiceCaller.getCustomer(customerId);
// Process result
} catch (Exception e) {
// Log error
}
}
}
// Usage in trigger:
System.enqueueJob(new CustomerSyncQueueable(accountId));Best Practices
1. Always use Named Credentials for authentication instead of hardcoding credentials 2. Set appropriate timeouts - default may be too short 3. Implement error handling - SOAP services can fail in many ways 4. Log requests and responses for debugging 5. Use async patterns when calling from DML contexts 6. Test with mocks - don't call real services in tests 7. Monitor governor limits - especially for large responses
Limitations
- Maximum 100 callouts per transaction
- Maximum 120 second timeout per callout
- Response body limit: 6MB
- Apex code size limits may prevent large WSDL imports
- Some WSDL features not supported (WS-Security, MTOM, etc.)
Credits & Acknowledgments
This skill is built on Salesforce integration platform patterns, incorporating security best practices and architectural guidance from the Salesforce platform ecosystem.
---
#!/usr/bin/env python3
"""
Credential Setup Suggestion Hook for building-sf-integrations
Detects when credential metadata files are created and suggests
running the appropriate automation scripts.
File patterns detected:
- *.namedCredential-meta.xml → configure-named-credential.sh
- *.externalCredential-meta.xml → configure-named-credential.sh
- *cspTrustedSite-meta.xml → Endpoint security configured
- *remoteSite-meta.xml → Endpoint security configured
Called automatically via PostToolUse hook on Write operations.
"""
import json
import os
import re
import sys
from pathlib import Path
# File pattern matchers
PATTERNS = {
'named_credential': re.compile(r'\.namedCredential-meta\.xml$', re.IGNORECASE),
'external_credential': re.compile(r'\.externalCredential-meta\.xml$', re.IGNORECASE),
'csp_trusted_site': re.compile(r'\.cspTrustedSite-meta\.xml$', re.IGNORECASE),
'remote_site': re.compile(r'\.remoteSiteSetting-meta\.xml$|\.remoteSite-meta\.xml$', re.IGNORECASE),
'external_service': re.compile(r'\.externalServiceRegistration-meta\.xml$', re.IGNORECASE),
}
# Script recommendations per file type
SCRIPT_RECOMMENDATIONS = {
'named_credential': {
'script': 'configure-named-credential.sh',
'description': 'Set API key securely via ConnectApi (Enhanced Named Credentials)',
'usage': './scripts/configure-named-credential.sh <org-alias>',
'next_steps': [
'Deploy metadata: sf project deploy start --metadata NamedCredential:<name>',
'Run script to configure API key securely',
'Test connection in Setup → Named Credentials'
]
},
'external_credential': {
'script': 'configure-named-credential.sh',
'description': 'Configure External Credential with ConnectApi',
'usage': './scripts/configure-named-credential.sh <org-alias>',
'next_steps': [
'Deploy External Credential first',
'Deploy associated Named Credential',
'Run script to set authentication parameters'
]
},
'csp_trusted_site': {
'script': None,
'description': 'CSP Trusted Site created for endpoint security',
'usage': None,
'next_steps': [
'Deploy: sf project deploy start --metadata CspTrustedSite:<name>',
'Verify in Setup → CSP Trusted Sites'
]
},
'remote_site': {
'script': None,
'description': 'Remote Site Setting created (legacy endpoint security)',
'usage': None,
'next_steps': [
'Deploy: sf project deploy start --metadata RemoteSiteSetting:<name>',
'Consider migrating to CSP Trusted Sites for modern approach'
]
},
'external_service': {
'script': None,
'description': 'External Service registration created',
'usage': None,
'next_steps': [
'Ensure Named Credential is configured first',
'Deploy: sf project deploy start --metadata ExternalServiceRegistration:<name>',
'Apex classes will be auto-generated from OpenAPI spec'
]
}
}
def detect_file_type(file_path: str) -> str | None:
"""Detect the credential file type from the file path."""
filename = os.path.basename(file_path)
for file_type, pattern in PATTERNS.items():
if pattern.search(filename):
return file_type
return None
def extract_credential_name(file_path: str, file_type: str) -> str:
"""Extract the credential name from the file path."""
filename = os.path.basename(file_path)
# Remove the metadata suffix to get the credential name
patterns = {
'named_credential': r'(.+)\.namedCredential-meta\.xml$',
'external_credential': r'(.+)\.externalCredential-meta\.xml$',
'csp_trusted_site': r'(.+)\.cspTrustedSite-meta\.xml$',
'remote_site': r'(.+)\.(?:remoteSiteSetting|remoteSite)-meta\.xml$',
'external_service': r'(.+)\.externalServiceRegistration-meta\.xml$',
}
pattern = patterns.get(file_type)
if pattern:
match = re.match(pattern, filename, re.IGNORECASE)
if match:
return match.group(1)
return filename
def analyze_file_content(file_path: str) -> dict:
"""Analyze the file content for additional context."""
context = {
'auth_protocol': None,
'endpoint_url': None,
'has_oauth': False,
'has_certificate': False
}
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Detect authentication protocol
if '<authProtocol>OAuth</authProtocol>' in content:
context['auth_protocol'] = 'OAuth 2.0'
context['has_oauth'] = True
elif '<authProtocol>Jwt</authProtocol>' in content:
context['auth_protocol'] = 'JWT Bearer'
elif '<authProtocol>Custom</authProtocol>' in content:
context['auth_protocol'] = 'Custom (API Key)'
elif '<authProtocol>Certificate</authProtocol>' in content:
context['auth_protocol'] = 'Certificate'
context['has_certificate'] = True
# Extract endpoint URL
url_match = re.search(r'<endpoint>([^<]+)</endpoint>', content)
if url_match:
context['endpoint_url'] = url_match.group(1)
# Check for Named Credential URL pattern
url_match = re.search(r'<url>([^<]+)</url>', content)
if url_match:
context['endpoint_url'] = url_match.group(1)
except Exception:
pass # File analysis is optional
return context
def generate_suggestion_message(file_type: str, cred_name: str, file_context: dict) -> str:
"""Generate the suggestion message for Claude."""
recommendation = SCRIPT_RECOMMENDATIONS.get(file_type, {})
lines = [
'',
'═' * 60,
'🔐 CREDENTIAL CONFIGURATION DETECTED',
'═' * 60,
'',
f'📄 File Type: {file_type.replace("_", " ").title()}',
f'📛 Name: {cred_name}',
]
if file_context.get('auth_protocol'):
lines.append(f'🔑 Auth Protocol: {file_context["auth_protocol"]}')
if file_context.get('endpoint_url'):
lines.append(f'🌐 Endpoint: {file_context["endpoint_url"]}')
lines.append('')
if recommendation.get('script'):
lines.extend([
'┌─────────────────────────────────────────────────────────┐',
'│ 🚀 AUTOMATION SCRIPT AVAILABLE │',
'├─────────────────────────────────────────────────────────┤',
f'│ Script: {recommendation["script"]:<46} │',
f'│ Purpose: {recommendation["description"][:44]:<44} │',
'├─────────────────────────────────────────────────────────┤',
'│ 💡 OFFER TO RUN: │',
f'│ {recommendation["usage"]:<55} │',
'└─────────────────────────────────────────────────────────┘',
'',
])
lines.extend([
'📋 NEXT STEPS:',
'─' * 60,
])
for i, step in enumerate(recommendation.get('next_steps', []), 1):
lines.append(f' {i}. {step}')
# Add OAuth-specific suggestion
if file_context.get('has_oauth'):
lines.extend([
'',
'⚠️ OAuth detected: Consider using /configuring-connected-apps to',
' create the Connected App for this credential.',
])
lines.extend([
'',
'═' * 60,
])
return '\n'.join(lines)
def main():
"""Main entry point for the hook."""
# Get file path from command line or stdin
file_path = None
if len(sys.argv) > 1:
file_path = sys.argv[1]
else:
# Try to read from stdin (hook input)
try:
hook_input = json.load(sys.stdin)
tool_input = hook_input.get('tool_input', {})
file_path = tool_input.get('file_path', '')
except (json.JSONDecodeError, IOError):
pass
if not file_path:
# No file path, exit silently
print(json.dumps({'continue': True}))
return 0
# Detect file type
file_type = detect_file_type(file_path)
if not file_type:
# Not a credential file, exit silently
print(json.dumps({'continue': True}))
return 0
# Extract credential name
cred_name = extract_credential_name(file_path, file_type)
# Analyze file content
file_context = analyze_file_content(file_path)
# Generate suggestion message
message = generate_suggestion_message(file_type, cred_name, file_context)
# Output hook result
result = {
'continue': True,
'hookSpecificOutput': {
'hookEventName': 'PostToolUse',
'additionalContext': message
}
}
print(json.dumps(result))
return 0
if __name__ == '__main__':
sys.exit(main())
Related skills
Forks & variants (1)
Building Sf Integrations has 1 known copy in the catalog totaling 523 installs. They canonicalize to this original listing.
- forcedotcom - 523 installs
How it compares
Use building-sf-integrations when you need Apex-native retry and Queueable backoff rather than middleware-side retry outside Salesforce.
FAQ
Can I make synchronous callouts from Apex triggers?
No. Use async callout patterns for trigger-originated outbound work.
How should secrets be stored?
Never hardcode credentials; use Named Credentials and External Credentials for runtime-managed auth.
When use External Services vs hand-written callouts?
External Services for OpenAPI spec-driven clients; Named Credential callouts for custom Apex or Flow HTTP.
Is Building Sf Integrations safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.