Introduction to Scheduled Callbacks
Scheduled callbacks are a new feature that is under development and is a part of FLIP 330. Currently, they only work in the emulator. The specific implementation may change as a part of the development process.
These tutorials will be updated, but you may need to refactor your code if the implementation changes.
Flow, EVM, and other blockchains are a form of a single shared computer that anyone can use and no one has admin privileges, super user roles, or complete control. For this to work, one of the requirements is that it needs to be impossible for any user to freeze the computer, on purpose or by accident.
As a result, most blockchain computers, including EVM and Solana, are not Turing Complete, because they can't run an unbounded loop. Each transaction must take place within one block, and cannot consume more gas than the limit.
While this limitation prevents infinite loops, it makes it so that you can't do anything 100% onchain if you need it to happen at a later time or after a trigger. As a result, developers must often build products that involve a fair amount of traditional infrastructure and requires users to give those developers a great amount of trust that their backend will execute the promised task.
Flow fixes this problem with scheduled callbacks. Scheduled Callbacks let smart contracts execute code at (or after) a chosen time without an external transaction. You schedule work now; the network executes it later. This enables recurring jobs, deferred actions, and autonomous workflows.
Learning Objectives
After completing this tutorial, you will be able to:
- Understand the concept of scheduled callbacks and how they solve blockchain limitations
 - Explain the key components of the FlowCallbackScheduler system
 - Implement a basic scheduled callback using the provided scaffold
 - Analyze the structure and flow of scheduled callback transactions
 - Create custom scheduled callback contracts and handlers
 - Evaluate the benefits and use cases of scheduled callbacks in DeFi applications
 
Prerequisites
Cadence Programming Language
This tutorial assumes you have a modest knowledge of Cadence. If you don't, you'll be able to follow along, but you'll get more out of it if you complete our series of Cadence tutorials. Most developers find it more pleasant than other blockchain languages and it's not hard to pick up.
Getting Started
Begin by creating a new repo using the Scheduled Callbacks Scaffold as a template.
This repository has a robust quickstart in the readme. Complete that first. It doesn't seem like much at first. The counter was at 0, you ran a transaction, now it's at 1. What's the big deal?
Let's try again to make it clearer what's happening. Open cadence/transactions/ScheduleIncrementIn.cdc and look at the arguments for the transaction:
_10transaction(_10    delaySeconds: UFix64,_10    priority: UInt8,_10    executionEffort: UInt64,_10    callbackData: AnyStruct?_10)
The first parameter is the delay in seconds for the callback. Let's try running it again. You'll need to be quick on the keyboard, so feel free to use a higher number of delaySeconds if you need to. You're going to:
- Call the script to view the counter
 - Call the transaction to schedule the counter to increment after 10 seconds
 - Call the script to view the counter again and verify that it hasn't changed yet
 - Wait 10 seconds, call it again, and confirm the counter incremented
 
