This guide is for payout creation when funds are custodied via a third-party custodian (e.g. Circle).
Before creating a payout, review the Funds Movement Lifecycle and Data Model overview to understand the shared data shape, lifecycle phases, and statuses that apply to payouts and Tesser's other funds-movement resources.
To create a payout, send a POST request to the Payments API.
For payouts, you can supply all the required information in the creation API call, or only supply the minimally necessary information for payment creation and then update the payment with account information. If you take the two-step approach, Tesser publishes an additional payment.updated webhook after the PATCH succeeds — see Update payout with account information.
Payouts expire
Every payout has an expires_at timestamp. If a payout doesn't complete by then — for example, the Circle ledger never reaches reserved because funds weren't replenished, or risk review wasn't decisioned in time — it cannot be resurrected. To retry, create a new payout. See Expiration for details.
Payout creation
At a minimum, supply the following information to create the payout record in the desired object:
Currencies: desired.from.currency and desired.to.currency.
Amounts: Either desired.from.amount or desired.to.amount (not both).
Networks: For stablecoin payouts, specify both desired.from.network and desired.to.network. For fiat payouts, the receiving network is null.
You may also supply desired.from.account_id, desired.to.account_id, and funding_account_id at this stage, or omit them and PATCH them later — see Update payout with account information.
Only one of desired.from.amount or desired.to.amount may be specified in the request, never both.
In the synchronous response, you receive an id for the payout that you can use for subsequent PATCH or GET calls or to track webhook updates received for this payout. The desired.* overlay echoes what you submitted; estimated.* and actual.* are populated as the payout progresses.
After you create the payout, Tesser asynchronously plans the route — sourcing the best exchange rate (if cross-currency) and computing the sequence of steps[] needed to deliver funds. Both pieces are emitted on a single payment.quote_created webhook. See Planning on the lifecycle overview for the full picture.
The webhook populates:
The top-level estimated.* overlay. For same-currency moves, estimated.* matches desired.* 1:1. For cross-currency moves, the ratio of estimated.from.amount to estimated.to.amount is the indicative exchange rate.
The steps[] array. Each step starts with status: "created" and its own estimated.{from,to} overlay; actual.{from,to} is null until the step executes.
If you created the payout without desired.*.account_id fields, those fields stay null on the payment.quote_created payload too. Customer-side account_id fields on the steps — estimated.from.account_id on step 1 (the source Circle ledger) and estimated.to.account_id on the last step (the final beneficiary) — also stay null until you PATCH accounts and Tesser updates the route plan (see next section). Provider-ledger account ids on intermediate step boundaries may already be populated.
Example payment.quote_created webhook for a stablecoin payout (same-currency, single on-chain step):
If you did not supply account information at creation, submit a PATCH request to the Payments API to populate:
desired.from.account_id: Ledger on the Tesser platform that funds will come from. Because this guide is about custodian payouts, ensure this is a managed ledger account (type: "ledger") at a supported custodian (e.g. Circle).
desired.to.account_id: Wallet or bank account of the beneficiary that funds will be delivered to.
funding_account_id: Fiat bank account of the ultimate originator of the payout.
Because the Tesser platform serves financial institutions, the desired.from.account_id may belong to a counterparty, tenant, or the workspace. funding_account_id is therefore required to determine the ultimate originating entity of the payout to fulfill Travel Rule obligations. See Travel Rule.
Example PATCH request for stablecoin payout updated with accounts:
After the PATCH succeeds, Tesser publishes a payment.updated webhook reflecting the now-complete desired.* overlay. This is unique to payouts — deposits, withdrawals, and rebalances require all desired.* fields at creation, so they don't get this mid-lifecycle event. (A second payment.updated fires later when the payout reaches its terminal state — see Payout terminal state.)
Tesser also updates the route plan now that account ids are known: step-level account_id fields are populated and provider_key is set on each step.
Example payment.updated webhook after PATCHing accounts on a stablecoin payout:
Once you supply desired.to.account_id, Tesser screens the beneficiary's wallet (and any intermediate wallets in the route) against your organization's risk policy. The outcome is reported on the payment as risk_status via a payment.risk_updated webhook. See Risk Statuses on the lifecycle overview for the full taxonomy.
If your policy requires manual review, submit a decision via the risk review decision API or in the Tesser dashboard. After a decision is recorded, risk_reviewed_by and risk_reviewed_at are populated and risk_status transitions to manually_approved or manually_rejected.
Balance check
If the payout is approved, Tesser checks the balance of the Circle ledger at desired.from.account_id. If sufficient funds are present, balance_status becomes "reserved" and balance_reserved_at is populated. The outcome is published on a payment.balance_updated webhook. See Balance Statuses on the lifecycle overview for the full taxonomy.
If funds are insufficient, the payout enters balance_status: "awaiting_funds" and waits for funds to arrive (or for the payout to expire). See Awaiting funds under Failure Modes for the resume-on-funding behavior.
Example payment.balance_updated webhook for a stablecoin payout after a successful balance check:
After balance is reserved, Tesser instructs the custodian to broadcast the on-chain transfer step. The step's status transitions through submitted → confirmed → completed (each emits a step.* webhook — see Execution). The blockchain transaction_hash is populated on the step once submitted, and gas fees appear in the per-step fees[] array once confirmed.
Tesser sponsors gas fees for your organization, so on-chain transfers will not fail due to insufficient native-token balance in the ledger account. Your organization is billed at the end of the month for accrued gas fees.
Example step.confirmed webhook for a stablecoin payout after blockchain network confirmation:
Note: at step.confirmed, the per-step actual.* is still all-null. Step-level actual.{from,to} becomes populated only at step.completed to minimize the risk of reporting values that subsequently change.
Fiat step processing
Once the stablecoin funds reach the off-ramp provider, Tesser instructs the provider to deliver fiat to the beneficiary at desired.to.account_id. The provider's last-mile transfer appears as the final step in the route (provider_key: "alfred") and progresses through the same created → submitted → confirmed → completed path. See Step Statuses.
Payout terminal state
When the last step reaches completed, Tesser populates the top-level actual.* overlay and emits a payment.updated webhook carrying the full updated payment object. Use this event to observe the terminal outcome without polling. See Terminal State and Divergence for the full picture, including how actual.* may diverge from desired.* on failure.
For payouts that used the two-step create-then-PATCH pattern, this is the secondpayment.updated event you receive — the first fired after the PATCH supplied account ids (see Update payout with account information).
Example terminal-state payment.updated webhook for a successful stablecoin payout:
This section covers the common non-happy-path states a custodian payout may enter. For the full status taxonomy applicable to all funds-movement resources, see Statuses Reference.
Awaiting funds
If the Circle ledger at desired.from.account_id does not have sufficient funds when Tesser performs the balance check, the payout enters balance_status: "awaiting_funds" and execution halts until either funds arrive or the payout expires.
What you will observe:
A payment.balance_updated webhook fires with balance_status: "awaiting_funds" and balance_reserved_at: null.
No steps are submitted; the payout sits idle.
When sufficient funds arrive at the Circle ledger, Tesser publishes a second payment.balance_updated webhook with balance_status: "reserved" and balance_reserved_at populated. Execution then resumes automatically — no client action is required.
A step may fail during execution — for example, an on-chain transaction reverts, the off-ramp provider rejects the transfer, or the upstream custodian returns an error.
What you will observe:
A step.failed webhook fires for the affected step with status: "failed" and a populated status_reasons array.
The parent payment's terminal payment.updated webhook fires shortly after, with the failed step reflected in steps[].
Every payout has an expires_at timestamp. If the payout has not reached terminal state by then, it cannot be resumed — to retry, create a new payout. See Expiration for the full semantics.
What you will observe:
Common precondition states that lead here: balance_status stuck in awaiting_funds, manual risk decision not submitted in time, or a step that retried-and-recovered too slowly.
The payout's terminal payment.updated webhook reflects the partial state at the moment of expiry — top-level actual.* is partially populated (or fully null if no step ever submitted), and any non-terminal steps remain in their pre-execution status.
No further state transitions occur after expiry.
Example terminal payment.updated webhook for a payout that expired while awaiting_funds: