Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

In one sentence: you have an HTTP callout inside a loop, and Salesforce allows 100 per transaction. Replace the per-record calls with one request carrying a batch payload if the remote API supports it. If it doesn’t, move the work into Batch Apex with a scope size below 100, or into chained Queueable jobs. Before you plan around 100 callouts, check the other ceiling: the cumulative timeout across all callouts in a transaction is 120 seconds, so 100 calls that each take two seconds will time out long before you reach the count limit.

Two limits govern callouts, and the timeout usually binds first

Limit Value
Callouts (HTTP requests or web service calls) per transaction 100
Maximum cumulative timeout for all callouts in a transaction 120 seconds
Default timeout for a single callout 10 seconds
Configurable timeout range for a single callout 1 ms to 120,000 ms

The 120 seconds is additive across every callout in the transaction, so your realistic budget is 100 calls only if each returns in about a second. At the 10-second default, twelve slow calls exhaust the transaction. Any design that assumes “I have 100 callouts to spend” is really assuming the remote system is fast, which is exactly the assumption that fails at month end.

Unlike SOQL queries and heap, this limit does not increase asynchronously. It is 100 in a trigger, in a future method, in a queueable, and in each execute of a batch job.

Cause 1: the callout is inside the loop

Nearly every occurrence of this exception is this shape.

// One request per record. Fails at 101, or times out well before that.
for (Account a : accounts) {
    HttpRequest req = new HttpRequest();
    req.setEndpoint('callout:Billing_API/customers/' + a.External_Id__c);
    req.setMethod('PATCH');
    req.setBody(JSON.serialize(new Map<String, Object>{ 'name' => a.Name }));
    new Http().send(req);
}

If the remote API accepts a collection, send one request:

List<Map<String, Object>> payload = new List<Map<String, Object>>();
for (Account a : accounts) {
    payload.add(new Map<String, Object>{
        'externalId' => a.External_Id__c,
        'name' => a.Name
    });
}

HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Billing_API/customers/batch');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(payload));
req.setTimeout(60000);
HttpResponse res = new Http().send(req);

This is the fix worth fighting for. One callout scales to any batch size, spends 60 seconds of the 120-second budget in the worst case, and gives you a single response to handle. If the vendor’s API has no bulk endpoint, asking for one is a legitimate integration requirement rather than a nice-to-have — every workaround below is more complex and less reliable than the request you are trying to avoid making.

Note the explicit setTimeout(). A bulk request processing 200 records will not return in the default 10 seconds, and the resulting failure looks like a network problem rather than a configuration one.

Cause 2: Batch Apex with the default scope of 200

Batch Apex resets governor limits per execute, which makes it the standard answer here, but each execute is one transaction with one budget of 100 callouts. Leave the scope at its default of 200 and every chunk fails identically.

public class BillingSyncBatch implements Database.Batchable<sObject>, Database.AllowsCallouts {

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator('SELECT Id, Name, External_Id__c FROM Account WHERE Needs_Sync__c = true');
    }

    public void execute(Database.BatchableContext bc, List<Account> scope) {
        for (Account a : scope) {
            // one callout per record: scope must stay under 100
        }
    }

    public void finish(Database.BatchableContext bc) { }
}

// Size the scope to the callouts, and to the 120-second timeout budget.
Database.executeBatch(new BillingSyncBatch(), 10);

Database.AllowsCallouts is not optional — omit it and you get a different error about callouts not being supported. And size the scope from the timeout, not the count: at a one-second average response, a scope of 10 spends ten of your 120 seconds and leaves plenty of margin for a slow day. A scope of 90 is technically within the count limit and will fail the first time the remote system is under load.

The tradeoff is throughput. A scope of 10 across 50,000 records is 5,000 batch executions, which is slow and consumes Apex async capacity that the rest of your org shares. This is the cost of not having a bulk endpoint, and it is worth quantifying in front of whoever decides API priorities.

Cause 3: retry logic multiplying the count

Three attempts per record turns a comfortable 40 callouts into 120.

for (Integer attempt = 0; attempt < 3; attempt++) {
    res = new Http().send(req);
    if (res.getStatusCode() == 200) { break; }
}

Every attempt counts, including the ones that failed, and every attempt spends its timeout against the same 120 seconds. Retry the transaction, not the individual call: catch the failure, mark the record as needing another attempt, and let a scheduled job pick it up. In-transaction retries against a system that is already struggling mostly succeed at burning your budget.

