Callouts in Apex: Secure Auth, Non-Blocking UX, and Resilient Retries

Share

Building reliable Salesforce integrations means balancing security, user experience, and resilience. You want secure authentication, non-blocking calls for the UI, and smart retry logic that doesn’t break governor limits.

In this guide, we’ll walk through how to achieve all three using:

  • Named Credentials for configuration-based security

  • OAuth/JWT for headless authentication

  • Continuation for long-running UI callouts

  • Governor-aware retries for robust, scalable recovery


Named Credentials — Configuration-First Security and Simpler Code

Core idea:
Store your base URL and authentication setup in Setup → Named Credentials, then reference it directly in Apex using callout:Your_NC.

This means your Apex code never handles secrets, tokens rotate automatically, and sandbox vs. production endpoints are easy to manage — no code changes required.

Example scenario:
A finance app makes calls to a payment service API. When the API key rotates, you simply update the Named Credential — no redeployment or code edits needed.

Best practices:

  • Keep URLs, paths, and queries in code — but store the host and authentication in the Named Credential.

  • Add timeouts and idempotency keys for safety.

  • Log correlation IDs, not raw tokens.

public with sharing class PaymentsClient {
    public class PaymentResponse { public String id; public String status; }

    public static PaymentResponse fetch(String paymentId, String correlationId) {
        HttpRequest req = new HttpRequest();
        req.setMethod('GET');
        req.setTimeout(10000); // 10s
        req.setEndpoint('callout:Payments_NC/v1/payments/' +
            EncodingUtil.urlEncode(paymentId, 'UTF-8'));
        req.setHeader('Accept', 'application/json');
        req.setHeader('X-Correlation-Id', correlationId);

        HTTPResponse res = new HTTP().send(req);
        if (res.getStatusCode() == 200) {
            return (PaymentResponse) JSON.deserialize(res.getBody(), PaymentResponse.class);
        }
        // Surface concise error, keep details in logs
        System.debug(LoggingLevel.WARN, 'Payments fetch failed ' + res.getStatus());
        throw new CalloutException('Payments API error: ' + res.getStatus());
    }
}

OAuth/JWT (JWT Bearer Flow) — Headless, Server-to-Server Authentication

Core idea:
Use an Auth Provider and Named Credential configured with the JWT Bearer flow. Salesforce automatically signs the JWT with your private key and injects the Bearer token into your callouts.

Your Apex code doesn’t need to handle tokens manually — Salesforce manages the authentication behind the scenes.

Example scenario:
A nightly batch pushes invoices to an ERP system. No user intervention is required, and the integration stays secure without passwords.

Best practices:

  • Use JWT Bearer for backend, headless integrations.

  • Keep scopes as limited as possible.

  • Store private keys securely in the Auth Provider — avoid handling them in Apex.

public with sharing class ERPClient {
    public static void pushInvoice(Invoice__c inv, String correlationId) {
        HttpRequest req = new HttpRequest();
        req.setMethod('POST');
        req.setEndpoint('callout:ERP_NC/api/invoices');
        req.setHeader('Content-Type', 'application/json');
        req.setHeader('X-Correlation-Id', correlationId);
        req.setBody(JSON.serialize(inv));

        HTTPResponse res = new HTTP().send(req);
        Integer code = res.getStatusCode();
        if (code >= 200 && code < 300) return;

        if (code == 409) throw new CalloutException('Invoice already exists');
        throw new CalloutException('ERP error: ' + res.getStatus());
    }
}


Continuation — Non-Blocking Callouts for Slow or Long-Running APIs

Core idea:
When dealing with APIs that take longer to respond — like shipping quotes or pricing engines — use Continuation to keep the user interface responsive.

Continuation offloads the HTTP request and waits for up to 120 seconds while freeing the request thread. The UI (LWC, Aura, or Visualforce) resumes once the response is ready.

Example scenario:
A customer clicks “Get Shipping Quote.” The controller defers the request using Continuation, and the UI automatically updates once the carrier’s system returns a result.

Best practices:

  • Use Continuation for user-initiated and high-latency calls.

  • Keep the per-request state light (serialize IDs, not large data blobs).

  • Return structured, typed data back to the component.

global with sharing class ShippingController {
    @AuraEnabled(continuation=true)
    global static Object getQuote(String orderId) {
        Continuation cont = new Continuation(120);
        HttpRequest req = new HttpRequest();
        req.setMethod('GET');
        req.setEndpoint('callout:Carrier_NC/quotes?orderId=' +
            EncodingUtil.urlEncode(orderId, 'UTF-8'));
        String label = cont.addHttpRequest(req);
        cont.state = label; // optional correlation
        cont.continuationMethod = 'handleQuote';
        return cont;
    }

    @AuraEnabled
    global static Object handleQuote(Object state) {
        String label = (String) state;
        HTTPResponse res = Continuation.getResponse(label);
        if (res.getStatusCode() == 200) {
            return JSON.deserializeUntyped(res.getBody()); // return to LWC/Aura
        }
        throw new AuraHandledException('Quote failed: ' + res.getStatus());
    }
}

