Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Governor limits

System.LimitException: Too many callouts: 101

A single transaction can make at most 100 HTTP callouts. Almost always caused by a callout inside a loop. Batch the calls or move them to async with proper batching.

Also seen asToo many callouts: 101·Too many callouts·System.LimitException: Too many callouts

A batch that syncs accounts to an external CRM blows up with System.LimitException: Too many callouts: 101. The batch processes 200 records per chunk, and each record triggers a separate HTTP callout to the external system. The 101st callout in the transaction is the one that fires the limit. The fix is the same shape as for SOQL-in-a-loop, but with HTTP requests.

The callout cap

Apex caps a single transaction at 100 HTTP callouts. The 101st throws LimitException: Too many callouts: 101. The cap counts every HTTP request, regardless of which method makes it. Http.send(), Database.callOut(), and any wrapper that issues an HTTP request all count together.

The cap is the same in synchronous and async contexts. Unlike SOQL (which doubles in async), callouts stay at 100 in both. The reason is operational: HTTP callouts hit external systems, and rate-limiting external systems is independent of which Salesforce context made the call.

The broken example

A trigger that syncs each updated account to an external system:

trigger AccountSyncTrigger on Account (after update) {
    for (Account a : Trigger.new) {
        Account old = Trigger.oldMap.get(a.Id);
        if (a.AnnualRevenue != old.AnnualRevenue) {
            // One HTTP callout per record. Throws on the 101st.
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:External_CRM/api/accounts/' + a.Id);
            req.setMethod('PUT');
            req.setBody(JSON.serialize(new Map<String, Object>{
                'revenue' => a.AnnualRevenue,
                'name' => a.Name
            }));
            new Http().send(req);
        }
    }
}

A batch update of 101 accounts hits the cap and throws. The synchronization is incomplete; the data drift between Salesforce and the external CRM grows.

The fix: batch the callouts

The fix depends on the external API. Two patterns work.

Pattern 1: a bulk endpoint. If the external system has a bulk endpoint that accepts many records in one request, send all records in one call:

trigger AccountSyncTrigger on Account (after update) {
    List<Map<String, Object>> payload = new List<Map<String, Object>>();
    for (Account a : Trigger.new) {
        Account old = Trigger.oldMap.get(a.Id);
        if (a.AnnualRevenue != old.AnnualRevenue) {
            payload.add(new Map<String, Object>{
                'id' => a.Id,
                'revenue' => a.AnnualRevenue,
                'name' => a.Name
            });
        }
    }
    if (!payload.isEmpty()) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:External_CRM/api/accounts/bulk-update');
        req.setMethod('POST');
        req.setBody(JSON.serialize(new Map<String, Object>{ 'records' => payload }));
        new Http().send(req);
    }
}

One callout, regardless of batch size. The external system handles the bulk update server-side.

Pattern 2: queue the callouts to async. If the external API only accepts one record per request, use a Queueable to spread the callouts across multiple transactions. Each queueable iteration gets a fresh 100-callout budget.

trigger AccountSyncTrigger on Account (after update) {
    List<Account> changed = new List<Account>();
    for (Account a : Trigger.new) {
        Account old = Trigger.oldMap.get(a.Id);
        if (a.AnnualRevenue != old.AnnualRevenue) changed.add(a);
    }
    if (!changed.isEmpty()) {
        System.enqueueJob(new AccountSyncQueueable(changed));
    }
}

public class AccountSyncQueueable implements Queueable, Database.AllowsCallouts {
    private List<Account> accounts;
    public AccountSyncQueueable(List<Account> accounts) { this.accounts = accounts; }
    public void execute(QueueableContext qc) {
        Integer toProcess = Math.min(80, accounts.size());
        for (Integer i = 0; i < toProcess; i++) {
            Account a = accounts[i];
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:External_CRM/api/accounts/' + a.Id);
            req.setMethod('PUT');
            req.setBody(JSON.serialize(new Map<String, Object>{ 'revenue' => a.AnnualRevenue }));
            new Http().send(req);
        }
        if (accounts.size() > toProcess && !Test.isRunningTest()) {
            List<Account> remaining = new List<Account>();
            for (Integer i = toProcess; i < accounts.size(); i++) remaining.add(accounts[i]);
            System.enqueueJob(new AccountSyncQueueable(remaining));
        }
    }
}

Each queueable handles 80 callouts (leaving 20 slots for retries or other callouts in the chain). When there's more work, it enqueues a next link. Long sync jobs span many queueable invocations.

The fixed example, with error handling

A production-shaped sync service that's resilient to per-record callout failures:

public class AccountSyncService {
    public static void sync(List<Account> accounts) {
        if (accounts == null || accounts.isEmpty()) return;
        System.enqueueJob(new AccountSyncQueueable(new List<Account>(accounts)));
    }
}

