Learn how to send USDC to multiple recipients cross-chains using atomic batch transactions with CCTP, Circle Wallets, and Thirdweb Engine.

Paying people across countries, banks, or currencies can be difficult. You have to deal with delayed settlements, currency conversion fees, and inconsistent infrastructure. Whether you’re running payroll, distributing cashbacks, or funding rewards, it often means navigating different payment systems, juggling bank integrations, and absorbing high operational costs.
Even in crypto, sending funds to multiple recipients, especially across different chains, can be slow and expensive. Each transfer is its own transaction, which means more fees, more signatures, and more room for things to go wrong. The more recipients you add, the more tedious and error-prone the process becomes.
But what if you could handle all of that in a single transaction?
Imagine making payouts to multiple people, even across chains, in one atomic call, where everything executes together or not at all. No partial transfers. No duplicated steps. No room for manual errors.
That’s what atomic batch transactions enable.
With Circle CCTP (Cross-Chain Transfer Protocol), Circle Wallets and Thirdweb Engine, you can build a programmable payout flow that works across chains, using USDC as the settlement layer.
In this blog, you’ll learn how atomic batch transactions, CCTP, and Thirdweb Engine come together to enable programmable cross-chain USDC payouts.
If you want to see the full technical walkthrough, check out the video and try it yourself with the Thirdweb Engine Template and the Atomic Batch Sample Application, both available on Replit (but must run locally due to Docker requirements)
Thirdweb Engine as the Backend
To power this flow, we use Thirdweb Engine as the backend server. It provides a set of backend APIs that let you create Circle Wallets, submit atomic batch transactions, poll transaction status, and more.
Once the Engine is running, it interacts with the frontend to manage the full USDC transfer flow.
Creating Wallets like Bank Accounts
In this app, each Circle Wallet functions like a dedicated bank account, one for the sender, and one for each recipient. You can programmatically create and manage these wallets through the backend. When the frontend needs to create a wallet, it sends a request to your Thirdweb Engine.
Here’s what that looks like:
// From the frontend
const response = await axios.post<WalletResponse>('/api/wallet/create', {
type: 'smart:circle',
credentialId: process.env.NEXT_PUBLIC_THIRDWEB_CREDENTIAL_ID,
label,
isTestnet: 'true',
});
This routes through your backend API:
// From the backend
const response = await fetch(`${BACKEND_URL}/backend-wallet/create`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
Once created, these wallets can sign and send transactions, including the atomic batch calls used to move USDC across chains.
Approving and Burning USDC in a Single Atomic Transaction
Once the sender and recipient wallets are set up, the first step in transferring USDC cross-chain is to approve the transfer and then burn the USDC on the source chain. This is the first part of the CCTP flow.
Normally, this would involve multiple transactions:
- One approve() call
- And one depositForBurn() call for each recipient
But here, we use an atomic batch transaction to handle it all at once.
If any one of those calls fails, nothing goes through. It’s all-or-nothing execution, submitted and signed from the sender’s Circle Wallet via Thirdweb Engine.
On the frontend, you first encode the contract calls into a single batch:
const approveInterface = new Interface([
'function approve(address spender, uint256 amount)'
]);
const depositForBurnInterface = new Interface([
'function depositForBurn(uint256 amount, uint32 destinationDomain, bytes32 mintRecipient, address burnToken, bytes32 destinationCaller, uint256 maxFee, uint32 deadline)'
]);
// Helper: Encode approve() call
function encodeApprove(tokenAddress: string, spender: string, amount: bigint | string) {
return {
toAddress: tokenAddress,
data: approveInterface.encodeFunctionData('approve', [spender, amount.toString()]),
value: "0"
};
}
// Helper: Encode depositForBurn() call
function encodeDepositForBurn({
recipientAddress,
recipientChain,
amount,
sourceUSDC,
tokenMessenger
}: {
recipientAddress: string,
recipientChain: string,
amount: bigint,
sourceUSDC: string,
tokenMessenger: string
}) {
const destinationConfig = getChainConfig(recipientChain);
const maxFee = amount / BigInt(5000);
const deadline = 1000;
return {
toAddress: tokenMessenger,
data: depositForBurnInterface.encodeFunctionData('depositForBurn', [
amount.toString(),
destinationConfig.domain,
pad(recipientAddress),
sourceUSDC,
"0x0000000000000000000000000000000000000000000000000000000000000000", // destinationCaller
maxFee.toString(),
deadline.toString()
]),
value: "0"
};
}
// Step 1: Encode approve transaction
const approveTransaction = encodeApprove(
sourceConfig.usdc,
sourceConfig.tokenMessenger,
totalAmount
);
// Step 2: Encode depositForBurn transactions
const burnTransactions = recipients.map(recipient =>
encodeDepositForBurn({
recipientAddress: recipient.address,
recipientChain: recipient.chain,
amount: BigInt(recipient.amount),
sourceUSDC: sourceConfig.usdc,
tokenMessenger: sourceConfig.tokenMessenger
})
);
// Step 3: Combine all transactions
const encodedTransactions = [approveTransaction, ...burnTransactions];
This batch is sent to your backend through a local API route:
await fetch('/api/wallet/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-backend-wallet-address': walletAddress
},
body: JSON.stringify({ transactions: encodedTransactions })
});
From there, the backend sends the batch to Thirdweb Engine:
await fetch('http://localhost:3005/backend-wallet/${chainId}/send-transaction-batch-atomic', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
'X-Backend-Wallet-Address': initiatingWalletAddress
},
body: JSON.stringify({ transactions })
});
At this point, you've submitted a single atomic transaction that approves and burns USDC for all recipients on the source chain.
Waiting for Circle Attestations
Once the atomic batch transaction to approve and burn USDC is submitted, the app needs to wait for attestations from Circle. This is a crucial part of the CCTP flow as this is how Circle validates that the burn occurred and prepares for minting on the destination chain.
Circle provides an API for polling the status of each burn transaction. The app keeps checking this endpoint until it receives a complete status for each recipient.
Here’s a simplified version of the polling logic:
async function waitForAttestation(sourceDomain: number, transactionHash: string): Promise<Attestation> {
const url = `https://iris-api-sandbox.circle.com/v2/messages/${sourceDomain}?transactionHash=${transactionHash}`;
for (let i = 0; i < 30; i++) {
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_CIRCLE_API_KEY}`,
'Content-Type': 'application/json',
}
});
const data = await res.json();
const message = data?.messages?.[0];
if (message?.status === 'complete') {
return {
message: message.message,
attestation: message.attestation,
};
}
await new Promise((resolve) => setTimeout(resolve, 10000)); // wait 10s
}
throw new Error('Attestation polling timed out');
}
Each burn transaction corresponds to a unique message ID, and Circle will only allow minting once the associated attestation is confirmed.
Once the app has received attestations for all recipients, it can proceed to the final step: atomic batch minting on the destination chain.
Minting USDC to Recipients on the Destination Chain
After all Circle attestations have been received, the final step is to mint USDC to each recipient on the destination chain. Just like the burn step, this is done using an atomic batch transaction, so that all mints succeed together, or none at all.
Each recipient's attestation is matched to their wallet, and the app builds a batch of receiveMessage() calls using the messageTransmitter contract.
Here’s how the frontend constructs the batch:
const messageTransmitterInterface = new Interface([
'function receiveMessage(bytes message, bytes attestation)'
]);
const receiveMessageTxs = chainRecipients.map((recipient) => {
return {
toAddress: destConfig.messageTransmitter,
data: messageTransmitterInterface.encodeFunctionData('receiveMessage', [
recipient.attestation.message,
recipient.attestation.attestation
]),
value: "0"
};
});
These are sent to the backend via a local API route:
await fetch('/api/wallet/receive', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-backend-wallet-address': sourceWallet.address
},
body: JSON.stringify({
transactions: receiveMessageTxs,
chain: destConfig.chainId.toString(),
isDestination: true
})
});
And the backend forwards this to Thirdweb Engine’s atomic batch endpoint:
await fetch(`http://localhost:3005/backend-wallet/${chainId}/send-transaction-batch-atomic`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
'X-Backend-Wallet-Address': walletAddress
},
body: JSON.stringify({ transactions })
});
This completes the flow: one atomic transaction to approve and burn, another atomic transaction to mint to multiple recipients, all orchestrated through CCTP, powered by Thirdweb Engine and Circle Wallets.
Wrapping Up
What used to be a complex, multi-step process — approvals, burns, attestations, and mints — can now be handled with just two atomic transactions. Whether you're managing global payroll, cashback distributions, or any kind of multi-recipient payout, you now have a programmable way to move money reliably and efficiently across chains.
Try It Yourself
Get started with Circle’s Developer Services by creating a Developer Account and check out the special offer below.
We’re excited to offer $100 in credits towards Circle's developer services - sign up today to automatically receive yours. That’s enough for about one month of 2,000 active wallets, 200,000 Smart Contract Platform API calls, or $95 in sponsored network fees
- Fork the Templates and run locally : Thirdweb Engine Template and Atomic Batch Sample Application
- Check out our CCTP docs to get started today!
- Join the Community : Circle Developer Discord