In order to send payouts, you will need to deposit funds to a liquidity provider to exchange fiat to stablecoins (i.e., on-ramp funds).
Tesser treats deposits via a liquidity provider as a special class of payments, so the structure of the deposits API experience will be similar to creating a payment.
Before creating a deposit, review the Funds Movement Lifecycle and Data Model overview to understand the shared data shape, lifecycle phases, and statuses that apply to deposits and Tesser's other funds-movement resources.
Prerequisites
Liquidity providers require that funds movement to and from their platforms are "first-party" only. So, your bank accounts (for fiat funds movement to/from the provider) and wallets (if using self-custodial wallets) must be registered with the liquidity provider prior to deposits and withdrawals.
Before beginning a deposit, you also must register the relevant bank account and/or wallet with Tesser. Where possible, Tesser will then register these accounts at your enrolled liquidity providers on your behalf. For more information on account creation, see Create an account.
Deposit Workflow
Like a payment, a deposit will execute over multiple steps.
- A
transferstep indicates funds are moving from one account to another. Accounts can be a bank account, ledger, or wallet. In atransferstep, the source and destination currencies (estimated.from.currencyandestimated.to.currency) may be the same or different. - A
swapstep indicates currencies have been exchanged within the same account (estimated.from.account_idandestimated.to.account_idare the same). E.g., a trade was performed to sell USD and buy USDC or USDT within your ledger at a liquidity provider.
Each step has a status. For more information on the statuses of a transfer step, see Step statuses.
Exchange Rates for On-Ramping
Depending on which liquidity provider is being used, there may be variation in the exchange rate between a given fiat and stablecoin pair when you on-ramp. Also, the liquidity provider may or may not offer the ability to guarantee the current rate. At this time, 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 buy and sell currencies fluctuates; current rates can be guaranteed for a period of time |
| Exchange | Kraken | Exchange rate fluctuates; no guaranteed rate |
Funding Deposits
Tesser allows you to create "set-it-and-forget-it" deposits for all liquidity providers, where the desired.from.currency and desired.to.currency are different. In this scenario, Tesser will supply an indicative exchange rate between the two currencies via the estimated.from.amount and estimated.to.amount fields, and once funds from the desired.from.currency land at the liquidity provider, Tesser will attempt to execute the on-ramp into the desired.to.currency at the current exchange rate.
Depending on the liquidity provider, you may be able to pre-position funds (pre-fund) at the provider. If pre-funding is supported, Tesser will create a ledger account at the provider to track your pre-funding levels (if any). For liquidity providers that support pre-funding, you can create deposits that transfer only fiat funds from your source bank account to the ledger of the liquidity provider.
You may want to pre-fund a liquidity provider if the provider offers guaranteed rates. Pre-funding at a liquidity provider that offers guaranteed rates can be beneficial because the exchange rate quoted by the liquidity provider can be directly executed because funds are already stored with the liquidity provider.
If you choose to pre-fund, you will create a deposit for the transfer of fiat to the provider and then a rebalance to trade from fiat into a stablecoin.
Deposit Creation
Submit a request to the Deposit API.
- If applicable, you should specify on which
tenant's behalf you are requesting the deposit. - For the deposit, populate the following fields in the
desiredobject:-
desired.from.account_id: The identifier of the bank account fiat funds will be sent from (see above). -
desired.from.amount: Fiat amount to deposit. -
desired.from.currency: Fiat currency you are on-ramping from. -
desired.to.account_id: Identifier of the self-custodial wallet or provider ledger account to deposit stablecoins to.Ensure the
desired.to.account_idis associated with a liquidity provider that can support your requesteddesired.from.currencyanddesired.to.currency. -
desired.to.currency: Stablecoin currency you are on-ramping to. -
desired.to.network: Stablecoin network on-ramping to (optional). Not applicable ifdesired.to.account_idhas a type of "ledger".
-
Note: desired.to.amount is not auto-populated. The Deposit'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 4 scenarios:
- Depositing and on-ramping to a ledger at Circle
- Depositing fiat to a ledger at OpenFX
- Depositing and on-ramping into a self-custodial wallet via OpenFX
- Depositing and on-ramping into a self-custodial wallet via Circle
Example request (deposit and on-ramp to a ledger at Circle)
Code
In the API response, Tesser will create and return an id for the deposit request.
Example response (deposit and on-ramp to a ledger at Circle):
Code
Deposit Quote Created (deposit.quote_created)
After your POST /deposits request is accepted, Tesser plans the route the funds will take through the liquidity provider and obtains a reference exchange rate. Tesser then sends a deposit.quote_created webhook — the first webhook fired for the deposit. The payload carries the planned steps[] array (each step with status: "created") together with the populated estimated overlay at both the deposit and step levels. This webhook fires for all liquidity providers, including those with fixed 1:1 rates such as Circle, for consistency across the deposit lifecycle. The ratio of estimated.from.amount and estimated.to.amount is the indicative exchange rate for the deposit.
Example deposit.quote_created webhook (deposit and on-ramp to a ledger at Circle):
Code
Deposit Instructions for Liquidity Provider
The estimated.to.account_id field in the first step will be populated with the Tesser identifier of the provider's bank account. Submit a request to Get an account by ID with the to_account_id as the path parameter.
The response will contain the bank account information you need to deposit funds to the liquidity provider, including the account number, bank identifier code (BIC/SWIFT/Routing number).
Once you have the account information, initiate a push of funds to the liquidity provider from your bank account. This funds transfer happens outside of Tesser's system. The instructions will include the supported payment methods you can use to push funds. For example, some liquidity providers will only accept wires.
Follow provider instructions carefully
Make sure to follow the liquidity provider's deposit instructions carefully. Failing to comply with the instructions can cause delays in deposits or deposits to be rejected.
Deposit Info After Completion
Tesser will track the receipt of the fiat funds to the liquidity provider, as well as any on-ramping into stablecoins and withdrawal to a self-custodial wallet (if applicable). When a deposit is successful, the status of each step will be "completed" and the actual.* fields will be populated on the top-level of the deposit and on each step. Tesser emits a step.completed event for each step that reaches terminal completion, followed by a deposit.updated event carrying the full updated Deposit object with all overlays populated. Tesser-initiated steps (such as a final ledger → self-custodial wallet transfer) also emit step.submitted and step.confirmed ahead of step.completed; see each scenario's Webhook Events table for the exact event sequence.
Example step.completed webhook (terminal step for deposit and on-ramp to a ledger at Circle):
Code
For deposits into a ledger at Circle, transaction_hash is always null.
Example deposit.updated webhook (deposit and on-ramp to a ledger at Circle complete):
Code
Webhook Events by Scenario
Below are the webhook events you will observe for each of the four deposit scenarios in this guide.
Scenario 1 — Deposit and on-ramp to a ledger at Circle
| # | What happens | What you receive / do | Fields populated |
|---|---|---|---|
| 1 | You submit the deposit request | HTTP response with id | desired.from.amount |
| 2 | Tesser plans the steps and obtains a quote | deposit.quote_created webhook fires with 2 steps and the populated estimated overlay | Steps array; estimated.from.amount and estimated.to.amount at deposit and step level (1:1) |
| 3 | You wire USD to Circle's bank account | — | — |
| 4 | Circle receives your wire, mints USDC, and credits your Circle ledger | Two terminal step.completed events fire (one per step), followed by a deposit.updated event carrying the full Deposit with actual.* populated. Tesser observes Circle's single terminal webhook, so no intermediate step.submitted or step.confirmed events are emitted. If the wire cannot be reconciled, two step.failed events fire instead, also followed by deposit.updated. | Steps 1 & 2: actual.from.amount, actual.to.amount, all timestamps. Deposit-level actual.from.amount and actual.to.amount. Deposit complete. |
The two step.completed events for Steps 1 and 2 arrive in close succession, followed immediately by deposit.updated.
Scenario 2 — Deposit fiat to a ledger at OpenFX
| # | What happens | What you receive / do | Fields populated |
|---|---|---|---|
| 1 | You submit the deposit request | HTTP response with id | desired.from.amount |
| 2 | Tesser plans the steps and obtains a quote | deposit.quote_created webhook fires with 2 steps and the populated estimated overlay | Steps array; estimated.from.amount and estimated.to.amount at deposit and step level (1:1) |
| 3 | You wire USD to OpenFX's bank account | — | — |
| 4 | OpenFX receives your wire and credits your OpenFX ledger | Two terminal step.completed events fire (one per step), followed by a deposit.updated event carrying the full Deposit with actual.* populated. Tesser only observes OpenFX's terminal reconciliation webhook, so no intermediate step.submitted or step.confirmed events are emitted. If the wire cannot be reconciled, two step.failed events fire instead, also followed by deposit.updated. | Steps 1 & 2: actual.from.amount, actual.to.amount, all timestamps. Deposit-level actual.from.amount and actual.to.amount. Deposit complete. |
The two step.completed events for Steps 1 and 2 arrive in close succession, followed immediately by deposit.updated.
Scenario 3 — Deposit and on-ramp to a self-custodial wallet via OpenFX
| # | What happens | What you receive / do | Fields populated |
|---|---|---|---|
| 1 | You submit the deposit request | HTTP response with id | desired.from.amount |
| 2 | Tesser plans the steps and obtains a quote | deposit.quote_created webhook fires with 4 steps and the populated estimated overlay | Steps array; estimated.from.amount and estimated.to.amount at deposit and step level |
| 3 | You initiate a push of funds to the liquidity provider's bank account | — | — |
| 4 | Funds arrive at the liquidity provider and are credited to its ledger | Two terminal step.completed events fire for the fiat-transfer steps (steps 1 and 2). Tesser only observes OpenFX's terminal reconciliation webhook, so no intermediate step.submitted or step.confirmed events are emitted for these steps. | Steps 1 & 2: actual.from.amount, actual.to.amount, all timestamps. Deposit-level actual.from.amount. |
| 5 | Tesser executes the on-ramp trade at the liquidity provider | Step lifecycle events fire for the swap step | Step 3: actual.from.amount, actual.to.amount (reflects actual fill rate), all timestamps |
| 6 | Withdrawal to your wallet is initiated | step.submitted on the wallet-transfer step | Step 4: submitted_at |
| 7 | The on-chain transaction is broadcast and accepted into the network's mempool (not yet in a block) | step.confirmed on the wallet-transfer step | Step 4: confirmed_at, transaction_hash |
| 8 | The transfer reaches finality; deposit is complete | step.completed on the wallet-transfer step, and a deposit.updated event fires with the terminal Deposit object | Step 4: completed_at, actual.to.amount. Deposit-level actual.to.amount. |
Lifecycle events for Steps 1, 2, and 3 will arrive in close succession.
Scenario 4 — Deposit and on-ramp to a self-custodial wallet via Circle
| # | What happens | What you receive / do | Fields populated |
|---|---|---|---|
| 1 | You submit the deposit request | HTTP response with id | desired.from.amount |
| 2 | Tesser plans the steps and obtains a quote | deposit.quote_created webhook fires with 3 steps and the populated estimated overlay | Steps array; estimated.from.amount and estimated.to.amount at deposit and step level (1:1) |
| 3 | You wire USD to Circle's bank account | — | — |
| 4 | Circle receives your wire, mints USDC, and credits your Circle ledger | Two terminal step.completed events fire for the fiat-transfer steps (Steps 1 and 2). Tesser observes Circle's terminal webhook for each step, so no intermediate step.submitted or step.confirmed events are emitted for these steps. | Steps 1 & 2: actual.from.amount, actual.to.amount, confirmed_at, completed_at. Deposit-level actual.from.amount. |
| 5 | Tesser initiates the on-chain transfer from your Circle ledger to your self-custodial wallet | step.submitted on Step 3 | Step 3: submitted_at |
| 6 | Circle acknowledges the transfer request (off-chain confirmation; the on-chain transaction has not yet settled) | step.confirmed on Step 3 | Step 3: confirmed_at. transaction_hash remains null at this stage. |
| 7 | Circle's on-chain settlement is observed via SNS; the deposit is complete | step.completed on Step 3, followed by a deposit.updated event with the terminal Deposit object | Step 3: completed_at, transaction_hash, actual.from.amount, actual.to.amount. Deposit-level actual.to.amount. Deposit complete. |
For the Circle ledger → wallet step, transaction_hash populates at step.completed — not at step.confirmed. This timing differs from the OpenFX wallet flow (Scenario 3), where Tesser broadcasts the on-chain transaction itself and populates transaction_hash at step.confirmed.
Lifecycle events for Steps 1 and 2 will arrive in close succession.
Failure Modes for Deposits
If a deposit 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. Failed steps always have all-null actual.*. Any steps subsequent to the failure step also transition to failed. Step-level status_reasons carries the failure cause only for steps that started (transitioned past created); never-started steps — including subsequent steps after a failure — show status_reasons: [].
Example: Trade Fails to Complete at an Exchange or Ramp
If Tesser attempts the on-ramp trade at the liquidity provider and cannot get the trade to succeed before the deposit's expires_at timestamp, the deposit terminates as a fiat-only deposit. This means the source fiat remains at the liquidity provider, but the conversion into the target stablecoin did not happen. This situation can occur in scenario 3 (OpenFX with on-ramp).
What you will observe:
step.failedon the swap step (step 3 in scenario 3). Step-levelactual.*is null;status_reasonscarries the failure detail.step.failedon the subsequent wallet-transfer step (step 4 in scenario 3). This step never enteredsubmittedbecause there were no stablecoins to withdraw; step-levelactual.*is null, andstatus_reasonsis[]because the step never started — the failure cause lives at the resource level (the swap step failed, which terminated the deposit before this step could start).- The deposit's top-level
actual.frommatches the first completed step'sactual.from(the original fiat source), andactual.tomatches the last completed step'sactual.to— the OpenFX ledger holding the source USD.desired.to.currencystays as the originally requested stablecoin (client intent is never overwritten). For a USD→USDT deposit, the terminal top-level showsactual.to.currency: "USD"andactual.to.amountequal toactual.from.amount. - The fiat balance at the ledger with the liquidity provider is available to use for a rebalance.
- A
deposit.updatedwebhook fires alongside the terminalstep.failedevents, carrying the full updated Deposit object with the populatedactual.*overlay. You can use this webhook instead of GETting the deposit to observe the terminal state.
The JSON examples below show steps 3 and 4 from scenario 3.
Example webhook schema for step.failed on step 3 (swap):
Code
Example deposit.updated webhook (deposit terminated as fiat after trade give-up):
Code
Example: Fiat Deposit Never Arrives or Is Not Credited
Tesser relies on the liquidity provider for notification of when your fiat funds have been received and credited. If that notification never arrives within the deposit's expires_at window — e.g., the wire was not sent, was returned, is stuck at an intermediary bank, or was received but could not be reconciled to your account at the liquidity provider — Tesser will time the deposit out. The same pattern applies in scenarios 1, 2, 3, and 4.
What you will observe:
step.failedon every step that was still increatedstatus when the deposit expired. Step-levelactual.*is null;status_reasonsis[]because the step never started — the failure cause lives at the resource level (the deposit reachedexpires_atwhile waiting for the customer's wire to arrive).- The deposit's top-level
actual.*is all null because no step reachedstep.status = completed— no funds ever moved. - A
deposit.updatedwebhook fires alongside the terminalstep.failedevents, carrying the full updated Deposit object reflecting the terminal state. You can use this webhook instead of GETting the deposit to observe the terminal state.
The example below illustrates scenario 1 (Circle, two steps); the shape of the payload is the same in scenarios 2, 3, and 4, with one failed step entry per never-started step.
Example deposit.updated webhook (Circle deposit, scenario 1, fiat never landed):
Code