Inside a Schedulable’s execute() you cannot call Http.send() directly, but you can hand the work off to another async context that does allow callouts. The three legal alternates are Queueable with Database.AllowsCallouts, @future(callout=true), and Batch Apex with Database.AllowsCallouts.
The three patterns
1. Queueable (preferred for new code)
public class SyncQueueable implements Queueable, Database.AllowsCallouts {
private final List<Id> recordIds;
public SyncQueueable(List<Id> ids) { this.recordIds = ids; }
public void execute(QueueableContext qc) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ExternalApi/sync');
req.setMethod('POST');
req.setBody(JSON.serialize(recordIds));
HttpResponse res = new Http().send(req);
// parse and persist response...
}
}
public class NightlySyncSchedule implements Schedulable {
public void execute(SchedulableContext sc) {
List<Id> ids = new List<Id>();
for (Account a : [SELECT Id FROM Account WHERE LastSyncFailed__c = true LIMIT 1000]) {
ids.add(a.Id);
}
System.enqueueJob(new SyncQueueable(ids));
}
}
Why Queueable wins: typed constructor parameters (no Id-string lists for future methods), job chaining, and the job shows up in AsyncApexJob with a real Id you can monitor.
2. @future(callout=true)
public class SyncFuture {
@future(callout=true)
public static void doSync(List<Id> ids) {
HttpResponse res = new Http().send(buildRequest(ids));
// ...
}
private static HttpRequest buildRequest(List<Id> ids) { /*...*/ return null; }
}
public class NightlySyncSchedule implements Schedulable {
public void execute(SchedulableContext sc) {
SyncFuture.doSync(new List<Id>{ /* ... */ });
}
}
Future methods take only primitive types (and lists/sets of primitives), so you can pass Ids and Strings but not full SObject lists. Once submitted they’re fire-and-forget.
3. Batch Apex
If the work is large (more than 50 records or a few hundred callouts), use Batchable:
public class SyncBatch implements Database.Batchable<sObject>, Database.AllowsCallouts {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id FROM Account WHERE LastSyncFailed__c = true');
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
// one callout per chunk
}
public void finish(Database.BatchableContext bc) { }
}
public class NightlySyncSchedule implements Schedulable {
public void execute(SchedulableContext sc) {
Database.executeBatch(new SyncBatch(), 50);
}
}
Batch is the right answer when callout volume is high — Database.executeBatch with scope = 50 means you get 1 callout per 50 records and respect the 100-callout-per-transaction limit.
Which one to pick
| Need | Use |
|---|---|
| Pass an SObject or a custom Apex type to the callout method | Queueable |
| One quick fire-and-forget callout | @future(callout=true) |
| Tens of thousands of records to sync | Batch Apex |
| Chain multiple callouts in sequence | Queueable (chain via System.enqueueJob in execute()) |
Common follow-ups
- Why does the platform allow callouts in Queueable but not Schedulable? — Queueable runs in a fresh transaction explicitly designed for async work; Schedulable executes on the cron worker where callouts could block other tenants’ scheduled jobs.
- Limit on queueables per Schedulable? — 1 Queueable can be enqueued per Schedulable
execute()(well within the synchronous 50 cap), and that Queueable can then chain.
Verified against: Apex Developer Guide — Queueable Apex and Invoking Callouts Using Apex. Last reviewed 2026-05-17 for Spring ‘26.