public class AccountSyncQueueable implements Queueable, Database.AllowsCallouts {
    private List<Account> accounts;
    private static final Integer CHUNK_SIZE = 80;
    private static final Integer MAX_RETRIES = 3;

    public AccountSyncQueueable(List<Account> accounts) { this.accounts = accounts; }

    public void execute(QueueableContext qc) {
        Integer toProcess = Math.min(CHUNK_SIZE, accounts.size());
        List<Sync_Log__c> logs = new List<Sync_Log__c>();

        for (Integer i = 0; i < toProcess; i++) {
            Account a = accounts[i];
            HttpResponse res = sendWithRetry(a);
            logs.add(new Sync_Log__c(
                Account__c = a.Id,
                Status_Code__c = res.getStatusCode(),
                Response_Body__c = res.getBody().left(2000)
            ));
        }
        if (!logs.isEmpty()) insert logs;

        List<Account> remaining = new List<Account>();
        for (Integer i = toProcess; i < accounts.size(); i++) remaining.add(accounts[i]);
        if (!remaining.isEmpty() && !Test.isRunningTest()) {
            System.enqueueJob(new AccountSyncQueueable(remaining));
        }
    }

    private HttpResponse sendWithRetry(Account a) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:External_CRM/api/accounts/' + a.Id);
        req.setMethod('PUT');
        req.setBody(JSON.serialize(new Map<String, Object>{
            'revenue' => a.AnnualRevenue,
            'name' => a.Name
        }));
        req.setTimeout(20000);

        Integer attempts = 0;
        while (attempts < MAX_RETRIES) {
            try {
                HttpResponse res = new Http().send(req);
                if (res.getStatusCode() >= 200 && res.getStatusCode() < 300) return res;
                if (res.getStatusCode() >= 400 && res.getStatusCode() < 500) return res;   // Don't retry 4xx
            } catch (CalloutException ex) {
                // Retryable network error
            }
            attempts++;
        }
        HttpResponse failed = new HttpResponse();
        failed.setStatusCode(599);
        failed.setBody('Max retries exceeded');
        return failed;
    }
}

The service includes per-record logging, retry logic for transient errors, and chunk-based progress. The sync continues across many queueable invocations until every account is processed.

Chained queueables for unbounded callouts

For very large sync jobs (millions of records), a single chain of queueables eventually hits some limit. Two architectures handle this scale:

Batch Apex with Database.AllowsCallouts. A batch's execute() method runs in its own transaction with its own callout budget. A batch with batch size 80 means each chunk does 80 callouts. The batch processes the entire data set across many chunks, each in its own governor context.

External job queue. For genuinely massive sync needs, push the work to an external job system (a Heroku worker, AWS Lambda, etc.) that consumes a queue and makes the callouts at its own pace. Salesforce posts to the queue once; the external system handles the per-record HTTP work.

The architectural answer depends on volume and reliability requirements. For small-to-medium volumes, the Queueable chain is sufficient. For high volumes, external systems are the right home.

Timeouts and the callout budget

Each callout has its own timeout (default 10 seconds, max 120 seconds). The callout budget is the number of attempts, not the duration. A timeout still counts as one callout. Setting a long timeout doesn't help with the count cap; it just lets each callout take longer.

For external systems that are slow, the trade-off is between fewer callouts per transaction (timeout high enough to succeed) and more callouts per transaction (timeout low enough to fail fast and retry).

Named Credentials simplify the wire-up

The endpoint syntax callout:External_CRM/api/accounts/... references a Named Credential, which is a centralized configuration of base URL, authentication, and security settings. Setup → Named Credentials → New defines one.

Named Credentials make callout code cleaner:

  • The base URL lives in config, not code.
  • Authentication is handled automatically (OAuth, JWT, Basic, etc.).
  • TLS and certificate trust are configured per-credential.
  • Sandboxes can point at sandbox endpoints; production at production endpoints.

For production-grade integrations, always use Named Credentials. Inline endpoint URLs in Apex are a sign of test code or quick prototypes.

A diagnostic for callout usage

Limits.getCallouts() returns the current count. Useful for verifying bulkification:

System.debug('Callouts: ' + Limits.getCallouts() + '/' + Limits.getLimitCallouts());

Drop this debug at the top and bottom of suspected hot spots. The delta tells you how many callouts the section made. Test scenarios with realistic batch sizes to confirm the count stays below 100.

Async limits beyond callouts

When chaining queueables for callout-heavy work, watch the related governors:

  • 50 chained queueables per chain (synchronous origin).
  • 50 callouts per queueable in async context (technically; the limit is per-transaction at 100, but practical Queueable chains often stay around 50-80 per link to leave room for retries).
  • 24-hour rolling cap on total Apex callouts (200,000 in most editions).

