DEV Community

Selavina B
Selavina B

Posted on

Integration Failures and API Callout Issues

Problem statement:
External integrations (REST/SOAP) intermittently fail due to unhandled callout errors, timeout issues, changes in external API contracts, or missing named credentials, causing data sync problems between Salesforce and external systems.

Step 1 Fix the #1 root cause: Authentication + Endpoint setup
Use Named Credentials (don’t hardcode URL/tokens)

Setup → Named Credentials

Create External Credential + Named Credential (newer setup) OR classic Named Credential.

Ensure:

Correct base URL (prod vs sandbox)

Auth method (OAuth 2.0 / JWT / Basic)

Proper scopes/permissions

Add required Headers if the API needs them (e.g., API key header)

Why: Missing/incorrect Named Credentials causes intermittent 401/403 and “it works sometimes” issues due to token refresh failures.

Step 2 Create a single, reusable Apex callout wrapper (handles errors properly)
Apex: Callout service with status-code handling + timeout + safe parsing
`public with sharing class ExternalApiService {
public class ApiException extends Exception {
public Integer statusCode;
public String responseBody;
public ApiException(String msg, Integer code, String body) {
super(msg);
statusCode = code;
responseBody = body;
}
}

public class ApiResponse {
    @AuraEnabled public Integer statusCode;
    @AuraEnabled public String body;
    @AuraEnabled public Map<String, Object> json; // tolerant parsing
}

// Named Credential: "Order_API"
// Endpoint usage: callout:Order_API/path
public static ApiResponse sendRequest(String method, String path, String jsonBody, Integer timeoutMs) {
    HttpRequest req = new HttpRequest();
    req.setMethod(method);
    req.setEndpoint('callout:Order_API' + path);
    req.setTimeout(timeoutMs == null ? 20000 : timeoutMs); // default 20s

    req.setHeader('Content-Type', 'application/json');
    req.setHeader('Accept', 'application/json');

    if (!String.isBlank(jsonBody) && (method == 'POST' || method == 'PUT' || method == 'PATCH')) {
        req.setBody(jsonBody);
    }

    Http http = new Http();
    HttpResponse res;
    try {
        res = http.send(req);
    } catch (System.CalloutException ce) {
        // Timeouts, DNS, handshake, etc.
        throw new ApiException('Callout failed: ' + ce.getMessage(), 0, null);
    }

    ApiResponse out = new ApiResponse();
    out.statusCode = res.getStatusCode();
    out.body = res.getBody();

    // Handle common failure codes clearly
    if (out.statusCode >= 400) {
        throw new ApiException(
            'External API error. Status=' + out.statusCode,
            out.statusCode,
            out.body
        );
    }

    // Tolerant JSON parsing (prevents failures if API adds fields)
    if (!String.isBlank(out.body) && out.body.trim().startsWith('{')) {
        out.json = (Map<String, Object>) JSON.deserializeUntyped(out.body);
    }
    return out;
}
Enter fullscreen mode Exit fullscreen mode

}
`

What this fixes

Intermittent failures become visible and actionable (proper exceptions + status codes)

Timeouts are handled

JSON parsing is resilient to “API contract changes” (new fields won’t break your code)

Step 3 Add retries for transient failures (timeouts, 429, 5xx)

Retries should be async (Queueable) and limited.

Queueable retry worker (with simple backoff + max attempts)
`public with sharing class ExternalSyncJob implements Queueable, Database.AllowsCallouts {
private Id recordId;
private Integer attempt;

public ExternalSyncJob(Id recordId, Integer attempt) {
    this.recordId = recordId;
    this.attempt = attempt == null ? 1 : attempt;
}

public void execute(QueueableContext context) {
    // Example: sync an Order__c record
    Order__c ord = [
        SELECT Id, Name, Amount__c, External_Id__c
        FROM Order__c
        WHERE Id = :recordId
        LIMIT 1
    ];

    String payload = JSON.serialize(new Map<String, Object>{
        'orderName' => ord.Name,
        'amount' => ord.Amount__c,
        'externalId' => ord.External_Id__c
    });

    try {
        ExternalApiService.ApiResponse resp =
            ExternalApiService.sendRequest('POST', '/orders', payload, 20000);

        // Mark success
        ord.Last_Sync_Status__c = 'SUCCESS';
        ord.Last_Sync_Message__c = 'Synced OK';
        update ord;

    } catch (ExternalApiService.ApiException ex) {
        // Retry only for transient cases
        Boolean transientFail =
            ex.statusCode == 0 || ex.statusCode == 429 || (ex.statusCode >= 500 && ex.statusCode <= 599);

        logIntegration('Order Sync', recordId, 'ERROR',
            'Attempt ' + attempt + ' failed. Status=' + ex.statusCode,
            ex.responseBody
        );

        if (transientFail && attempt < 3) {
            // Re-enqueue (basic retry). For real backoff, schedule later (see note below).
            System.enqueueJob(new ExternalSyncJob(recordId, attempt + 1));
        } else {
            ord.Last_Sync_Status__c = 'FAILED';
            ord.Last_Sync_Message__c = 'Final failure: ' + ex.getMessage();
            update ord;
        }
    }
}

private static void logIntegration(String operation, Id relatedId, String level, String message, String body) {
    try {
        Integration_Log__c log = new Integration_Log__c(
            Operation__c = operation,
            Related_Record_Id__c = String.valueOf(relatedId),
            Level__c = level,
            Message__c = message,
            Payload__c = body
        );
        insert log;
    } catch (Exception ignore) {}
}
Enter fullscreen mode Exit fullscreen mode

}
`

