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 asynchronously 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.
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):
Rebalances debit funds from the managed account at desired.from.account_id, so Tesser performs a balance check before the rebalance executes. When the balance check runs and how it surfaces depends on the type of account:
Ledger sources (Scenarios 1 and 2): the check runs asynchronously after the rebalance is created. Shortly after rebalance.quote_created fires, Tesser reads the ledger's available balance and emits rebalance.balance_updated with the outcome.
Wallet sources (Scenario 3): the check runs synchronously inside your call to the step-signing API. On success, the sign endpoint accepts your signature and rebalance.balance_updated fires with balance_status: "reserved" from the sign-API path alongside step.signed. On insufficient funds, the sign endpoint returns a 4XX response and rebalance.balance_updated fires with balance_status: "awaiting_funds". Tesser monitors the wallet's on-chain balance and republishes step.signature_requested (with a refreshed signature_requested_at) once the wallet is funded; repeat the signing flow with the refreshed step to retry, until expires_at.
In both cases, a reservedbalance_status means execution can proceed and balance_reserved_at records when the reservation succeeded. See Balance Statuses.
If desired.from.account_id does not have sufficient funds when the balance check runs, balance_status is set to awaiting_funds. For ledger sources, Tesser republishes rebalance.balance_updated as the ledger balance is re-evaluated. For wallet sources, Tesser republishes step.signature_requested once on-chain funding is observed, and the next rebalance.balance_updated (with balance_status: "reserved") fires on the subsequent successful sign. The retry loop continues until funds arrive or the rebalance expires — see Failure Modes for Rebalances.
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
Tesser initiates the internal move via Circle's API and observes Circle's synchronous confirmation, so step.submitted, step.confirmed, and step.completed arrive in close succession. 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, producing step.signed. Tesser then broadcasts on-chain, producing step.submitted, step.confirmed (with transaction_hash), 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.rebalance_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. You will receive step.signed, followed by step.submitted, step.confirmed (with transaction_hash populated), and step.completed. For wallet-originated on-chain steps, step.submitted is an event-only marker — see Step Statuses in the lifecycle overview.
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
step.submitted, step.confirmed, and step.completed fire on the single step in close succession, followed by a rebalance.updated event carrying the full Rebalance with actual.* populated
Step 1: actual.from.amount, actual.to.amount, all timestamps. Rebalance-level actual.from.amount and actual.to.amount. Rebalance complete.
Scenario 2 — Cross-token rebalance from OpenFX ledger to a BASE wallet
#
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 2 steps and the populated estimated overlay
Steps array; estimated.from.amount and estimated.to.amount at rebalance and step level
3
Tesser reserves USD at the desired.from.account_id
rebalance.balance_updated webhook with balance_status: "reserved"
balance_status, balance_reserved_at
4
Tesser executes the swap (USD → USDC) at the OpenFX ledger
step.submitted, step.confirmed, and step.completed fire on the swap step in close succession
Step 1: actual.from.amount, actual.to.amount (reflects actual fill rate), all timestamps
5
Tesser submits the on-chain transfer of USDC to the BASE wallet
step.submitted on the transfer step
Step 2: submitted_at, actual.from.amount
6
The on-chain transaction is broadcast and accepted into the network's mempool (not yet in a block)
step.confirmed on the transfer step
Step 2: confirmed_at, transaction_hash
7
The transfer reaches finality; rebalance is complete
step.completed on the transfer step, followed by rebalance.updated with the terminal Rebalance object
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 broadcast and accepted into the network's mempool (not yet in a block)
step.confirmed on the step
Step 1: confirmed_at, transaction_hash
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 top-level actual.* fields populate only when at least one step has reached step.status = completed. With one or more completed steps, the top-level actual.from matches the first completed step's actual.from, and the top-level actual.to matches the last completed step's actual.to. With no completed step, the top-level actual.* stays all-null — desired.* and estimated.* describe the originally requested and quoted state, while step-level status_reasons carries the cause of the failure. Failed steps always have all-null actual.*. Any steps subsequent to the failure step also transition to failed with null actual.* fields.
Circle-Internal Transfer Rejected (Scenario 1)
If Circle rejects the internal move between your two Circle ledger accounts, step.submitted fires (Tesser handed off to Circle) and the single transfer step then transitions to failed when Circle's response is a rejection rather than a confirmation. Funds remain at the desired.from.account_id.
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). The step's actual.* is null because the swap never completed; failure details are in status_reasons.
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.* is all null because no step reached step.status = completed — the swap step failed, and Step 2 (wallet transfer) never started. desired.to stays as USDC/BASE at the wallet (client intent is never overwritten); desired.from and estimated.* describe what was requested and planned.
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 desired.from.amount and desired.from.currency (1000 USD). actual.to resolves to USDC at the OpenFX ledger account 2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b with amount 999.475. The USDC that resulted from the swap is available to use for a future (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):
On-Chain Transfer Between Wallets Fails (Scenario 3)
If the on-chain wallet-to-wallet transfer in Scenario 3 fails after broadcast — for example because of a chain reorg, gas exhaustion, or a nonce conflict — the rebalance terminates with USDT remaining at the source wallet. Pre-broadcast failures (e.g., the signed step never reaches the chain) are surfaced by the step-signing API itself; see the /how-tos/sign-a-wallet-step peer guide.
What you will observe:
step.failed on Step 1 (wallet-to-wallet transfer). Step-level actual.* is null; status_reasons carries the failure detail. transaction_hash is present because the transfer broadcast onto the chain before failing.
The rebalance's top-level actual.* is all null because no step reached step.status = completed.
balance_status rolls back to unreserved and balance_reserved_at clears to null at terminal failure. The funds remain at the source wallet on-chain; the reservation that was held at step signing is released so the wallet's balance is no longer locked against this rebalance.
A rebalance.updated webhook fires alongside the terminal step.failed event.
Example step.failed webhook on Step 1 (on-chain wallet transfer broadcast but not confirmed):
If the desired.from.account_id does not have enough balance to cover desired.from.amount when the async balance check runs (Scenarios 1 and 2), Tesser fires rebalance.balance_updated with balance_status: "awaiting_funds" shortly after rebalance.quote_created 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). For wallet sources (Scenario 3), insufficient funds surface as a 4XX from the step-signing API; rebalance.balance_updated fires shortly afterward, and your retry path is to fund the wallet and call sign again. If the reservation does not succeed before expires_at, the rebalance times out.
What you will observe:
rebalance.balance_updated with balance_status: "awaiting_funds" shortly after rebalance.quote_created (ledger sources) or shortly after a rejected sign call (wallet sources).
(Optional) further rebalance.balance_updated events as balance changes are detected.
At expires_at: step.failed on every step that was still in created status. Step-level actual.* is null; status_reasons is [] because the step never started — the failure cause lives at the resource level (the rebalance timed out after remaining in awaiting_funds until expires_at).
The rebalance's top-level actual.* is all null because no step reached step.status = completed — no funds ever moved.
A terminal rebalance.updated webhook fires alongside the step.failed events, carrying the full updated Rebalance object reflecting the terminal state.
Example rebalance.balance_updated webhook (Scenario 1, source ledger short):