For certain payment corridors, it can make sense to combine a Deposit and an Outbound Payment into a single, orchestrated payment flow. In this scenario, you can effect this flow via the /v1/payments API.
Before walking the flow, review the Funds Movement Lifecycle and Data Model for the shared data shape, lifecycle phases, and status taxonomy. This guide describes how Tesser composes those shared primitives into a single end-to-end orchestration.
Prerequisites
Before initiating a combined deposit and payout, ensure:
- Your workspace at Tesser is configured with an on-ramp liquidity provider for your source fiat currency and an off-ramp partner for your destination fiat currency.
- Your first-party fiat bank account is registered with the on-ramp partner and with Tesser (as an account record your organization owns). All on/off-ramp interactions with liquidity providers must be first-party, so the on-ramp partner only accepts inbound funds that arrive from your own bank — never directly from the originator's bank.
- Your organization holds a managed wallet belonging to your Tesser workspace. This wallet will be an omnibus wallet for all of your customer's flows.
- Managed wallet contains buffer funds. See Buffer funds.
- The counterparties and their bank accounts are registered at Tesser. Register the originator and the beneficiary as counterparties (see Create a Counterparty), along with the originator's fiat bank account and the beneficiary's fiat bank account (see Create an Account). This setup happens once per counterparty and is re-used across subsequent payments.
The originator's funds reach your organization's bank through your own collection flow — that collection is outside Tesser. The Tesser-orchestrated flow begins once your organization holds the originator's fiat in your first-party bank account and is ready to push that fiat to the on-ramp partner.
Workflow
A combined deposit and payout progresses through these phases:
- Create the payment and request an FX rate quote —
POST /v1/paymentswith the currency pair. Receive a payment ID synchronously. Tesser then plans the transfer steps asynchronously and fires a singlepayment.quote_createdwebhook carrying the end-to-end FX rate (on-ramp cost + off-ramp cost + Tesser's fee) and the plannedsteps[]. - Submit accounts — once the originator accepts the rate,
PATCH /v1/payments/{paymentId}with the bank account you previously registered with the on-ramp liquidity provider (desired.from.account_id), the originator's bank account (funding_account_id), which is the ultimate source of funds for Travel Rule purposes, and the beneficiary's US bank account (desired.to.account_id). Tesser attaches these accounts to the payment and the relevant steps, then fires apayment.updatedwebhook. - Execute the payment — the six steps progress in order: You push the desired.from.currency to the on-ramp liquidity provider bank account via a fiat funds transfer; the on-ramp credits the funds to your ledger; the on-ramp swaps the fiat currency for stablecoins; the on-ramp transfers the stablecoin on-chain to your wallet; you sign and submit the on-chain transfer to the off-ramp's deposit address; the off-ramp screens the payment for compliance and releases the fiat payout to the beneficiary's bank.
- Reach terminal state — the payment's
actualoverlay populates and a finalpayment.updatedwebhook fires alongside the laststep.completed.
The Six-Step Model
A combined deposit and payout is one Payment resource whose steps[] array contains six funds-transfer steps. The first four mirror the deposit-to-wallet pattern in Deposit funds via a liquidity provider (Scenario 3); the last two mirror the fiat-payout pattern in Create a payout (from a wallet). The table below summarizes what your organization sees; the per-step sections later in this guide walk through each in detail.
| Step | Description | Step type | Initiated by | You sign? |
|---|---|---|---|---|
| 1 | Fiat transfer from your bank account to the on-ramp partner's bank account | transfer | You (push fiat to the on-ramp partner via your local push-payment rail) | No |
| 2 | Internal credit of the fiat from the on-ramp partner's bank account to your ledger account at the on-ramp partner | transfer | On-ramp partner (internal credit, observed by Tesser) | No |
| 3 | On-ramp from fiat to stablecoin within your ledger account at the on-ramp partner | swap | Tesser (dispatched to the on-ramp partner) | No |
| 4 | On-chain transfer of stablecoin from your ledger account at the on-ramp partner to your managed wallet at Tesser | transfer | Tesser (dispatched to the on-ramp partner) | No |
| 5 | On-chain transfer of stablecoin from your managed wallet to the off-ramp partner's deposit address | transfer | You (signed via your wallet provider) | Yes |
| 6 | Fiat payout from the off-ramp partner to the beneficiary's bank account | transfer | Off-ramp partner | No |
A swap step has the same account_id on both sides because the currency exchange happens within a single provider ledger; a transfer step has distinct account_ids on the two sides. See Steps.
Example Scenario
The remainder of this guide walks through a concrete example: a combined deposit and payout from Mexico to the United States, in which an originator in Mexico pays a beneficiary in the United States. The example uses the following corridor configuration:
- Source currency: MXN (Mexican peso)
- Destination currency: USD
- Local push-payment rail (step 1): SPEI, Mexico's real-time push-payments system
- On-ramp partner: OpenFX, which holds your MXN ledger and swaps MXN to USDC
- Stablecoin: USDC
- On-chain network: Polygon
- Wallet provider: Turnkey
- Off-ramp partner: Circle Payments Network (CPN), which receives USDC at a per-payment deposit address and delivers USD to the beneficiary's bank
The patterns shown apply to any corridor with an equivalent set of partners and a local push-payment rail. Adapt the specifics to your own corridor when applying this guide.
Phase 1: Request an FX Rate Quote
When the originator initiates a payment request, your organization creates a Payment at Tesser to request an end-to-end FX rate quote. The POST /v1/payments call supplies the currency pair and one side of the amount; account fields are deferred to the PATCH in Phase 2.
There is no separate quote API endpoint. Quotes are obtained by creating a payment record.
In this example the originator specifies the USD amount the beneficiary should receive, so the POST body sets desired.to.amount and leaves desired.from.amount null.
POST /v1/payments — request body
Tesser responds synchronously with the new Payment record. At this point the desired overlay echoes what you submitted; estimated.*, actual.*, risk_status, balance_status, and steps[] are all empty or null because planning runs asynchronously and has not completed yet.
POST /v1/payments — synchronous response
Tesser then asynchronously plans the six-step path through the providers to compute the end-to-end cost and FX rate (on-ramp via OpenFX, off-ramp via CPN, Tesser's fee). Tesser fires a single payment.quote_created webhook carrying the MXN amount the originator needs to fund (estimated.from.amount) and the planned steps.
Some of the account ids can be populated at this stage based on which providers would be used to fulfill the payment and how Tesser will orchestrate funds flow through your managed wallet.
Webhook — payment.quote_created
The ratio of estimated.from.amount to estimated.to.amount is Tesser's all-in end-to-end FX rate. You can surface this rate (with any markup you may add) to the originator in your UI. The originator either accepts the rate and continues to Phase 2, or declines and the payment expires unused at expires_at.
Phase 2: Submit Payment Details
Once the originator accepts the rate, via a PATCH call your organization now populates the identifiers for your organization's Mexican bank account (desired.from.account_id), the originator's Mexican bank account (funding_account_id), and the beneficiary's US bank account (desired.to.account_id). This finalizes the payment's desired overlay.
Populate payment_description_wire in the payment_info object with information about the payment that you wish to appear on the beneficiary's bank account statement (e.g. invoice number, account number).
Tesser will always include the originator's name in the payment instruction to the off-ramp. Where possible, the originator will be designated as the sender of the fiat payout. If that is not possible, Tesser will include the originator's name in the off-ramp's payment description field in addition to the information you include in payment_description_wire.
PATCH /v1/payments/{paymentId} — request body
desired.from.account_idis your organization's MX bank account — the account that is pre-registered with OpenFX, from which fiat is pushed in step 1. OpenFX (and other liquidity providers) only accept inbound funds that arrive from an account with your organization's name as the account holder, so this account must be your own bank account, not the originator's.
Tesser does not model any funds movement from your customer's account to your named bank account. However, you should account for this in your own funds orchestration workflow.
funding_account_idis the originator's MX bank account, registered in advance (see Prerequisites). This top-level field identifies the ultimate originating entity of the payout for Travel Rule purposes — see Travel Rule.desired.to.account_idis the beneficiary's US bank account, where CPN delivers the USD in step 6.
The PATCH succeeds synchronously and Tesser publishes a payment.updated webhook reflecting the populated account IDs on the desired overlay and on the affected steps. The six steps were already planned in Phase 1; PATCH attaches the accounts you just submitted — step 1's from.account_id and step 6's to.account_id. Step 5's to.account_id and step 6's from.account_id remain null until Tesser requests the per-payment CPN deposit address in Phase 5.
Webhook — payment.updated (accounts attached)
Phase 3: Execute the Deposit (Steps 1–4)
The first four steps move the originator's fiat into a stablecoin balance in your managed wallet. Steps 1 and 2 are observed by Tesser when the on-ramp partner notifies it of receipt — neither fires intermediate step.submitted or step.confirmed events; both transition created → completed directly, batched from a single notification (see Step 2). Steps 3 (swap) and 4 (on-chain withdrawal) are dispatched by Tesser to the on-ramp partner — each fires the full step.submitted → step.confirmed → step.completed lifecycle as Tesser executes, the on-ramp partner acknowledges, and the action settles. Step 4's transaction_hash populates at step.confirmed; see Deposit funds via a liquidity provider — Scenario 3 for the canonical event timing.
Look up the on-ramp partner's deposit instructions
Before pushing the originator's MXN to OpenFX in step 1, retrieve OpenFX's bank account details from Tesser. Step 1's estimated.to.account_id — carried on the payment.updated webhook once you attach accounts in Phase 2 — holds the Tesser identifier for the OpenFX bank account that will receive the SPEI push. Call Get an account by ID with that account ID to retrieve the bank account information needed for the push.
Follow On-Ramp's deposit instructions carefully
Failing to comply with the on-ramp's deposit instructions can cause delays or rejection of the deposit to the provider's platform. Use the bank account details from the API response exactly as returned.
Step 1: Fiat transfer of MXN to the OpenFX bank account
Your organization pushes MXN via SPEI from its registered first-party Mexican bank account to the OpenFX bank account associated with your OpenFX ledger. This push happens outside Tesser — Tesser observes the deposit when OpenFX notifies it.
Lifecycle
Because the funds transfer happens outside of Tesser's system, submitted_at stays null; only confirmed_at and completed_at populate (from OpenFX's notification timestamp).
Webhook — step.completed (step 1)
Step 2: Transfer of MXN from the OpenFX bank account to your MXN ledger at OpenFX
Once the inbound SPEI lands at the OpenFX bank account in step 1, OpenFX credits the funds to your MXN ledger account at OpenFX. This is an internal OpenFX move that Tesser observes; your organization does not need to take any action between step 1 and step 2.
Lifecycle
Like step 1, this step's submitted_at stays null; only confirmed_at and completed_at populate from OpenFX's notification. Step 2's step.completed event signals that your MXN ledger balance at OpenFX is now available for the swap in step 3.
A single OpenFX inbound-credit webhook triggers Tesser to transition both step 1 and step 2 to completed within the same database transaction. The step.completed events fire together (step 1 first, then step 2), followed by a single payment.updated reflecting the new top-level state.
Step 3: On-ramp from MXN to USDC at your OpenFX ledger
Tesser executes the currency swap via OpenFX, which exchanges the MXN for USDC inside your OpenFX ledger account. Because this is an in-ledger currency exchange, both sides of the step share the same account_id and the step is typed as swap.
Lifecycle
The events fire as Tesser executes the swap (step.submitted), OpenFX acknowledges the trade (step.confirmed), and the trade settles in your ledger (step.completed). actual.from.amount matches estimated.from.amount; actual.to.amount reflects the rate OpenFX actually executed at — which may differ from the estimated quote if rates moved between planning and execution.
Step 4: On-chain transfer of USDC to your managed wallet
Tesser executes the withdrawal from OpenFX, which broadcasts the on-chain transfer of USDC from its custody to your managed wallet on Polygon. Your organization does not sign for this transfer — OpenFX signs from its own ledger. The events fire as Tesser executes the withdrawal (step.submitted), OpenFX broadcasts the transaction and it is accepted into the network's mempool (step.confirmed, with transaction_hash populated), and the transfer reaches on-chain finality (step.completed).
Lifecycle
Webhook — step.completed (step 4)
Phase 4: Pre-Payout Checks
Once step 4 completes, two pre-payout checks gate the payout side of the payment. Both checks follow the conventions in the Pre-Execution Checks section of the lifecycle overview.
Risk check
Tesser runs the risk check on the off-ramp partner's per-payment deposit address and publishes payment.risk_updated with risk_status: "auto_approved" (or awaiting_decision if your organization's compliance policy requires manual review). See Failure Modes for what happens if the risk check rejects the payment.
Balance check
Because this flow combines a Deposit and Payment, the balance check occurs on the managed wallet that received USDC from the on-ramp partner in step 4. The check fires synchronously when you sign and submit step 5: Tesser verifies there is enough USDC in the wallet to fulfill the payout and emits payment.balance_updated with balance_status: "reserved" (step 5 proceeds) or balance_status: "awaiting_funds" (step 5 waits until the wallet is topped up). The buffer funds your managed wallet carries are what determine whether the check resolves reserved or awaiting_funds.
Buffer funds
In the event that funds sent from your organization's bank account (desired.from.account_id) are deposited to your ledger at the on-ramp after the initial FX rate quote from the on-ramp has expired, Tesser will obtain a new quote from the on-ramp.
The re-obtained quote can move the FX rate in either direction:
- Favorable to the originator — the swap produces more stablecoin than originally planned. The surplus remains in your managed wallet after step 4 completes; Tesser does not refund it to the originator.
- Adverse to the originator — the swap produces less stablecoin than originally planned. Step 5 still needs to deliver the full quoted amount to the off-ramp partner's deposit address to fulfill the payout. To make up the shortfall, Tesser draws a buffer amount from your managed wallet's pre-existing USDC balance (the same wallet identified by step 4's
estimated.to.account_id, which is also step 5'sestimated.from.account_id). When the buffer is applied, step 5'sactual.from.amountwill exceed itsestimated.from.amountby the buffer amount drawn.
Maintain a USDC buffer balance in your managed wallet
Because adverse FX moves require Tesser to pull buffer USDC from your managed wallet's pre-existing balance, your organization must maintain a USDC reserve in this wallet sufficient to cover the maximum plausible shortfall across in-flight payments. The size of the reserve depends on the volatility of your corridor and the time window between quote issuance and on-ramp settlement.
In the rare case that your wallet's buffer is depleted — for example, by a string of adverse FX moves across many in-flight payments — Tesser emits payment.balance_updated with balance_status: "awaiting_funds". Step 5 does not proceed until you top up the wallet; Tesser re-runs the balance check once funds arrive.
Phase 5: Execute the Payout (Steps 5–6)
The last two steps move the stablecoin from your managed wallet through the off-ramp partner and out to the beneficiary's bank as fiat. Step 5 is the only step your organization signs (via your wallet provider); step 6 is driven by the off-ramp partner.
Step 5: On-chain transfer of USDC from your wallet to CPN's deposit address
This is the only step your organization signs. Tesser requests a per-payment deposit address from Circle Payments Network, populates step.estimated.to.account_id with the CPN deposit-address account, and emits step.signature_requested so your organization can sign the on-chain transfer locally via Turnkey.
When your organization submits the signed step, Tesser runs the balance check on your wallet and submits the signed step to the off-ramp partner, which broadcasts the on-chain transfer. The synchronous response from the sign-submit endpoint plus the subsequent payment.balance_updated webhook reflect the balance reservation outcome.
Webhook — step.signature_requested (step 5)
Your organization constructs the signed transaction and submits it. See Sign a Wallet Step for the sign-endpoint body shape and signing conventions — the same POST /v1/payments/{paymentId}/steps/{stepId}/sign pattern applies here.
Once the off-ramp partner broadcasts the transfer, step.confirmed fires; CPN provides the transaction_hash once the transaction settles, so it is populated on step.completed (when CPN reports receipt at the deposit address).
Step 5's on-chain transfer also carries a blockchain network (gas) fee, reported as a gas line item (denominated in USDC) in step 5's fees[]. Unlike a typical execution-time gas charge, the off-ramp partner reports this gas amount in its quote — so it is known upfront and is folded into the end-to-end rate the originator accepts in Phase 1. With CPN, gas is abstracted: your organization does not need to hold native tokens (such as POL on Polygon) to cover it — the fixed USDC amount from the quote is withdrawn at settlement.
Tesser's fee
Tesser's fee is denominated in USDC and reported as a tesser_fee line item in step 5's fees[]:
Tesser fee — step 5 fees[] entry
This entry is informational: unlike the off-ramp fee on step 6, it is not deducted from the amount step 5 delivers to CPN — the full payout still reaches the deposit address.
Tesser's fee is folded into the end-to-end rate at quote time (Phase 1), so the MXN you collect from the originator already covers it. That MXN is sent to OpenFX in full, swapped to USDC, and lands in your managed wallet — which is why step 4 credits your wallet with more USDC (10015.0125) than step 5 sends to CPN (10010.005). The difference (5.0075 USDC, the Tesser fee) remains in your managed wallet. Tesser holds its fee in your wallet so that your organization does not incur any FX rate risk.
Tesser settles these accrued fees on a monthly basis, so the fee USDC stays in your wallet between settlement cycles. Track it separately from your spendable balance so it is available at settlement.
Step 6: USD payout from CPN to the beneficiary's US bank
After USDC arrives at the CPN deposit address, CPN runs its own compliance and screening checks, debits the USDC from the deposit-address ledger, and pushes the USD to the beneficiary's US bank account.
Lifecycle
Your organization does not sign for or initiate step 6 — CPN drives the off-ramp end-to-end. submitted_at populates when Tesser marks the payout step submitted; confirmed_at when CPN observes the USDC arrive at the deposit address; completed_at when CPN settles the USD to the beneficiary's bank. The step's actual.from.currency is USDC and actual.to.currency is USD (the currency change is implicit on the transfer step's overlays).
The off-ramp fee appears as a single bfi_transaction_fee line item on step 6's fees[]. It will include any variable fee for off-ramping and a payout method transaction fee, as applicable. It is surfaced on the payment.quote_created webhook (Phase 1) so you can reflect the all-in cost to the originator before they accept the rate.
Terminal State
When step 6 completes, Tesser populates the payment's top-level actual overlay and fires a final payment.updated webhook. On success, actual.to.amount matches desired.to.amount (the beneficiary receives exactly the USD the originator requested) and actual.from.amount matches estimated.from.amount (the MXN the originator funded against the quoted rate).
For a worked example of a terminal-state outbound payment with all step overlays populated end-to-end, see the outbound payment example in the lifecycle and data model overview. A combined deposit and payout carries six steps rather than two but is otherwise the same shape.
Failure Modes
When a payment reaches a terminal outcome — all six steps completed, or a step failed along the way — Tesser populates the top-level actual overlay once and never overwrites it, and fires a payment.updated webhook carrying it (on success and on failure alike). The top-level actual.from matches the first completed step's actual.from, and actual.to matches the last completed step's actual.to. If no step completed, the top-level actual.* stays all-null, and desired.* / estimated.* describe the originally requested and quoted amounts. Failed steps always have all-null actual.*.
Downstream-step cascade behavior
When any step transitions to failed, Tesser automatically cascades the failure to all downstream steps that are still in created or signature_requested state. Each cascaded step transitions to failed with failed_at populated and a status_reasons entry marking it as a downstream casualty — a placeholder error code (payments-XXXX) with a message such as Prior step in sequence failed — distinct from the error on the step that actually failed, so you can tell the originating failure apart from the steps it knocked over. Tesser fires a step.failed webhook for each failed step — the step that actually failed and every cascaded downstream step — so a failure at step k produces step.failed events for step k through step 6, followed by a payment.updated carrying the terminal top-level actual overlay (described above). Steps that have already completed remain in completed; steps that are mid-execution (e.g. submitted or confirmed) are not cascaded. The downstream-step cascade is the same mechanism used across all funds-movement resource types — see the Cascade behavior note in the lifecycle overview. Each failure mode below shows which downstream steps cascade in that scenario.
Originator funds never arrive at the OpenFX bank account
If the originator's MXN push to the OpenFX bank account never arrives, step 1 cannot complete. If expires_at is reached without funds arriving, the payment expires: step 1 transitions to failed with status_reasons: [] (the step never began executing — the cause is the expiry, tracked at the payment-resource level), and steps 2–6 cascade to failed, each carrying the Prior step in sequence failed reason. There is no dedicated payment.expired event; the expired state is reflected in the payment's terminal actual overlay.
Step 5 sign timeout
Step 5 requires your organization to cryptographically sign and submit the on-chain transfer. If you do not sign before expires_at, the payment expires:
- Step 5 transitions to
failedwithstatus_reasons: [](no prior signing or broadcast — the cause is the expiry, tracked at the resource level). - Step 6 cascades to
failedcarrying thePrior step in sequence failedreason. - Steps 1–4 stay
completed; the USDC remains in your wallet. Your organization can use it for a future payment (subject to a fresh quote). - There is no dedicated
payment.expiredevent; the expired state is reflected in the payment's terminalactualoverlay.
CPN compliance rejection at step 6
CPN runs its own compliance and screening on the beneficiary and the payment amount before releasing the USD wire. If CPN rejects the payout (sanctions, beneficiary KYC failure, amount-limit breach), step 6 transitions to failed and Tesser fires step.failed. Because step 6 had started executing, its status_reasons carries the failure cause echoing CPN's rejection reason:
Step 6 status_reasons on CPN rejection
No cascade applies here — step 6 is the terminal step.
When this happens, the USDC sits at the CPN deposit address pending manual reconciliation between Tesser and CPN. Your organization should treat this as a customer-service event and contact Tesser support; the payment cannot be auto-completed to a different beneficiary.
Webhook Events by Phase
The following table summarizes the webhook events fired through the lifecycle of a successful combined deposit and payout.
| Phase | Webhook event | Carries |
|---|---|---|
| 1 — Quote | payment.quote_created | End-to-end FX rate via estimated.from.amount, plus the planned steps[] (six steps, all status: "created"); step accounts not yet known are null |
| 2 — PATCH | payment.updated | Account IDs populated on the desired overlay and attached to step 1 (from) and step 6 (to) |
| 3 — Step 1 | step.completed | OpenFX notified Tesser of MXN receipt at the OpenFX bank account (the SPEI push from your organization landed) |
| 3 — Step 2 | step.completed | OpenFX credited the funds to your MXN ledger at OpenFX |
| 3 — Step 3 | step.submitted → step.confirmed → step.completed | Tesser dispatched the swap to OpenFX; OpenFX acknowledged and executed the MXN→USDC trade within your OpenFX ledger |
| 3 — Step 4 | step.submitted → step.confirmed → step.completed | Tesser dispatched the withdrawal to OpenFX; OpenFX broadcast the on-chain transfer to your managed wallet on Polygon (transaction_hash populated at step.confirmed); on-chain finality reached at step.completed |
| 4 — Risk | payment.risk_updated | Off-ramp destination wallet risk screening outcome (risk_status) |
| 5 — Step 5 sign | step.signature_requested | Tesser has populated CPN's deposit address and is awaiting your signature |
| 5 — Step 5 balance | payment.balance_updated | Balance check outcome on your managed wallet, synchronized to step 5 sign-submit (reserved or awaiting_funds) |
| 5 — Step 5 progression | step.signed → step.submitted → step.confirmed → step.completed | Signed payload broadcast via Turnkey, confirmed on-chain, USDC arrived at CPN deposit address on Polygon |
| 5 — Step 6 | step.submitted → step.confirmed → step.completed | Tesser marked the payout step submitted (submitted_at); CPN observed the USDC at the deposit address (confirmed_at); CPN settled the USD wire to the beneficiary (completed_at) |
| Terminal | payment.updated | Top-level actual overlay populated; payment in terminal state |