The 24-hour cap is the long-pole. For very high-volume syncing, monitor against it and adjust pacing if you approach it.

Test patterns

Apex tests can mock HTTP responses via Test.setMock(). The mock returns a predefined response without actually making a callout:

@isTest
static void sync_handlesBulkBatch() {
    List<Account> accounts = new List<Account>();
    for (Integer i = 0; i < 100; i++) {
        accounts.add(new Account(Name = 'Test ' + i, AnnualRevenue = 1000 * i));
    }
    insert accounts;

    Test.setMock(HttpCalloutMock.class, new SuccessMock());
    Test.startTest();
    AccountSyncService.sync(accounts);
    Test.stopTest();

    Integer logs = [SELECT COUNT() FROM Sync_Log__c];
    System.assert(logs > 0, 'Should produce sync logs');
}

The mock implements HttpCalloutMock.respond and returns a stubbed response. The test runs in deterministic time and never actually hits an external system.

When to consider Salesforce Platform Events instead

For sync patterns where Salesforce notifies an external system of changes, Platform Events are an alternative to outbound HTTP callouts. The Apex code publishes a Platform Event; an external listener (via Streaming API or CometD) receives it without Salesforce needing to make an HTTP call.

The trade-off:

  • Platform Events: lower friction for outbound notifications, no Salesforce-side callout cap, the external system pulls.
  • HTTP callouts: more control, immediate request/response, easier debugging.

For high-volume outbound sync (more than 100 records per transaction repeatedly), Platform Events often simplify the architecture. The Apex side publishes events; the external worker consumes them at its own pace.

A real-world refactor example

A team integrated Salesforce with a billing system via per-record HTTP PUT calls. Every record update triggered a callout. The integration worked at low volume but failed during quarterly reconciliations when 500+ records updated at once.

The fix:

  1. Replaced per-record callouts with a single bulk-update endpoint on the billing system's side.
  2. Apex now batches up to 200 records per bulk call.
  3. For sync chains larger than 200 records, a Queueable pages through 200-record chunks, each with one callout.
  4. Added retry logic with exponential backoff for transient billing-system errors.

The result: the quarterly reconciliation completed in one batch run. Per-record callouts dropped 99%. The integration became reliable enough to remove the manual monitoring step.

The interaction with synchronous transactions

If your callout is part of a synchronous user-facing save (a Lightning save handler, a UI button), the user is waiting for the response. Long callouts produce frozen UIs. Two patterns help:

  • Pre-fetch: do the callout during page load (LWC @wire to an Apex method) so the data is ready when the user clicks Save.
  • Optimistic save: save the Salesforce-side data immediately, then enqueue the external sync. The user sees instant success; the external sync happens in the background.

Both pull the callout out of the user's interactive path. The user experience improves dramatically; the callout cap is rarely an issue because the work is no longer per-save.

A subtle case: callouts before DML

A common Apex pitfall (unrelated to the count cap but related to callouts): once you've done DML in a transaction, you can't do callouts. The platform throws You have uncommitted work pending. Please commit or rollback before calling out.

The fix: do all callouts at the top of the method, before any DML. Or, push the callout to async via @future(callout=true) or Queueable with Database.AllowsCallouts.

The post-DML-callout error is not the same as the callout-count error, but they often appear in the same code paths. Knowing both helps debug integration-heavy code.

Per-callout cost analysis

Each callout has measurable cost:

  • Network latency to the external system (typically 100-500ms).
  • TLS handshake overhead (one-time per connection, but Salesforce doesn't pool connections, so it's per-callout).
  • Response parsing time.
  • Apex CPU time spent on the callout's blocking wait.

A transaction with 50 callouts at 200ms each takes 10 seconds just in network time. That's most of the synchronous transaction's 10-second budget. For high-frequency callouts, even staying under the 100-call cap might exceed CPU or wall-clock limits.

The architectural lesson: callouts are expensive even when the count is well under the cap. Design integrations to minimize the number of callouts per business operation.

Monitoring outbound integration health

A discipline that pays off: log every outbound callout to a custom Outbound_Callout_Log__c object with the endpoint, the status code, the response time, and the timestamp. Build a Lightning dashboard over the log to monitor:

  • Callout volume per endpoint over time.
  • Error rate per endpoint.
  • Average response time per endpoint.

When the integration starts misbehaving, the dashboard tells you whether the issue is on Salesforce's side (callout count creeping up), the network's side (response times climbing), or the external system's side (error rate rising). The diagnostic loop shortens from hours to minutes.

The log adds DML overhead to every callout, but the volume is usually manageable. For very high-volume integrations, summarize the log nightly into aggregated metrics and purge old detail rows.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Governor limit errors