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. Ledger-to-wallet rebalances swap first and then transfer the resulting stablecoin. Wallet-to-OpenFX rebalances transfer the stablecoin to the OpenFX ledger first and then swap it there.
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
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.
Kraken Ledger Swaps
To convert BRL held at Kraken to USDC, use the same managed Kraken account ID on both sides and omit both networks. This route is available in production.
Tesser plans two market orders: BRL1→USD, followed by USD→USDC. BRL1 is Kraken's asset underlying the BRL balance in Tesser. Both planned order quantities remain fixed during execution. The actual USD proceeds of the first step stay reserved for the second step, whose actual debit can differ from those proceeds. Read each step's actual and fees fields for settled amounts; estimated remains the original quote.
CAD→USDC and USD→USDC use the same request shape with the corresponding source currency and require one swap step. USD→USDC can also convert USD left available by a failed BRL route. For partial execution and uncertain submissions, see Kraken rebalance behavior.
To deliver the resulting USDC to your managed wallet, create a separate USDC→USDC rebalance from the Kraken ledger to the wallet with desired.to.network = "BASE". The wallet address must be registered and verified in Kraken. Tesser accounts for Kraken's withdrawal fee in the amount delivered.
Wallet to Kraken Ledger
This route moves USDC from your managed wallet to your managed Kraken ledger in the same workspace. Tempo uses USDC.e; Ethereum, Polygon, and Base use the configured native USDC contract. Deposit networks remain unavailable until Tesser completes acceptance and enables them. Base is disabled by default.
Submit POST /v1/treasury/rebalances with the wallet network on desired.from and no network on desired.to. Tesser selects the matching Kraken method and reuses or provisions a receiving address.
An unavailable network, unsupported currency, invalid amount, or provider minimum prevents the transfer from being sent. Address provisioning requires no customer-operated Kraken request.
Wait for a step in signature_requested, then sign its unsigned_transaction and submit the usual Turnkey envelope to POST /v1/treasury/rebalances/{id}/steps/{stepId}/sign.
The prepared destination and amount stay fixed. If they are no longer valid before submission, the step fails and a new rebalance requires a new signature.
Observe the saved rebalance through GET or treasury webhooks. Tesser completes it after verifying both chain delivery and Kraken's actual credit.
Chain confirmation can precede Kraken credit. Keep observing the same operation instead of creating a replacement transfer.
estimated.to.amount is the projected net credit. actual.from.amount is the wallet principal, actual.to.amount is the verified net Kraken amount, and fees reports provider and chain fees. For example, sending 5 USDC with a verified 0.01 USDC deposit fee credits 4.99 USDC.
Missing or conflicting transaction evidence remains pending for reconciliation. Expiration after submission does not make sent principal available again.
Example request for an enabled Tempo deposit route:
Amounts in examples are illustrative; Kraken minimums and fees are checked for each operation. Fiat conversion remains a separate Kraken rebalance.
Wallet to OpenFX Ledger
OpenFX also supports rebalancing a stablecoin from a self-custodial wallet into your OpenFX ledger. Set desired.from.network to the wallet's network and omit desired.to.network because the destination is a ledger. When the source and destination currencies match, the route contains one signed on-chain transfer. When they differ, Tesser transfers the source stablecoin first and then swaps it at OpenFX.
Example request (rebalance USDC from a self-custodial wallet on BASE into USD at an OpenFX ledger):
OpenFX does not provide on-chain deposit addresses in its sandbox. In sandbox and staging, Tesser sends the signed transfer to a Tesser-controlled sandbox address instead. After the transfer step reaches submitted, create a deposit in the OpenFX sandbox account connected to Tesser with the same amount and currency as the transfer step and the corresponding network. Tesser completes the transfer step and starts any following swap after OpenFX sends the completed deposit webhook.
Create the OpenFX sandbox deposit only after the transfer step is submitted, and run only one pending wallet-to-OpenFX flow with the same amount, currency, and network at a time. Identical pending flows cannot be matched safely. Production sends funds directly to the registered OpenFX deposit address and does not require this manual deposit.
If the OpenFX deposit does not match a submitted flow, Tesser does not advance the rebalance. Instead, Tesser records a completed inbound Payment to your OpenFX ledger, emits payment.created, and adds the received amount to the ledger's available_balance. If the deposit could match more than one submitted flow, Tesser does not choose between them: no flow advances, and Tesser does not create a Payment or update the ledger's tracked balance. Contact Tesser support before creating another deposit or retrying the rebalance. See OpenFX Ledger Inbound Payments for the inbound Payment's event behavior.
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 and wallet-to-OpenFX rebalances): 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.
If the same wallet has an earlier transfer still waiting to be broadcast, this one queues behind it: the sign call returns 200 and the step stays signed, already carrying its transaction_hash (derived from the signed payload) with submitted_at still null. It broadcasts as soon as the earlier transfers are submitted, and step.submitted / step.confirmed follow as above — no action needed on your side. See signing a wallet step for the queue's ordering rules.
Example step.signature_requested webhook (Scenario 3, on-chain wallet-to-wallet transfer):
To sign the step, build a StepForSigning from the webhook step and pass it to LocalSigner.signStep. It needs three values: the unsigned_transaction, the source wallet's on-chain address (crypto_wallet_address from GET /v1/accounts/{estimated.from.account_id}), and the network. Submit the returned signature to POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign to execute the rebalance step. See Sign a Wallet Step for install and the full SDK reference.
Code
import { LocalSigner, type StepForSigning } from "@tesser-payments/sdk-ts";const signer = new LocalSigner({ signing: { 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 event.const step = webhookPayload.data.object;// 2. Resolve the source wallet's on-chain address.const account = await fetch( `https://api.tesser.xyz/v1/accounts/${step.estimated.from.account_id}`, { headers: { Authorization: `Bearer ${token}` } }).then((res) => res.json());// 3. Sign locally.const toSign: StepForSigning = { unsignedTransaction: step.unsigned_transaction, signWith: account.crypto_wallet_address, network: step.estimated.from.network,};const { signature } = await signer.signStep(toSign);// 4. Submit the signature to execute the step.await 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 }), });
signStep returns a signature to send to the rebalance step sign API to execute the step, plus metadata (stampHeaderName, stampHeaderValue, body) for debugging that stays client-side. (The Kotlin result also echoes unsignedTransaction for audit trails.)
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):
A wallet-to-OpenFX rebalance uses the same signing flow as Scenario 3. Tesser emits step.signature_requested for the on-chain transfer, and you submit the signature through POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign. The transfer then moves through step.signed, step.submitted, and step.confirmed.
In production, OpenFX detects the transfer to its registered deposit address and sends the deposit webhook that completes the transfer step. In sandbox and staging, create the matching OpenFX sandbox deposit after the transfer reaches submitted, as described under Wallet to OpenFX Ledger. The transfer step completes only after Tesser receives the completed deposit webhook. Its actual.to.amount reflects the amount OpenFX reports as credited and can differ from estimated.to.amount. A same-currency rebalance then completes; a cross-currency rebalance continues to the OpenFX swap step using the credited amount.
Rebalance Info After Completion
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.
Additional route — Stablecoin from a self-custodial wallet to an OpenFX ledger
#
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 with one transfer step for a same-currency route, or a transfer followed by a swap for a cross-currency route
Steps array; estimated.from.amount and estimated.to.amount
3
Tesser asks you to sign the on-chain transfer
step.signature_requested on the transfer step
Transfer step signing payload
4
You submit the signature
POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign with { "signature": "0x..." }; step.signed and rebalance.balance_updated with balance_status: "reserved" fire
balance_status, balance_reserved_at; transfer step status: "signed"
5
Tesser broadcasts the signed transaction
step.submitted on the transfer step
Transfer step submitted_at
6
The transaction is accepted by the network
step.confirmed on the transfer step
Transfer step confirmed_at, transaction_hash
7
OpenFX credits the on-chain deposit. In sandbox and staging, create the matching OpenFX sandbox deposit after the transfer is submitted; production observes the deposit sent directly to OpenFX.
step.completed on the transfer step after Tesser receives the completed OpenFX deposit webhook; for a same-currency route, this is followed by rebalance.updated carrying the terminal Rebalance object
Transfer step completed_at, actual.from.amount, actual.to.amount; for a same-currency route, rebalance-level actual.*
8
For a cross-currency route, OpenFX executes the swap; a same-currency route is already complete
Cross-currency only: step.submitted, step.confirmed, and step.completed on the swap, followed by rebalance.updated
Swap actual.*; rebalance-level actual.*
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 and wallet-to-OpenFX rebalances), 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):