Withdrawals via a liquidity provider move funds out of your managed accounts to your external bank account.
Before creating a withdrawal, review the Funds Movement Lifecycle and Data Model overview to understand the shared data shape, lifecycle phases, and statuses that apply to withdrawals and Tesser's other funds-movement resources.
Prerequisites
Liquidity providers require funds movement to and from their platforms to be "first-party" only. For a withdrawal, the destination bank account must be registered both with Tesser and with the liquidity provider executing the withdrawal.
Where supported, Tesser will register the destination at your enrolled liquidity provider on your behalf. This mirrors how deposits work, except funds flow toward the fiat bank account rather than from it. For more information on account creation, see Create an account. The desired.from.account_id must be one of your managed accounts at a liquidity provider (for example, an OpenFX USD ledger or a Circle USDC ledger) or a self-custodial wallet.
A transfer step moves funds from one account to another — for example, a USD push from a provider ledger to your external bank account.
A swap step exchanges currencies within the same account (the step's estimated.from.account_id and estimated.to.account_id are equal). Cross-currency withdrawals include a swap step at the liquidity provider's ledger; for self-custodial wallet sources, the swap is preceded by an on-chain transfer that delivers funds to the ledger.
Each step has a status. See Step Statuses for the full status taxonomy. Tesser plans the sequence of steps during the Planning phase, executes them during the Execution phase, and populates the top-level actual.* overlay when the withdrawal reaches its Terminal State.
Off-Ramp Exchange Rates
When a withdrawal includes a swap step that crosses currencies (e.g., USDC redeemed to USD at Circle before the USD push to your bank), the exchange rate depends on the liquidity provider executing the off-ramp. As with on-ramping, Tesser does not provide guaranteed exchange rates unless the provider does.
Type of liquidity provider
Example
Behavior
Stablecoin issuer
Circle
Exchange rate between USD and USDC always 1:1
On/Off ramp
Alfred, OpenFX
Exchange rate between sell and buy currencies fluctuates; current rates can be guaranteed for a period of time
Exchange
Kraken
Exchange rate fluctuates; no guaranteed rate
Reserving Funds from the Balance
Withdrawals debit funds from a managed account, so Tesser performs a balance check on the desired.from.account_id before executing any steps. The withdrawal's top-level balance_status field reports the result and balance_reserved_at is populated when funds are reserved. See Balance Statuses for the full taxonomy.
If the desired.from.account_id does not have sufficient funds at creation time, the withdrawal's balance_status is set to awaiting_funds and Tesser republishes the withdrawal.balance_updated webhook. The withdrawal remains in awaiting_funds until funds arrive or the withdrawal expires.
If applicable, you should specify on which tenant's behalf you are requesting the withdrawal.
For the withdrawal, populate the following fields in the desired object:
desired.from.account_id: The identifier of one of your managed accounts at a liquidity provider — for example, an OpenFX USDT ledger or a Circle USDC ledger — or a self-custodial wallet.
desired.from.amount: Amount to withdraw, in desired.from.currency.
desired.from.currency: Currency held at desired.from.account_id.
desired.from.network: Only specified when desired.from.account_id is a self-custodial wallet (e.g., BASE).
desired.to.account_id: Identifier of your external destination — the bank account registered with Tesser ahead of time.
desired.to.currency: Currency to deliver to the destination (e.g., USD when off-ramping a stablecoin into a USD bank account).
Note: desired.to.amount is not auto-populated. The withdrawal'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:
Scenario 1 — Withdrawing USDT from an OpenFX ledger to your external USD bank account (cross-currency off-ramp at OpenFX; swap step + transfer step).
Scenario 2 — Withdrawing USDC from a Circle ledger to your external USD bank account (cross-currency off-ramp via Circle redemption; swap step + transfer step).
Scenario 3 — Withdrawing USDC from a self-custodial wallet on BASE to your external USD bank account (on-chain transfer to the OpenFX ledger, swap to USD at OpenFX, then USD push to bank; 3 steps total, including wallet signing on the on-chain step).
Example request (USDT from an OpenFX ledger off-ramped to your external USD bank account):
In the API response, Tesser will create and return an id for the withdrawal. The top-level balance_status is initially unreserved — Tesser performs the balance check asynchronously and publishes the outcome via a withdrawal.balance_updated webhook (see Balance Check below). The estimated and actual overlays are all-null at creation; estimated is populated when withdrawal.quote_created fires, and actual is populated when the withdrawal reaches its terminal state.
Example response (withdraw USDT from an OpenFX ledger off-ramped to your external USD bank account):
Withdrawal Quote Created (withdrawal.quote_created)
After your POST /v1/treasury/withdrawals request is accepted, Tesser plans the route the funds will take and obtains a reference exchange rate. Tesser then sends a withdrawal.quote_created webhook — the first webhook fired for the withdrawal. The payload carries the planned steps[] array (each step with status: "created") together with the populated estimated overlay at both the withdrawal and step levels. This webhook always fires, even for same-currency withdrawals, to keep the lifecycle uniform across resource types.
For cross-currency withdrawals, the ratio of estimated.from.amount to estimated.to.amount is the indicative exchange rate at the off-ramp provider. Circle redemptions are 1:1, so for Scenario 2 the indicative rate matches the source amount.
Example withdrawal.quote_created webhook (USDT from an OpenFX ledger off-ramped to your external USD bank account; swap step + transfer step):
For Scenario 1, the swap step models OpenFX's USDT-to-USD redemption: USDT is debited and USD is credited at the same OpenFX ledger account (estimated.from.account_id and estimated.to.account_id both equal the OpenFX ledger UUID). The follow-on transfer step then pushes the redeemed USD from the OpenFX ledger to your external bank account.
Balance Check (withdrawal.balance_updated)
After withdrawal.quote_created fires, Tesser reserves funds at the desired.from.account_id and emits withdrawal.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 on the wallet 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 withdrawal.balance_updated webhook (Scenario 1, balance reserved at the source OpenFX ledger):
For Scenario 1, Step 1 is a swap at the OpenFX ledger that redeems USDT into USD. 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 the long-pole bank transfer that pushes the redeemed USD from the OpenFX ledger to your external bank account. It follows the standard step lifecycle: step.submitted fires when OpenFX accepts the wire instruction; step.confirmed fires when OpenFX's local payment network confirms acceptance of the outbound wire; step.completed fires when funds settle at the external bank account.
Example step.completed webhook for Scenario 1, swap step (step 1):
Scenario 2: Cross-Currency Off-Ramp at Circle (USDC → USD → Bank)
For Scenario 2, Circle's API exposes only terminal-state webhooks for both the redemption swap and the bank transfer. Tesser collapses each step's lifecycle directly to step.completed (or step.failed) — no intermediate step.submitted or step.confirmed events are emitted. See the note on collapsed step lifecycles in Execution.
Step 1 is a swap step that redeems USDC into USD on Circle's books at the Circle ledger account. estimated.from.currency is USDC and estimated.to.currency is USD; both legs reference the Circle ledger account. Circle's redemption is 1:1.
Step 2 is a transfer step that pushes the redeemed USD from the Circle ledger account to your external USD bank account.
Example step.completed webhook for Scenario 2, swap step (step 1):
Scenario 3: On-Chain Transfer From a Self-Custodial Wallet, Then Off-Ramp at OpenFX
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 Step 1 is prepared, with the unsigned transaction available on the step DTO as unsigned_transaction (also retrievable via GET /v1/treasury/withdrawals/{withdrawalId}). You sign the unsigned transaction client-side, then submit the signature to POST /v1/treasury/withdrawals/{withdrawalId}/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 for Step 1.
Once Step 1 settles, Step 2 (swap at the OpenFX ledger, USDC → USD) executes synchronously: step.submitted, step.confirmed, and step.completed arrive in close succession. Step 3 (USD push from the OpenFX ledger to your external bank account) is the long pole and follows the standard step lifecycle.
Example step.signature_requested webhook (Scenario 3, on-chain transfer from BASE wallet to OpenFX ledger):
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/withdrawals/{withdrawalId}/steps/{stepId}/sign to execute the withdrawal 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/withdrawals/${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 withdrawal sign endpoint to execute the step
unsignedTransaction: the serialized unsigned transaction
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 for Step 1.
Example step.completed webhook for Scenario 3, on-chain transfer step (step 1):
When the last step reaches a terminal state, Tesser populates the top-level actual.* overlay and emits a withdrawal.updated webhook carrying the full updated Withdrawal object. You can also retrieve the withdrawal at any time via GET /v1/treasury/withdrawals/{withdrawalId} — the response payload below matches the body of the terminal withdrawal.updated webhook for each scenario.
desired.to.amount remains null even after the withdrawal completes; the indicative target is in estimated.to.amount and the realized amount is in actual.to.amount.
Example GET /v1/treasury/withdrawals/{withdrawalId} response (Scenario 1, complete):
A terminal step.completed event fires for the transfer step, followed by withdrawal.updated with the terminal Withdrawal object
Step 2: actual.from.amount, actual.to.amount, completed_at. Withdrawal-level actual.from.amount and actual.to.amount.
Scenario 3 — On-chain transfer from a self-custodial wallet, then off-ramp at OpenFX
#
What happens
What you receive / do
Fields populated
1
You submit the withdrawal request
HTTP response with id; balance_status: "unreserved"
desired.from.amount
2
Tesser plans the route and obtains a quote
withdrawal.quote_created webhook fires with 3 steps and the populated estimated overlay
Steps array; estimated.from.amount and estimated.to.amount at withdrawal and step level
3
Tesser asks you to sign the on-chain transfer
step.signature_requested on Step 1
Step 1: status updates; signing payload supplied via the API
4
You submit the signature
POST /v1/treasury/withdrawals/{withdrawalId}/steps/{stepId}/sign with {signature}; step.signed fires; withdrawal.balance_updated with balance_status: "reserved" once the signed step is accepted
step.submitted, step.confirmed, and step.completed fire on the swap step in close succession
Step 2: actual.from.amount, actual.to.amount (reflects actual fill rate), all timestamps
9
OpenFX initiates the wire to the external bank
step.submitted on the transfer step
Step 3: submitted_at, actual.from.amount
10
OpenFX confirms acceptance
step.confirmed on the transfer step
Step 3: confirmed_at
11
Funds settle at the external bank; withdrawal is complete
step.completed on the transfer step, followed by withdrawal.updated with the terminal Withdrawal object
Step 3: completed_at, actual.to.amount. Withdrawal-level actual.from.amount and actual.to.amount.
Failure Modes for Withdrawals
If a withdrawal fails, the actual.* fields on the top-level of the withdrawal 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 withdrawal. 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.
Off-Ramp Trade Fails to Complete (Scenarios 1, 2, or 3)
If the liquidity provider's swap step cannot complete before the withdrawal's expires_at timestamp, the swap step transitions to failed and the subsequent transfer step also fails because there were no off-ramped funds to push to the external bank. The top-level actual.to resolves to the source stablecoin held at the liquidity provider's ledger:
Scenario 1: USDT at the OpenFX ledger UUID 2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b.
Scenario 2: USDC at the Circle ledger UUID 44031e7e-d416-45f0-a46b-ded12b9751ca.
Scenario 3: USDC at the OpenFX ledger UUID 2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b (the on-chain transfer in Step 1 already delivered the USDC to OpenFX before the swap was attempted).
What you will observe (illustration uses Scenario 1 — OpenFX swap give-up):
step.failed on the swap step. actual.to.amount remains null because no USD was obtained.
step.failed on the off-ramp transfer step. This step never entered submitted, because there were no funds to push to the external bank.
The withdrawal's top-level actual.to.account_id resolves to the OpenFX ledger UUID with actual.to.currency: "USDT" and actual.to.amount equal to the source amount. desired.to.account_id stays as the originally requested external bank account (client intent is never overwritten).
A withdrawal.updated webhook fires alongside the terminal step.failed events, carrying the full updated Withdrawal object with the populated actual.* overlay.
The USDT balance at the OpenFX ledger is available to use for a future rebalance or withdrawal.
Example step.failed webhook on Step 1 (swap, never filled):
If the off-ramp swap succeeded but the liquidity provider's wire to the external bank cannot be confirmed before the withdrawal's expires_at timestamp, the transfer step transitions to failed. The top-level actual.to resolves to USD held at the liquidity provider's ledger — the redeemed USD never reached the external bank.
The illustration below uses Scenario 2 (post-Circle redemption) — Step 1 (swap) completed successfully, and Step 2 (bank transfer) then failed.
What you will observe:
step.completed on the swap step (step 1) earlier in the lifecycle — funds redeemed from USDC into USD at the Circle ledger.
step.failed on the bank transfer step (step 2) when expires_at is reached without confirmation. actual.from.amount reflects the USD that left the Circle ledger toward the external bank; actual.to.amount is null because no settlement was confirmed at the bank.
The withdrawal's top-level actual.to.account_id resolves to the Circle ledger UUID 44031e7e-d416-45f0-a46b-ded12b9751ca, with actual.to.currency: "USD" and actual.to.amount equal to the redeemed amount. desired.to.account_id stays as the originally requested external bank account.
A withdrawal.updated webhook fires alongside the terminal step.failed, carrying the full updated Withdrawal object with the populated actual.* overlay.
The USD balance at the Circle ledger is available to use for a future rebalance or withdrawal.
Example step.failed webhook for step 2 (bank transfer; Scenario 2 post-redemption):
If Step 1's on-chain transfer from the self-custodial wallet to the OpenFX ledger fails — for example, because of a gas issue, nonce conflict, chain reorg, or the signed step never being submitted — the withdrawal terminates with USDC remaining at the source wallet. The subsequent swap and transfer steps never start.
What you will observe:
step.failed on Step 1 (on-chain transfer). actual.* is null because no on-chain transfer was confirmed.
step.failed on Step 2 (swap) and Step 3 (bank transfer). Neither step was ever submitted.
The withdrawal's top-level actual.from and actual.to both resolve to USDC on BASE at the source wallet 7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f — funds remained at the source. desired.to.account_id stays as the originally requested external bank account.
A withdrawal.updated webhook fires alongside the terminal step.failed events, carrying the full updated Withdrawal object with the populated actual.* overlay.
Example step.failed webhook for Step 1 (on-chain transfer from BASE wallet, never confirmed):
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. For provider-ledger sources (Scenarios 1 and 2), Tesser fires withdrawal.balance_updated with balance_status: "awaiting_funds" shortly after creation and queues the withdrawal, retrying the reservation as the balance at desired.from.account_id changes (e.g., as a deposit lands or another withdrawal frees funds). For self-custodial wallet sources (Scenario 3), the balance check happens during step signing — when you submit the signature, Tesser detects the wallet is short and fires withdrawal.balance_updated with balance_status: "awaiting_funds"; the same timeout pattern applies. If the reservation does not succeed before expires_at in any scenario, the withdrawal times out: every step transitions to failed, step.failed events fire for each, and a final withdrawal.updated webhook reports the terminal state with actual.* null because no funds moved.
The example below illustrates Scenario 1; the same pattern applies in Scenarios 2 and 3, with one failed step entry per never-started step.
What you will observe:
withdrawal.balance_updated with balance_status: "awaiting_funds" shortly after creation (or after step signing in Scenario 3).
(Optional) further withdrawal.balance_updated events as balance changes are detected.
At expires_at: step.failed for every step, followed by withdrawal.updated with the terminal Withdrawal object.
Example withdrawal.balance_updated webhook (Scenario 1, source ledger short):