Governor-Aware Retries — Building Resilient and Limits-Friendly Integrations

Core idea:
Since Apex can’t “sleep” or pause execution, you need to design retries that span transactions. The pattern uses Queueable jobs combined with Scheduled Apex to retry failed requests with exponential backoff.

This ensures your integration respects governor limits, avoids spamming the remote system, and automatically recovers from transient issues (like rate limiting or 5xx errors).

Example scenario:
A payment gateway starts returning 429 (rate-limited) responses during peak hours. Your retry logic spaces out future attempts (1, 2, 4, 8 minutes, etc.) until it succeeds — without hitting governor or API limits.

Best practices:

  • Retry only idempotent requests (or include an Idempotency-Key).

  • Cap the number of attempts and log the final failure.

  • Respect the Retry-After header if the external system provides it.

public with sharing class RetryableChargeJob implements Queueable, Database.AllowsCallouts {
    String payloadJson; Integer attempt;
    public RetryableChargeJob(String payloadJson, Integer attempt) {
        this.payloadJson = payloadJson; this.attempt = attempt == null ? 1 : attempt;
    }

    public void execute(QueueableContext ctx) {
        // Guardrails: bail early if close to limits
        if (Limits.getCallouts() >= Limits.getLimitCallouts() - 1 ||
            Limits.getCpuTime() > (Limits.getLimitCpuTime() - 1000)) {
            scheduleNext(attempt); return;
        }

        HttpRequest req = new HttpRequest();
        req.setMethod('POST');
        req.setEndpoint('callout:Payments_NC/v1/charges');
        req.setHeader('Content-Type', 'application/json');
        req.setHeader('Idempotency-Key', Crypto.generateDigest('SHA1', Blob.valueOf(payloadJson)).toString());
        req.setBody(payloadJson);

        HTTPResponse res;
        try { res = new HTTP().send(req); }
        catch (Exception e) { scheduleNext(attempt); return; }

        Integer code = res.getStatusCode();
        if (code >= 200 && code < 300) return; // success

        if (code == 429 || code >= 500) {
            // Prefer Retry-After header if available
            Integer retryAfter = parseRetryAfterSeconds(res.getHeader('Retry-After'));
            scheduleNext(attempt, retryAfter);
            return;
        }
        // Non-retryable
        System.debug(LoggingLevel.ERROR, 'Charge failed non-retryable: ' + code + ' ' + res.getStatus());
        // Optionally publish a Platform Event for ops here
    }

    private static Integer parseRetryAfterSeconds(String headerVal) {
        if (String.isBlank(headerVal)) return null;
        try { return Integer.valueOf(headerVal); } catch (Exception e) { return null; }
    }

    private void scheduleNext(Integer currentAttempt, Integer retryAfterSecondsOpt) {
        Integer maxAttempts = 5;
        if (currentAttempt >= maxAttempts) return;

        Integer baseDelayMin = Math.min(60, Integer.valueOf(Math.pow(2, currentAttempt)).intValue()); // 1,2,4,8,16
        Integer jitterMin = Math.abs(Crypto.getRandomInteger()) % 3; // 0–2
        Integer delayMin = (retryAfterSecondsOpt != null)
            ? Math.max( (retryAfterSecondsOpt + 59) / 60, 1)   // ceil to minutes
            : baseDelayMin + jitterMin;

        Datetime runAt = Datetime.now().addMinutes(delayMin);
        String cron = String.format('{0} {1} {2} {3} {4} ? {5}',
            new List<Object>{ runAt.second(), runAt.minute(), runAt.hour(), runAt.day(), runAt.month(), runAt.year() });

        String name = 'RetryCharge_' + currentAttempt + '_' + Datetime.now().getTime();
        System.schedule(name, cron, new RetryScheduler(payloadJson, currentAttempt + 1));
    }
}

global class RetryScheduler implements Schedulable {
    String payloadJson; Integer attempt;
    public RetryScheduler(String payloadJson, Integer attempt) {
        this.payloadJson = payloadJson; this.attempt = attempt;
    }
    global void execute(SchedulableContext sc) {
        System.enqueueJob(new RetryableChargeJob(payloadJson, attempt));
    }
}

Callouts

Quick Summary

To build integrations that scale gracefully and survive real-world conditions:

  • Named Credentials: Centralize security and make your code environment-safe.

  • OAuth/JWT: Handle server-to-server authentication without user credentials.

  • Continuation: Keep your UI responsive during slow API calls.

  • Governor-aware retries: Use Queueable + Scheduled Apex to gracefully recover from transient errors.

Together, these patterns create Salesforce callouts that are secure, performant, and fault-tolerant, ready for any enterprise-grade integration scenario.

  • October 20, 2025