Withdrawals via a liquidity provider move funds out of your managed accounts to your external bank account.
Funds can only be withdrawn from a liquidity provider to a bank account. If the destination is a wallet, the resource should be a rebalance (when the wallet is Tesser-provisioned) or a payment (when the wallet belongs to a third party).
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 bank account 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, an on-chain push from a wallet to a provider's ledger, or a fiat transfer from a provider's ledger to your external bank account. A transfer step's from and to may differ in currency: when a provider redeems a stablecoin into fiat as part of a single API call to the provider (e.g., Circle redeeming USDC into USD on the way out to your bank), the redemption is modeled as one cross-currency transfer step.
A swap step exchanges currencies within the same account (the step's estimated.from.account_id and estimated.to.account_id are equal). Tesser uses a standalone swap step when the provider's ledger can hold both source and destination currencies (e.g., OpenFX redeeming USDT into USD on its own ledger). For providers whose redemption is bundled with the withdrawal itself (e.g., Circle), the currency change rides on a transfer step instead, and the withdrawal has no standalone swap step.
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.
Exchange Rates for Withdrawals
When a withdrawal crosses currencies — whether as a standalone swap step (e.g., OpenFX redeeming USDT to USD on its own ledger) or as a cross-currency transfer step (e.g., Circle redeeming USDC to USD as part of the withdrawal) — the exchange rate depends on the liquidity provider executing the withdrawal. As with deposits, 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
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 or Circle 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 withdrawing a stablecoin into a USD bank account).
Note: 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 USD bank account.
Scenario 2 — Withdrawing USDC from a Circle ledger to your USD bank account.
Scenario 3 — Withdrawing USDC from a self-custodial wallet via OpenFX to your USD bank account.
Example request (USDT from an OpenFX ledger withdrawn 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. For ledger sources (Scenarios 1 and 2), Tesser performs the balance check asynchronously and publishes the outcome via a withdrawal.balance_updated webhook shortly after withdrawal.quote_created. For wallet sources (Scenario 3), the balance check runs synchronously inside your call to the step-signing API. See Withdrawal 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 (USDT from an OpenFX ledger withdrawn 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 liquidity 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 withdrawn 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.
Withdrawals debit funds from the managed account at desired.from.account_id, so Tesser performs a balance check before the withdrawal 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 withdrawal is created. Shortly after withdrawal.quote_created fires, Tesser reads the ledger's available balance and emits withdrawal.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 withdrawal.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 withdrawal.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 withdrawal.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 withdrawal.balance_updated (with balance_status: "reserved") fires on the subsequent successful sign. The retry loop continues until funds arrive or the withdrawal expires — see Failure Modes for Withdrawals.
Example withdrawal.balance_updated webhook (Scenario 1, balance reserved at the source OpenFX ledger):
Once funds have been reserved, Tesser begins executing each step in step_sequence order. The shape of step execution differs by scenario.
Scenario 1: Withdrawing USDT from an 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 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 withdrawal instruction; step.confirmed fires when OpenFX's local payment network confirms acceptance of the outbound push; step.completed fires when funds settle at the external bank account.
Example step.completed webhook for Scenario 1, swap step (step 1):
For ledger-internal swap steps, transaction_hash is always null — the redemption happens entirely within the provider's books and never touches a chain.
Example step.completed webhook for Scenario 1, transfer step (step 2; terminal step):
For Scenario 2, Tesser initiates the withdrawal via Circle's API as a single call. The withdrawal executes over onetransfer step that moves USDC from the Circle ledger account to your external USD bank account, absorbing the USDC→USD redemption inside the step. estimated.from.currency is USDC (debited at the Circle ledger) and estimated.to.currency is USD (credited at your bank). Circle's redemption is 1:1, so estimated.to.amount matches estimated.from.amount at the indicative quote.
Circle accepts the withdrawal synchronously, so step.submitted and step.confirmed fire in close succession. Final settlement at the receiving bank lands later via Circle's terminal webhook, at which point step.completed fires.
Example step.completed webhook for Scenario 2, cross-currency transfer step (the only step; terminal step):
Scenario 3: Withdrawing USDC from a self-custodial wallet via 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, producing step.signed. Tesser then broadcasts on-chain, producing step.submitted, step.confirmed (with transaction_hash), 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) follows the standard step lifecycle.
Example step.signature_requested webhook (Scenario 3, on-chain transfer from 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.withdrawal_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. You will receive step.signed, followed by step.submitted, step.confirmed (with transaction_hash populated), and step.completed for Step 1. For wallet-originated on-chain steps, step.submitted is an event-only marker — see Step Statuses in the lifecycle overview.
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.
Example GET /v1/treasury/withdrawals/{withdrawalId} response (Scenario 1, complete):
Below are the webhook events you will observe for each of the three withdrawal scenarios in this guide.
Cross-currency withdrawal at OpenFX (USDT → USD → bank):
#
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 2 steps and the populated estimated overlay
Steps array; estimated.from.amount and estimated.to.amount at withdrawal and step level (reflects indicative rate)
3
Tesser reserves USDT at the desired.from.account_id
withdrawal.balance_updated webhook with balance_status: "reserved"
balance_status, balance_reserved_at
4
OpenFX redeems USDT for USD on its books
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
OpenFX initiates the push of funds to the external bank
step.submitted on the transfer step
Step 2: submitted_at, actual.from.amount
6
OpenFX confirms acceptance
step.confirmed on the transfer step
Step 2: confirmed_at
7
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 2: completed_at, actual.to.amount. Withdrawal-level actual.from.amount and actual.to.amount.
Failure Modes for Withdrawals
If a withdrawal 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.
Swap Fails (Scenarios 1 and 3 only)
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 funds to push to the external bank. This failure mode applies only to scenarios with a standalone swap step — Scenario 1 (OpenFX redeems USDT→USD as a swap step at its ledger) and Scenario 3 (OpenFX redeems USDC→USD as a swap step at its ledger). Scenario 2 has no standalone swap step (Circle's redemption is absorbed into the single transfer step), so this failure mode does not apply to Sc 2 — see Fiat Funds Transfer Fails (All Scenarios) below for the Sc 2 failure path.
In Scenario 1 (where the swap is the only execution work before the bank push), no step reaches step.status = completed — top-level actual.* stays all-null per the Failure Modes rule. In Scenario 3, the prior on-chain transfer step (Step 1) had already completed, so top-level actual.from and actual.to populate from Step 1's actual.* (USDC at the OpenFX ledger).
What you will observe (illustration uses Scenario 1 — OpenFX swap give-up):
step.failed on the swap step. Step-level actual.* is null; status_reasons carries the failure detail.
step.failed on the transfer step. This step never entered submitted, because there were no funds to push to the external bank.
The withdrawal's top-level actual.* is all null because no step reached completed. desired.to.account_id stays as the originally requested external bank account (client intent is never overwritten); desired.from and estimated.* describe what was requested and planned.
A withdrawal.updated webhook fires alongside the terminal step.failed events, carrying the full updated Withdrawal object.
The USDT balance at the OpenFX ledger remains in your account and is available to use for a future rebalance or withdrawal.
Example step.failed webhook on Step 1 (swap, never filled):
The fiat funds transfer step (the step that delivers funds to your external bank account) may fail:
Between submission and confirmation — Tesser called the provider, but the provider returned a non-200 response (or no response within Tesser's timeout). submitted_at is populated; confirmed_at is null; failed_at is populated. The upstream balance reservation is rolled back. Funds remain at the upstream source.
After confirmation — The provider accepted the request (so confirmed_at is populated), but the provider's terminal webhook later reported failure. submitted_at and confirmed_at are populated; completed_at is null; failed_at is populated. What happened to the funds at the provider between confirmation and the terminal webhook depends on the provider's internal handling — for Circle, the redeemed USD typically returns to the Circle ledger; for OpenFX, the USD typically remains at the OpenFX ledger.
Step-level actual.* is null on the failed transfer step regardless of sub-case (failed steps never populate actual.*).
Top-level actual.* is determined by the last completed step:
Scenario 1: Step 1 (swap) had completed, so top-level actual.from reflects Step 1's actual.from (USDT at the OpenFX ledger) and top-level actual.to reflects Step 1's actual.to (USD at the OpenFX ledger). USD remains at the OpenFX ledger.
Scenario 2: No upstream step completed (Sc 2 has only the one step that just failed), so top-level actual.* stays all-null. USDC remains at the Circle ledger.
Scenario 3: Step 1 (on-chain transfer) and Step 2 (swap) had completed, so top-level actual.from reflects Step 1's actual.from (USDC at the source wallet) and top-level actual.to reflects Step 2's actual.to (USD at the OpenFX ledger). USD remains at the OpenFX ledger.
What you will observe (illustration uses Scenario 2 — Circle rejected the withdrawal between submission and confirmation):
step.failed on the transfer step. submitted_at is populated; confirmed_at is null; status_reasons carries the provider's failure detail; step-level actual.* is null.
The withdrawal's top-level actual.* is all-null because no step reached completed. 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.
The USDC balance at the Circle ledger remains in your account and is available to use for a future rebalance or withdrawal.
Example step.failed webhook (Scenario 2; Circle rejected the withdrawal between submission and confirmation):
If Step 1's on-chain transfer from the self-custodial wallet to the OpenFX ledger never enters submitted — for example, because the customer never signs the step before expires_at, or because the sign endpoint rejects the signature — the withdrawal terminates with USDC remaining at the source wallet. The subsequent swap and transfer steps never start. (Post-broadcast failure modes — chain reorg, gas exhaustion, nonce conflict on a signed-and-broadcast transaction — are covered separately.)
What you will observe:
step.failed on Step 1 (on-chain transfer). Step-level actual.* is null; status_reasons is [] because the step never started — the failure cause lives at the resource level (the withdrawal reached expires_at waiting for the customer signature, or the sign endpoint rejected the signature).
step.failed on Step 2 (swap) and Step 3 (bank transfer). Neither step was ever submitted. Step-level actual.* is null; status_reasons is [].
The withdrawal's top-level actual.* is all null because no step reached completed. desired.to.account_id stays as the originally requested external bank account; desired.from and estimated.* describe what was requested and planned.
A withdrawal.updated webhook fires alongside the terminal step.failed events, carrying the full updated Withdrawal object.
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 when the balance check runs, the withdrawal enters balance_status: "awaiting_funds". For ledger sources (Scenarios 1 and 2), the async check after creation detects the shortfall and Tesser fires withdrawal.balance_updated with balance_status: "awaiting_funds"; Tesser then retries the reservation as the balance at desired.from.account_id changes (e.g., as a deposit lands or another withdrawal frees funds). For wallet sources (Scenario 3), the sync check inside the step-signing API returns a 4XX rejecting the signature and withdrawal.balance_updated fires shortly afterward 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, so you can retry the signing flow with the refreshed step. If the reservation does not succeed before expires_at, the withdrawal times out.
What you will observe:
withdrawal.balance_updated with balance_status: "awaiting_funds" shortly after withdrawal.quote_created (ledger sources) or shortly after a rejected sign call (wallet sources).
(Optional) further withdrawal.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 withdrawal timed out after remaining in awaiting_funds until expires_at).
The withdrawal's top-level actual.* is all null because no step reached step.status = completed — no funds ever moved.
A terminal withdrawal.updated webhook fires alongside the step.failed events, carrying the full updated Withdrawal object reflecting the terminal state.
The example below illustrates Scenario 1; the same pattern applies in Scenarios 2 and 3, with one failed step entry per never-started step.
Example withdrawal.balance_updated webhook (Scenario 1, source ledger short):