A Salesforce org integrates with five external systems: a payments gateway, a billing platform, an analytics API, a notification service, and the company's internal data warehouse. On a normal day, every callout works. On the afternoon any one of those systems has a bad hour, the integration code starts dropping operations, duplicating charges, or returning empty data to users. Three callout fundamentals decide whether the integration is robust or fragile: timeout configuration, retry logic, idempotency.
Most production Apex callout code is missing at least one of the three. The omission usually does not matter for months, then matters a lot for one afternoon.
Timeout configuration
The default Apex HTTP callout timeout is 10 seconds, with a maximum of 120 seconds. Most teams leave the default in place. This is rarely the right choice.
The right timeout for a callout depends on the operation:
| Operation |
Reasonable timeout |
| Idempotent GET (read) |
5 to 10 seconds |
| Critical POST (e.g., payment authorization) |
30 to 60 seconds |
| Heavy POST (e.g., bulk upload) |
60 to 120 seconds (the max) |
| Webhook acknowledgment |
2 to 5 seconds (fail fast, return to Salesforce quickly) |
The setting:
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/charge');
req.setMethod('POST');
req.setTimeout(45000); // 45 seconds
// ... set headers and body ...
HttpResponse res = http.send(req);
Setting the timeout shorter than the default fails fast on systems that are slow but eventually respond. Setting it longer accommodates legitimately slow operations without timing out prematurely.
The mistake to avoid: leaving the default 10 seconds on critical operations. A payment callout that takes 12 seconds (because the gateway is briefly overloaded) times out and the customer is left wondering whether the charge went through.
Retry on transient failures
External systems experience three categories of failure:
Transient. Network blip, brief overload, transient rate limit. The same request, retried in a few seconds, will succeed.
Persistent system failure. The external service is down or in a long degradation. Retries do not help until the system recovers.
Client error. The request itself is malformed, unauthorized, or references something that does not exist. Retrying produces the same failure.
Retry logic should distinguish these:
public class CalloutService {
public static HttpResponse sendWithRetry(HttpRequest req,
Integer maxRetries) {
HttpResponse res;
Integer attempt = 0;
while (attempt <= maxRetries) {
try {
res = new Http().send(req);
// Success or client error: do not retry.
if (res.getStatusCode() < 500) return res;
// 5xx server error: retry with backoff.
Integer delayMs = (Integer) Math.pow(2, attempt) * 1000;
// Note: Apex does not have sleep. Defer via Queueable instead.
// ...
} catch (CalloutException e) {
// Network-level failure. Retry.
}
attempt++;
}
return res; // Final attempt's response, even if failure.
}
}
The shape: retry on 5xx and network exceptions, do not retry on 4xx (client errors). Use exponential backoff between attempts. Cap the total attempts.
Apex does not have a synchronous sleep primitive. The "backoff" between retries is implemented by scheduling the retry as a Queueable with a delayed execution, or by accepting that retries in the same transaction are immediate.
For genuinely critical operations, the retry queue lives outside the synchronous transaction. The callout fires once. On failure, a Queueable is enqueued to retry. On its failure, a longer-delayed Queueable. After N retries, the failure goes to a dead-letter queue for manual review.
Idempotency
A retry only works if the external system can handle the same request twice. This is the idempotency property: a request that produces the same result regardless of how many times it is sent.
Some HTTP methods are idempotent by design (GET, PUT, DELETE). POST typically is not. A POST /charges that creates a new charge each time it is called is not idempotent. A retry produces double-charges.
The fix is an idempotency key. The client (Salesforce) generates a unique key per logical operation and sends it as a header. The server (external system) checks: if a request with this key already succeeded, return the prior response without re-executing.
String idempotencyKey = generateUniqueKey(salesforceRecordId, operation);
req.setHeader('Idempotency-Key', idempotencyKey);
Most modern APIs (Stripe, payment gateways, many internal services) support an idempotency key header. The Salesforce side just needs to:
- Generate a key that is stable for the same logical operation (typically: hash of the Salesforce record ID, operation type, and a timestamp bucket).
- Send the key on the initial request and all retries.
If the external system does not support idempotency keys natively, the next-best pattern is a "check before create" flow: GET the resource by external ID, only POST if it does not exist. Two callouts instead of one, but safe against duplicates.
The worst pattern is no idempotency consideration at all. A retry storm on a non-idempotent endpoint produces operational damage that takes weeks to clean up.
Mixing callouts and DML
Apex has a specific rule: you cannot make a callout after DML in the same transaction. The error is You have uncommitted work pending. Please commit or rollback before calling out.
The implication: any flow that does work, then calls out, then does more work, requires either:
- DML before the callout, no DML between callout and end of transaction.
- Move the callout to async context (@future, Queueable, Batch). The async context is a new transaction.
The async pattern is the standard fix. A trigger that needs to call an external system on insert: the trigger creates the records (DML), then enqueues a Queueable to handle the callout. The Queueable runs in its own transaction with no prior DML, callout succeeds.
trigger AccountTrigger on Account (after insert) {
Set<Id> ids = new Map<Id, Account>(Trigger.new).keySet();
System.enqueueJob(new AccountSyncQueueable(ids));
}
public class AccountSyncQueueable
implements Queueable, Database.AllowsCallouts {
private Set<Id> accountIds;
public AccountSyncQueueable(Set<Id> ids) {
this.accountIds = ids;
}
public void execute(QueueableContext qc) {
// Now in a fresh transaction. Callout is allowed.
for (Id id : accountIds) { /* ... */ }
}
}
Database.AllowsCallouts is required on the Queueable interface to make callouts.
Named Credentials over hardcoded endpoints
Apex code that hardcodes endpoint URLs and auth tokens is a security and operational risk:
// Anti-pattern.
req.setEndpoint('https://api.example.com/...');
req.setHeader('Authorization', 'Bearer ' + secretToken);
The right pattern uses Named Credentials, which store the endpoint and auth configuration in Salesforce metadata:
req.setEndpoint('callout:My_Named_Credential/path/to/resource');
// Auth header is added automatically.
Benefits:
- Auth tokens are not in code or in deployment artifacts.
- Endpoint changes (sandbox to production, vendor URL change) are configuration, not code.
- OAuth flows handled by Salesforce, not by custom code.
Named Credentials are non-negotiable for new code. Legacy hardcoded endpoints are a refactor priority on any Salesforce engagement.
Common callout mistakes
Five patterns Sapota has seen in audits:
- Default 10-second timeout on critical operations. Payment gateway hiccups for 11 seconds, timeout fires, customer wonders what happened. Set deliberate timeouts.
- No retry on transient failures. Single 503 from the external system breaks the integration for hours. Add retry logic.
- Retry without idempotency. Retries on non-idempotent POST endpoints create duplicates. Add idempotency keys.
- Hardcoded endpoints and tokens. Secret in code, deployment in version control. Move to Named Credentials.
- Synchronous callouts in trigger context. Callout fires inside the trigger transaction, blocks DML, errors out. Move to async.
What good callout code looks like
A Salesforce org with healthy external integrations:
- Every Apex callout has a deliberate, documented timeout.
- Critical operations have retry logic with bounded attempts.
- POST and DELETE operations use idempotency keys.
- Endpoints and auth via Named Credentials, not hardcoded.
- Callouts in async context (Queueable with
Database.AllowsCallouts) when the calling transaction does DML.
- Tests use HttpCalloutMock to simulate responses, including failure cases.
Sapota's Salesforce team holds the Platform Developer I credential and audits callout code for these properties on every engagement that includes external integrations. The patterns are well-understood; the discipline to apply them consistently is what keeps integrations working when external systems have a bad afternoon.
Building or auditing Apex callout integrations? Sapota's Salesforce team handles external integration design, retry logic, and idempotency patterns on production engagements. Get in touch ->
See our full platform services for the stack we cover.