For your convenience, the updated transaction call is:
_10flow transactions send cadence/transactions/ScheduleIncrementIn.cdc \_10  --network emulator --signer emulator-account \_10  --args-json '[_10    {"type":"UFix64","value":"20.0"},_10    {"type":"UInt8","value":"1"},_10    {"type":"UInt64","value":"1000"},_10    {"type":"Optional","value":null}_10  ]'
And the call to run the script to get the count is:
_10flow scripts execute cadence/scripts/GetCounter.cdc --network emulator
The result in your terminal should be similar to:
_37briandoyle@Mac scheduled-callbacks-scaffold % flow scripts execute cadence/scripts/GetCounter.cdc --network emulator_37_37Result: 2_37_37briandoyle@Mac scheduled-callbacks-scaffold % flow transactions send cadence/transactions/ScheduleIncrementIn.cdc \_37  --network emulator --signer emulator-account \_37  --args-json '[_37    {"type":"UFix64","value":"10.0"},_37    {"type":"UInt8","value":"1"},_37    {"type":"UInt64","value":"1000"},_37    {"type":"Optional","value":null}_37  ]'_37Transaction ID: 61cc304cee26ad1311cc1b0bbcde23bf2b3a399485c2b6b8ab621e429abce976_37Waiting for transaction to be sealed...⠹_37_37Block ID        6b9f5138901cd0d299adea28e96d44a6d8b131ef58a9a14a072a0318da0ad16b_37Block Height    671_37Status          ✅ SEALED_37ID              61cc304cee26ad1311cc1b0bbcde23bf2b3a399485c2b6b8ab621e429abce976_37Payer           f8d6e0586b0a20c7_37Authorizers     [f8d6e0586b0a20c7]_37_37# Output omitted for brevity_37_37briandoyle@Mac scheduled-callbacks-scaffold % flow scripts execute cadence/scripts/GetCounter.cdc --network emulator_37_37Result: 2_37_37_37briandoyle@Mac scheduled-callbacks-scaffold % flow scripts execute cadence/scripts/GetCounter.cdc --network emulator_37_37Result: 2_37_37_37briandoyle@Mac scheduled-callbacks-scaffold % flow scripts execute cadence/scripts/GetCounter.cdc --network emulator_37_37Result: 3
Review of the Existing Contract and Transactions
If you're not familiar with it, review cadence/contracts/Counter.cdc. This is the standard contract created by default when you run flow init. It's very simple, with a counter and public functions to increment or decrement it.
Callback Handler
Next, open cadence/contracts/CounterCallbackHandler.cdc
_19import "FlowCallbackScheduler"_19import "Counter"_19_19access(all) contract CounterCallbackHandler {_19_19    /// Handler resource that implements the Scheduled Callback interface_19    access(all) resource Handler: FlowCallbackScheduler.CallbackHandler {_19        access(FlowCallbackScheduler.Execute) fun executeCallback(id: UInt64, data: AnyStruct?) {_19            Counter.increment()_19            let newCount = Counter.getCount()_19            log("Callback executed (id: ".concat(id.toString()).concat(") newCount: ").concat(newCount.toString()))_19        }_19    }_19_19    /// Factory for the handler resource_19    access(all) fun createHandler(): @Handler {_19        return <- create Handler()_19    }_19}
This contract is simple. It contains a resource that has a function with the FlowCallbackScheduler.Execute entitlement. This function contains the code that will be called by the callback. It:
- Calls the 
incrementfunction in theCountercontract - Fetches the current value in the counter
 - Logs that value to the console for the emulator
 
It also contains a function, createHandler, which creates and returns an instance of the Handler resource.
Initializing the Callback Handler
Next, take a look at cadence/transactions/InitCounterCallbackHandler.cdc:
_16import "CounterCallbackHandler"_16import "FlowCallbackScheduler"_16_16transaction() {_16    prepare(signer: auth(Storage, Capabilities) &Account) {_16        // Save a handler resource to storage if not already present_16        if signer.storage.borrow<&AnyResource>(from: /storage/CounterCallbackHandler) == nil {_16            let handler <- CounterCallbackHandler.createHandler()_16            signer.storage.save(<-handler, to: /storage/CounterCallbackHandler)_16        }_16_16        // Validation/example that we can create an issue a handler capability with correct entitlement for FlowCallbackScheduler_16        let _ = signer.capabilities.storage_16            .issue<auth(FlowCallbackScheduler.Execute) &{FlowCallbackScheduler.CallbackHandler}>(/storage/CounterCallbackHandler)_16    }_16}
This transaction saves an instance of the Handler resource to the user's storage. It also tests out/demonstrates how to issue the handler [capability] with the FlowCallbackScheduler.Execute entitlement. The use of the name _ is convention to name a variable we don't intend to use for anything.
Scheduling the Callback
Finally, open cadence/transactions/ScheduleIncrementIn.cdc again. This is the most complicated transaction, so we'll break it down. The final call other than the log is what actually schedules the callback:
_10let receipt = FlowCallbackScheduler.schedule(_10    callback: handlerCap,_10    data: callbackData,_10    timestamp: future,_10    priority: pr,_10    executionEffort: executionEffort,_10    fees: <-fees_10)
It calls the schedule function from the FlowCallbackScheduler contract. This function has parameters for:
callback: The handler [capability] for the code that should be executed.
This is created above as a part of the transaction with:
_10let handlerCap = signer.capabilities.storage_10            .issue<auth(FlowCallbackScheduler.Execute) &{FlowCallbackScheduler.CallbackHandler}>(/storage/CounterCallbackHandler)
That line is creating a capability that allows something with the FlowCallbackScheduler.Execute entitlement to call the function (executeCallback()) from the Handler resource in CounterCallbackHandler.cdc that you created and stored an instance of in the InitCounterCallbackHandler transaction.
data: The arguments required by the callback function.
In this example, callBackData is passed in as a prop on the transaction and is null.
timestamp: The timestamp for the time in thefuturethat this callback should be run.
The transaction call has an argument for delaySeconds, which is then converted to a future timestamp:
_10let future = getCurrentBlock().timestamp + delaySeconds
priority: The priority this transaction will be given in the event of network congestion. A higher priority means a higher fee for higher precedence.
The priority argument is supplied in the transaction as a UInt8 for convenience, then converted into the appropriate enum type:
_10let pr = priority == 0_10    ? FlowCallbackScheduler.Priority.High_10    : priority == 1_10        ? FlowCallbackScheduler.Priority.Medium_10        : FlowCallbackScheduler.Priority.Low
The executionEffort is also supplied as an argument in the transaction. It's used to prepare the estimate for the gas fees that must be paid for the callback, and directly in the call to schedule() the callback.
fees: A vault containing the appropriate amount of gas fees needed to pay for the execution of the scheduled callback.
To create the vault, the estimate() function is first used to calculate the amount needed:
_10let est = FlowCallbackScheduler.estimate(_10    data: callbackData,_10    timestamp: future,_10    priority: pr,_10    executionEffort: executionEffort_10)
Then, an authorized reference to the signer's vault is created and used to withdraw() the needed funds and move them into the fees variable which is then sent in the schedule() function call.
Finally, we also assert that some minimums are met to ensure the callback will be called:
_10assert(_10    est.timestamp != nil || pr == FlowCallbackScheduler.Priority.Low,_10    message: est.error ?? "estimation failed"_10)
Writing a New Scheduled Callback
With this knowledge, we can create our own scheduled callback. For this demo, we'll simply display a hello from an old friend in the emulator's console logs.
Creating the Contracts
Start by using the Flow CLI to create a new contract called RickRoll.cdc and one called RickRollCallbackHandler.cdc:
_10flow generate contract RickRoll_10flow generate contract RickRollCallbackHandler
Open the RickRoll contract, and add functions to log a fun message to the emulator console, and a variable to track which message to call:
_29access(all)_29contract RickRoll {_29_29    access(all) var messageNumber: UInt8_29_29    init() {_29        self.messageNumber = 0_29    }_29_29    // Reminder: Anyone can call these functions!_29    access(all) fun message1() {_29        log("Never gonna give you up")_29        self.messageNumber = 1_29    }_29_29    access(all) fun message2() {_29        log("Never gonna let you down")_29        self.messageNumber = 2_29    }_29_29    access(all) fun message3() {_29        log("Never gonna run around and desert you")_29        self.messageNumber = 3_29    }_29_29    access(all) fun resetMessageNumber() {_29        self.messageNumber = 0_29    }_29}
Next, open RickRollCallbackHandler.cdc. Start by importing the RickRoll contract, FlowToken, FungibleToken, and FlowCallbackScheduler, and stubbing out the Handler and factory:
_17import "FlowCallbackScheduler"_17import "RickRoll"_17import "FlowToken"_17import "FungibleToken"_17_17access(all)_17contract RickRollCallbackHandler {_17    /// Handler resource that implements the Scheduled Callback interface_17    access(all) resource Handler: FlowCallbackScheduler.CallbackHandler {_17        // TODO_17    }_17_17    /// Factory for the handler resource_17    access(all) fun createHandler(): @Handler {_17        return <- create Handler()_17    }_17}
Next, add a switch to call the appropriate function based on what the current messageNumber is:
_14access(FlowCallbackScheduler.Execute) fun executeCallback(id: UInt64, data: AnyStruct?) {_14    switch (RickRoll.messageNumber) {_14        case 0:_14            RickRoll.message1()_14        case 1:_14            RickRoll.message2()_14        case 2:_14            RickRoll.message3()_14        case 3:_14            return_14        default:_14            panic("Invalid message number")_14    }_14}
We could move forward with this, but it would be more fun to have each callback schedule the follow callback to share the next message. You can do this by moving most of the code found in the callback transaction to the handler. Start with configuring the delay, future, priority, and executionEffort. We'll hardcode these for simplicity:
_10var delay: UFix64 = 5.0_10let future = getCurrentBlock().timestamp + delay_10let priority = FlowCallbackScheduler.Priority.Medium_10let executionEffort: UInt64 = 1000
Next, create the estimate and assert to validate minimums are met, and that the Handler exists:
_17let estimate = FlowCallbackScheduler.estimate(_17    data: data,_17    timestamp: future,_17    priority: priority,_17    executionEffort: executionEffort_17)_17_17assert(_17    estimate.timestamp != nil || priority == FlowCallbackScheduler.Priority.Low,_17    message: estimate.error ?? "estimation failed"_17)_17_17 // Ensure a handler resource exists in the contract account storage_17if RickRollCallbackHandler.account.storage.borrow<&AnyResource>(from: /storage/RickRollCallbackHandler) == nil {_17    let handler <- RickRollCallbackHandler.createHandler()_17    RickRollCallbackHandler.account.storage.save(<-handler, to: /storage/RickRollCallbackHandler)_17}
Then withdraw the necessary funds:
_10let vaultRef = CounterLoopCallbackHandler.account.storage_10    .borrow<auth(FungibleToken.Withdraw) &FlowToken.Vault>(from: /storage/flowTokenVault)_10    ?? panic("missing FlowToken vault on contract account")_10let fees <- vaultRef.withdraw(amount: estimate.flowFee ?? 0.0) as! @FlowToken.Vault
Finally, issue a capability, and schedule the callback:
_12// Issue a capability to the handler stored in this contract account_12let handlerCap = RickRollCallbackHandler.account.capabilities.storage_12    .issue<auth(FlowCallbackScheduler.Execute) &{FlowCallbackScheduler.CallbackHandler}>(/storage/RickRollCallbackHandler)_12_12let receipt = FlowCallbackScheduler.schedule(_12    callback: handlerCap,_12    data: data,_12    timestamp: future,_12    priority: priority,_12    executionEffort: executionEffort,_12    fees: <-fees_12)
Setting Up the Transactions
Next, you need to add transactions to initialize the new callback, and another to fire off the sequence.
Start by adding InitRickRollHandler.cdc:
_10flow generate transaction InitRickRollHandler
The contract itself is nearly identical to the one we reviewed:
_16import "RickRollCallbackHandler"_16import "FlowCallbackScheduler"_16_16transaction() {_16    prepare(signer: auth(Storage, Capabilities) &Account) {_16        // Save a handler resource to storage if not already present_16        if signer.storage.borrow<&AnyResource>(from: /storage/RickRollCallbackHandler) == nil {_16            let handler <- RickRollCallbackHandler.createHandler()_16            signer.storage.save(<-handler, to: /storage/RickRollCallbackHandler)_16        }_16_16        // Validation/example that we can create an issue a handler capability with correct entitlement for FlowCallbackScheduler_16        let _ = signer.capabilities.storage_16            .issue<auth(FlowCallbackScheduler.Execute) &{FlowCallbackScheduler.CallbackHandler}>(/storage/CounterCallbackHandler)_16    }_16}
Next, add ScheduleRickRoll:
_10flow generate transaction ScheduleRickRoll
This transaction is essentially identical as well, it just uses the handlerCap stored in RickRollCallback:
_52import "FlowCallbackScheduler"_52import "FlowToken"_52import "FungibleToken"_52_52/// Schedule an increment of the Counter with a relative delay in seconds_52transaction(_52    delaySeconds: UFix64,_52    priority: UInt8,_52    executionEffort: UInt64,_52    callbackData: AnyStruct?_52) {_52    prepare(signer: auth(Storage, Capabilities) &Account) {_52        let future = getCurrentBlock().timestamp + delaySeconds_52_52        let pr = priority == 0_52            ? FlowCallbackScheduler.Priority.High_52            : priority == 1_52                ? FlowCallbackScheduler.Priority.Medium_52                : FlowCallbackScheduler.Priority.Low_52_52        let est = FlowCallbackScheduler.estimate(_52            data: callbackData,_52            timestamp: future,_52            priority: pr,_52            executionEffort: executionEffort_52        )_52_52        assert(_52            est.timestamp != nil || pr == FlowCallbackScheduler.Priority.Low,_52            message: est.error ?? "estimation failed"_52        )_52_52        let vaultRef = signer.storage_52            .borrow<auth(FungibleToken.Withdraw) &FlowToken.Vault>(from: /storage/flowTokenVault)_52            ?? panic("missing FlowToken vault")_52        let fees <- vaultRef.withdraw(amount: est.flowFee ?? 0.0) as! @FlowToken.Vault_52_52        let handlerCap = signer.capabilities.storage_52            .issue<auth(FlowCallbackScheduler.Execute) &{FlowCallbackScheduler.CallbackHandler}>(/storage/RickRollCallbackHandler)_52_52        let receipt = FlowCallbackScheduler.schedule(_52            callback: handlerCap,_52            data: callbackData,_52            timestamp: future,_52            priority: pr,_52            executionEffort: executionEffort,_52            fees: <-fees_52        )_52_52        log("Scheduled callback id: ".concat(receipt.id.toString()).concat(" at ").concat(receipt.timestamp.toString()))_52    }_52}
Deployment and Testing
It's now time to deploy and test the new scheduled callback!: First, add the new contracts to the emulator account in flow.json (other contracts may be present):
_10"deployments": {_10    "emulator": {_10        "emulator-account": [_10            "RickRoll",_10            "RickRollCallbackHandler"_10        ]_10    }_10}
Then, deploy the contracts to the emulator:
_10flow project deploy --network emulator
And execute the transaction to initialize the new scheduled callback:
_10flow transactions send cadence/transactions/InitRickRollHandler.cdc \_10  --network emulator --signer emulator-account
Finally, get ready to quickly switch to the emulator console and call the transaction to schedule the callback!:
_10flow transactions send cadence/transactions/ScheduleRickRoll.cdc \_10  --network emulator --signer emulator-account \_10  --args-json '[_10    {"type":"UFix64","value":"2.0"},_10    {"type":"UInt8","value":"1"},_10    {"type":"UInt64","value":"1000"},_10    {"type":"Optional","value":null}_10  ]'
In the logs, you'll see similar to:
_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "Scheduled callback id: 4 at 1755099632.00000000"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.execute_callback] executing callback 4"_2611:40AM INF LOG: "Never gonna give you up"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.execute_callback] executing callback 5"_2611:40AM INF LOG: "Never gonna let you down"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.execute_callback] executing callback 6"_2611:40AM INF LOG: "Never gonna run around and desert you"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"_2611:40AM INF LOG: "[system.process_callbacks] processing callbacks"
The last case returns the function, so it doesn't set a new scheduled callback.
Conclusion
In this tutorial, you learned about scheduled callbacks, a powerful feature that enables smart contracts to execute code at future times without external transactions. You explored how scheduled callbacks solve the fundamental limitation of blockchain computers being unable to run unbounded loops or execute time-delayed operations.
Now that you have completed this tutorial, you should be able to:
- Understand the concept of scheduled callbacks and how they solve blockchain limitations
 - Explain the key components of the FlowCallbackScheduler system
 - Implement a basic scheduled callback using the provided scaffold
 - Analyze the structure and flow of scheduled callback transactions
 - Create custom scheduled callback contracts and handlers
 - Evaluate the benefits and use cases of scheduled callbacks in DeFi applications
 
Scheduled callbacks open up new possibilities for DeFi applications, enabling recurring jobs, deferred actions, and autonomous workflows that were previously impossible on blockchain. This feature represents a significant step forward in making blockchain more practical for real-world applications that require time-based execution.