Rebalances move funds between managed accounts on the Tesser platform. Rebalances can be same-currency as well as cross-currency (that include a swap step). Common patterns include moving funds between two ledgers at the same provider, between a provider ledger and a self-custodial wallet, or between two self-custodial wallets.
Before creating a rebalance, review the Funds Movement Lifecycle and Data Model overview to understand the shared data shape, lifecycle phases, and statuses that apply to rebalances and Tesser's other funds-movement resources.
Prerequisites
The desired.from.account_id and desired.to.account_id must each be one of your managed accounts — either a provider ledger (e.g., a Circle ledger or an OpenFX ledger) or a self-custodial wallet you have registered with Tesser. For more information on registering accounts, see Create an account.
Rebalances are first-party only — they move funds between your own managed accounts and never involve a third party. If you want to send funds to a third party, see Send a Stablecoin Payout and Create a counterparty.
For Scenario 2 in this guide, the source ledger is at OpenFX and assumes USD has already landed at that ledger via a fiat deposit. See Deposit Funds via a Liquidity Provider for the earlier part of the deposit lifecycle.
Rebalance Workflow
A rebalance executes as one or more steps. Tesser plans the route synchronously when you submit the create request, then drives each step through the shared step lifecycle.
A transfer step moves funds from one account to another. Same-currency moves between two ledgers, between a ledger and a wallet, or between two wallets are all transfer steps.
A swap step exchanges currencies inside a single account. The step's estimated.from.account_id and estimated.to.account_id are the same. Cross-currency rebalances start with a swap step at a provider ledger, then transfer the swapped balance.
For a complete description of how rebalances move through planning, balance reservation, execution, and terminal state, see the lifecycle overview's Planning, Execution, and Terminal State and Divergence sections. Step statuses are documented under Step Statuses.
Exchange Rates for Cross-Token Rebalances
When a rebalance crosses currencies, Tesser sources an indicative quote from the relevant liquidity provider and reports it via the estimated.from.amount and estimated.to.amount fields. The actual fill rate may differ slightly from the indicative quote and is reflected in actual.* once the swap step completes. See Planning for how Tesser obtains and reports quotes.
Type of liquidity provider
Example
Behavior
On/Off ramp
Alfred, OpenFX
Exchange rate between buy and sell currencies fluctuates; current rates can be guaranteed for a period of time
Exchange
Kraken
Exchange rate fluctuates; no guaranteed rate
For same-currency rebalances, estimated.* matches desired.* (1:1) and there is no swap step.
Reserving Funds from the Balance
After creation, Tesser reserves funds at the desired.from.account_id and reports the outcome via the rebalance.balance_updated webhook. A reserved outcome lets execution proceed; an awaiting_funds outcome means the desired.from.account_id is short, and Tesser will retry the reservation until expires_at. The terminal balance_reserved_at timestamp records when the reservation succeeded. See Balance Statuses.
If applicable, you should specify on which tenant's behalf you are requesting the rebalance.
For the rebalance, populate the following fields in the desired object:
desired.from.account_id: The identifier of the account funds will be moved from.
desired.from.amount: The amount of desired.from.currency to move from the source.
desired.from.currency: The currency to move from the source.
desired.from.network: The network of the funds at desired.from.account_id (only applicable when desired.from.account_id is a wallet).
desired.to.account_id: The identifier of the destination account.
desired.to.currency: The currency to land at the destination.
desired.to.network: The destination network (only applicable when the destination account is a wallet).
Note: desired.to.amount is not auto-populated. The Rebalance's desired.to.amount remains null throughout the lifecycle; the indicative target amount is supplied in estimated.to.amount once the quote is obtained.
The examples in this guide show 3 scenarios:
Same-token rebalance between two ledger accounts at Circle (USDC, no network)
Cross-token rebalance from a USD ledger at OpenFX to a self-custodial wallet on BASE (USDC)
Same-token rebalance between two self-custodial wallets (USDT on POLYGON)
Example request (rebalance USDC between two Circle ledger accounts):
In the API response, Tesser will create and return an id for the rebalance request. At creation, balance_status is unreserved and balance_reserved_at is null — both update once the balance check completes.
Example response (rebalance USDC between two Circle ledger accounts):
After your POST /v1/treasury/rebalances request is accepted, Tesser plans the route the funds will take and obtains a reference exchange rate. Tesser then sends a rebalance.quote_created webhook — the first webhook fired for the rebalance. The payload carries the planned steps[] array (each step with status: "created") together with the populated estimated overlay at both the rebalance and step levels. This webhook always fires, even for same-token same-network rebalances, to keep the lifecycle uniform across resource types and to future-proof bridging across networks.
For same-currency rebalances, the ratio of estimated.from.amount to estimated.to.amount is 1:1. For cross-currency rebalances, the ratio is the indicative exchange rate at the liquidity provider.
Example rebalance.quote_created webhook (rebalance USDC between two Circle ledger accounts):
After rebalance.quote_created fires, Tesser reserves funds at the desired.from.account_id and emits rebalance.balance_updated with the outcome. A reserved outcome means execution can proceed; an awaiting_funds outcome means the desired.from.account_id is short and Tesser will retry until expires_at. When desired.from.account_id is a self-custodial wallet (Scenario 3), the balance check is performed as part of the step-signing flow — Tesser emits step.signature_requested first, and the reservation outcome follows once the signed step is submitted. See Pre-Execution Checks for the full description.
Example rebalance.balance_updated webhook (Scenario 1, balance reserved at the source Circle ledger):
Once funds have been reserved, Tesser begins executing each step in step_sequence order. The events you observe depend on whether the step has on-chain visibility and whether it requires a wallet signature.
Scenario 1: Circle-Internal Transfer
Internal moves between two Circle ledger accounts surface only a terminal webhook from the provider, so Tesser emits a single step.completed event for the step. There are no intermediate step.submitted or step.confirmed events. The terminal step.completed is followed by a rebalance.updated event carrying the full updated Rebalance object.
Example step.completed webhook (Scenario 1, single Circle internal transfer):
For Circle-internal moves, transaction_hash is always null.
Scenario 2: Swap at OpenFX, Then On-Chain Transfer to a Wallet
Step 1 is a swap at the OpenFX ledger. Tesser submits the trade, observes its acceptance, and observes its fill in close succession, so step.submitted, step.confirmed, and step.completed arrive close together. The swap's estimated.from.account_id and estimated.to.account_id are equal — both are the OpenFX ledger UUID 2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b.
Step 2 is an on-chain transfer that moves the swapped USDC to the BASE wallet. The transfer originates from the OpenFX ledger and follows the full step lifecycle — step.submitted, step.confirmed, and step.completed — with the transaction_hash populated once Tesser observes the on-chain transaction.
Example step.completed webhook (Scenario 2, on-chain transfer to a BASE wallet):
Scenario 3: On-Chain Transfer Between Self-Custodial Wallets
When the desired.from.account_id is a self-custodial wallet, Tesser cannot submit the on-chain transaction on its own — you must locally sign the unsigned transaction with your wallet's signing key. Tesser emits step.signature_requested once the step is prepared, with the unsigned transaction available on the step DTO as unsigned_transaction (also retrievable via GET /v1/treasury/rebalances/{rebalanceId}). You sign client-side, then submit the resulting signature to POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign. Tesser validates the signed transaction targets the prepared step and broadcasts on-chain — producing step.signed, step.submitted (with transaction_hash), step.confirmed, and finally step.completed.
Example step.signature_requested webhook (Scenario 3, on-chain wallet-to-wallet transfer):
To sign the step, use the LocalSigner SDK to construct and sign the transaction locally. The signature is returned as a string, which you then submit to POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign to execute the rebalance step.
Wallet and recipient addresses are automatically resolved from the step's account IDs; you only need to pass the step object.
Code
import { LocalSigner, TesserApi } from "@tesser-payments/sdk";// Initialize API clientconst client = new TesserApi({ // Get a valid token following the authentication process // Additional details: https://docs.tesser.xyz/overviews/authentication#generate-an-api-token token,});// Initialize the signer (once at application startup)const signer = new LocalSigner(client, { publicKey: process.env.SIGNING_PUBLIC_KEY, privateKey: process.env.SIGNING_PRIVATE_KEY, enclaveId: process.env.SIGNING_ENCLAVE_ID,});// 1. Extract the step from a step.signature_requested webhook eventconst step = webhookPayload.data.object;// 2. Sign the stepconst result = await signer.signStep(step);// 3. Submit the signature to execute the stepawait fetch( `https://api.tesser.xyz/v1/treasury/rebalances/${step.transfer_id}/steps/${step.id}/sign`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ signature: result.signature }), });
The signStep method returns:
signature: send this to the rebalance step sign API to execute the step
unsignedTransaction: the serialized unsigned transaction (matches unsigned_transaction on the step DTO)
metadata: full signature details (stampHeaderName, stampHeaderValue, body)
Tesser then broadcasts the signed transaction. After broadcast, you will receive step.signed, step.submitted (with transaction_hash populated), step.confirmed, and step.completed.
Example step.completed webhook (Scenario 3, on-chain wallet-to-wallet transfer):
When the last step reaches a terminal state, Tesser populates the top-level actual.* overlay and emits a rebalance.updated webhook carrying the full updated Rebalance object. You can also retrieve the rebalance at any time via GET /v1/treasury/rebalances/{rebalanceId} — the response payload below matches the body of the terminal rebalance.updated webhook for each scenario.
desired.to.amount remains null even after the rebalance completes; the indicative target is in estimated.to.amount and the realized amount is in actual.to.amount.
Example GET /v1/treasury/rebalances/{rebalanceId} response (Scenario 1, complete):
Below are the webhook events you will observe for each of the three rebalance scenarios in this guide.
Scenario 1 — Same-token rebalance between two Circle ledger accounts
#
What happens
What you receive / do
Fields populated
1
You submit the rebalance request
HTTP response with id; balance_status: "unreserved"
desired.from.amount
2
Tesser plans the route and obtains a quote
rebalance.quote_created webhook fires with 1 step and the populated estimated overlay
Steps array; estimated.from.amount and estimated.to.amount at rebalance and step level (1:1)
3
Tesser reserves funds at the desired.from.account_id
rebalance.balance_updated webhook with balance_status: "reserved"
balance_status, balance_reserved_at
4
Circle moves the USDC between ledger accounts
A terminal step.completed event fires for the single step, followed by a rebalance.updated event carrying the full Rebalance with actual.* populated. Tesser observes Circle's single terminal webhook, so no intermediate step.submitted or step.confirmed events are emitted.
Scenario 3 — Same-token rebalance between two self-custodial wallets
#
What happens
What you receive / do
Fields populated
1
You submit the rebalance request
HTTP response with id; balance_status: "unreserved"
desired.from.amount
2
Tesser plans the route and obtains a quote
rebalance.quote_created webhook fires with 1 step and the populated estimated overlay
Steps array; estimated.from.amount and estimated.to.amount at rebalance and step level (1:1)
3
Tesser asks you to sign the on-chain transfer
step.signature_requested on the step
Step 1: status updates; signing payload supplied via the API
4
You submit the signed transaction
POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign with { "signature": "0x..." }; step.signed fires; rebalance.balance_updated with balance_status: "reserved" once the signed step is accepted
The on-chain transaction is visible on the network
step.confirmed on the step
Step 1: confirmed_at
7
The transfer reaches finality; rebalance is complete
step.completed on the step, followed by rebalance.updated with the terminal Rebalance object
Step 1: completed_at, actual.from.amount, actual.to.amount. Rebalance-level actual.from.amount and actual.to.amount.
Failure Modes for Rebalances
If a rebalance fails, the actual.* fields on the top-level of the rebalance represent the final account the funds are in, the final amount and currency in the final account, and on which blockchain network the funds are held (if applicable). For the steps that did successfully complete (if any), the actual.* fields are also populated. The actual.to.* fields of the last successful step match the actual.to.* fields at the top level of the rebalance. Inspect the status_reasons of the step where the failure occurred for more information about the cause of the failure. Any steps subsequent to the failure step also have status = failed, with null actual.* fields.
Circle-Internal Transfer Rejected (Scenario 1)
If Circle rejects the internal move between your two Circle ledger accounts, the single transfer step transitions directly to failed (no intermediate submitted or confirmed lifecycle is observed for Circle-internal moves). Funds remain at the source ledger account.
What you will observe:
step.failed on the single Circle-internal step. actual.* is null because no funds moved.
The rebalance's top-level actual.* is null for the same reason. desired.* is preserved.
A rebalance.updated webhook fires alongside the terminal step.failed, carrying the full updated Rebalance object with the populated terminal state.
Example step.failed webhook (Scenario 1, Circle-internal step rejected):
If Tesser attempts the swap at the OpenFX ledger and cannot get the trade to succeed before the rebalance's expires_at timestamp, the rebalance terminates with USD still at the OpenFX ledger. The conversion into USDC did not happen, and the subsequent on-chain transfer step is never submitted.
What you will observe:
step.failed on Step 1 (swap). actual.from is populated (USD entered the swap), but actual.to is null because no USDC was obtained.
step.failed on Step 2 (wallet transfer). This step never entered submitted, because there were no USDC to transfer.
The rebalance's top-level actual.from is populated (USD at the source OpenFX ledger). actual.to resolves to USD at the same OpenFX ledger account, with the unconverted amount. desired.to stays as USDC/BASE at the wallet (client intent is never overwritten).
A rebalance.updated webhook fires alongside the terminal step.failed events, carrying the full updated Rebalance object with the populated actual.* overlay.
Example step.failed webhook on Step 1 (swap, never filled):
If the swap at the OpenFX ledger succeeds but the on-chain transfer to the BASE wallet fails (e.g., the transaction reverts on-chain or cannot be confirmed before expires_at), the rebalance terminates with USDC sitting at the OpenFX ledger. The swap step is completed with the actual fill amount; the transfer step is failed.
What you will observe:
step.completed on Step 1 (swap). actual.to.amount reflects the realized fill (999.475 USDC).
step.failed on Step 2 (wallet transfer). actual.* is null because no on-chain transfer was confirmed.
The rebalance's top-level actual.from reflects the source debit (1000 USD). actual.to resolves to USDC at the OpenFX ledger account 2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b with amount 999.475 — the swapped balance available to retry the on-chain leg via a new rebalance.
A rebalance.updated webhook fires alongside the terminal step.failed event.
Example step.failed webhook on Step 2 (wallet transfer, post-swap failure):
If the desired.from.account_id does not have enough balance to cover desired.from.amount, the balance check returns awaiting_funds instead of reserved. Tesser fires rebalance.balance_updated with balance_status: "awaiting_funds" and queues the rebalance, retrying the reservation as the balance at desired.from.account_id changes (e.g., as a deposit lands or another rebalance frees funds). If the reservation does not succeed before expires_at, the rebalance times out: every step transitions to failed, step.failed events fire for each, and a final rebalance.updated webhook reports the terminal state with actual.* null because no funds moved.
What you will observe:
rebalance.balance_updated with balance_status: "awaiting_funds" shortly after creation.
(Optional) further rebalance.balance_updated events as balance changes are detected.
At expires_at: step.failed for every step, followed by rebalance.updated with the terminal Rebalance object.
Example rebalance.balance_updated webhook (Scenario 1, source ledger short):