If you want true backoff delays (e.g., wait 5 min), use Schedulable or Platform Events + subscriber. Queueable runs immediately.

Step 4 Prevent “contract change” breaks with tolerant parsing

If you currently do strict deserialization like:

MyDto dto = (MyDto) JSON.deserialize(body, MyDto.class);

This can break when:

API changes data type

Field becomes null

Response shape changes

Safer parsing pattern
Map<String, Object> m = (Map<String, Object>) JSON.deserializeUntyped(body);
Object value = m.get('status'); // check exists
String status = value == null ? null : String.valueOf(value);

You can still map to DTOs, but keep tolerant fallback for critical fields.

Step 5 Use idempotency to avoid duplicate writes (very common in retries)

When retries happen, you must ensure you don’t create duplicates externally.

Add an idempotency key header (if API supports it)
req.setHeader('Idempotency-Key', String.valueOf(recordId));

Or always send a stable externalId in payload and have the external system “upsert”.

Step 6 Add proper monitoring (so “intermittent” becomes measurable)

Create a custom object like Integration_Log__c with fields:

Operation__c

Related_Record_Id__c

Level__c (INFO/ERROR)

Message__c

Payload__c (Long Text)

Status_Code__c (Number)

Timestamp__c (Date/Time)

Then build a simple report/dashboard:

Failures by day

Top error codes (401/403/429/5xx)

Records with repeated failures

Step 7 Write stable tests using HttpCalloutMock (mandatory for deployments)
Mock success + failure
`@IsTest
private class ExternalApiServiceTest {

private class SuccessMock implements HttpCalloutMock {
    public HTTPResponse respond(HTTPRequest req) {
        HttpResponse res = new HttpResponse();
        res.setStatusCode(200);
        res.setBody('{"status":"ok","id":"ABC123"}');
        return res;
    }
}

private class FailureMock implements HttpCalloutMock {
    public HTTPResponse respond(HTTPRequest req) {
        HttpResponse res = new HttpResponse();
        res.setStatusCode(500);
        res.setBody('{"error":"server_error"}');
        return res;
    }
}

@IsTest
static void testSendRequestSuccess() {
    Test.setMock(HttpCalloutMock.class, new SuccessMock());

    ExternalApiService.ApiResponse resp =
        ExternalApiService.sendRequest('GET', '/health', null, 5000);

    System.assertEquals(200, resp.statusCode);
    System.assertEquals('ok', String.valueOf(resp.json.get('status')));
}

@IsTest
static void testSendRequestFailureThrows() {
    Test.setMock(HttpCalloutMock.class, new FailureMock());
    try {
        ExternalApiService.sendRequest('GET', '/health', null, 5000);
        System.assert(false, 'Expected exception');
    } catch (ExternalApiService.ApiException ex) {
        System.assertEquals(500, ex.statusCode);
    }
}
Enter fullscreen mode Exit fullscreen mode

}`

Conclusion

Integration failures in Salesforce usually feel “intermittent” because the root causes are hidden auth/token issues, timeouts, rate limits (429), server errors (5xx), or small API contract changes. If callouts aren’t handled with proper status checks, timeouts, retries, and logging, a single external glitch can break your sync and create messy production incidents.
To make integrations stable and predictable:

  • Use Named Credentials so authentication and endpoints are managed securely and consistently.
  • Build a single callout wrapper that handles timeouts, validates HTTP status codes, and parses responses safely.
  • Retry only transient failures (timeouts/429/5xx) using Queueable callouts, and avoid duplicates with idempotency keys/external IDs.
  • Add structured logging + monitoring so failures are visible, traceable, and measurable.
  • Write strong HttpCalloutMock tests so deployments don’t break and issues are caught early.
  • With these practices, your Salesforce-to-external-system sync becomes reliable, debuggable, and production-ready, even when external APIs change or behave unpredictably.

Top comments (1)

Collapse
 
goldsteinnick profile image
Nick Goldstein

A lot of important reminders in here!