Cause 4: several integrations sharing one transaction

Your code makes four callouts. So does the managed package trigger on the same object, and so does the flow that calls an HTTP action. All of it counts against one pool of 100.

Find out who is spending it before you change anything. Set the Apex Code log level to FINEST, reproduce, and read the CUMULATIVE_LIMIT_USAGE block at the end of the log — the LIMIT_USAGE_FOR_NS lines break the callout count out per namespace. If your namespace shows 4 of 100, your integration is not the problem and the conversation is about reducing what else runs on that object.

Cause 5: the callout is in a trigger at all

A callout from a trigger context after any DML throws You have uncommitted work pending rather than the callout-count exception, but the two problems have one root: synchronous integration on the save path. Even when it works, you have put a third party’s uptime inside your users’ save button.

Publish the intent and let something else do the work:

public class BillingSyncQueueable implements Queueable, Database.AllowsCallouts {
    private final List<Id> accountIds;

    public BillingSyncQueueable(List<Id> accountIds) {
        this.accountIds = accountIds;
    }

    public void execute(QueueableContext ctx) {
        // one bulk callout for this slice, then chain the next
    }
}

System.enqueueJob(new BillingSyncQueueable(new List<Id>(Trigger.newMap.keySet())));

Chaining is one child job per parent job, so you build a chain rather than a fan-out, and in Developer and Trial Edition orgs the maximum stack depth for chained jobs is 5. Design for that limit in scratch orgs or your integration will pass in production and fail in every developer environment.

Diagnose it

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

Print that before and after each integration call. Alongside it, log elapsed time — the count limit is easy to see and the cumulative timeout is not, and a transaction that dies at 60 callouts is telling you about seconds rather than about the count.

Confirm the fix

Tests cannot make real callouts, so you assert the count against a mock. This is the rare case where the mock makes the test better: respond() is called once per callout, so counting invocations proves the count directly.

@IsTest
private class BillingSyncTest {

    private class CountingMock implements HttpCalloutMock {
        Integer calls = 0;
        public HttpResponse respond(HttpRequest req) {
            calls++;
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setBody('{"status":"ok"}');
            return res;
        }
    }

    @IsTest
    static void syncUsesOneCalloutForTwoHundredRecords() {
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 200; i++) {
            accounts.add(new Account(Name = 'Bulk ' + i, External_Id__c = 'EXT-' + i));
        }
        insert accounts;

        CountingMock mock = new CountingMock();
        Test.setMock(HttpCalloutMock.class, mock);

        Test.startTest();
        BillingSyncService.sync(accounts);
        Test.stopTest();

        System.assertEquals(1, mock.calls, 'Callout count scales with record volume');
    }
}

Assert an exact number, not “fewer than 100”. An exact assertion fails the day somebody reintroduces the loop, which is the only day the test matters.

Preventing the next one

  • Bulk endpoint first. Treat “does this API accept a collection” as a requirement question during design, not a discovery during a production incident.
  • No Http.send() inside a loop body. PMD’s OperationWithLimitsInLoop rule catches this in CI.
  • Set an explicit timeout on every callout, and size batch scope from the 120-second budget rather than the count of 100.
  • Integrations run asynchronously, so a slow vendor cannot block a user’s save.
  • Assert exact callout counts with a counting mock at 200 records.

Frequently asked

Does running asynchronously give me more callouts?

No. It stays at 100 in every context. Batch Apex helps only because each execute is a separate transaction with its own 100, so the job as a whole can make far more. What you give up is immediacy and atomicity. The sync completes over minutes and can be half done.

Do failed or timed-out callouts count?

Yes. The attempt counts against the 100 and its timeout counts against the 120 seconds, whether or not you got a usable response.

Do callouts from an installed package count against my limit?

Yes. Callouts are governed per transaction and every namespace draws from the same pool, which is why your code can throw the exception while spending very little of the budget itself.

Why do I get “You have uncommitted work pending” instead of this error?

That is a different rule: you cannot make a callout after DML in the same transaction, because the pending changes hold database locks. Move the callout into a future or queueable method, or make it before the DML.

Can support raise the callout limit?

No. It is a fixed per-transaction governor limit. The cumulative 120-second timeout is fixed too, though the timeout on an individual callout is yours to set between 1 ms and 120,000 ms.


Reference: Per-Transaction Apex Limits, Callout Limits and Limitations, Queueable Apex, and Named Credentials as Callout Endpoints, Salesforce Apex Developer Guide.