This guide is for payout creation when funds are held in self-custodial wallets.
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.
Payout Creation
To create a payout, send a POST request to the Payments API.
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 wallet) and estimated.to.account_id on the last step (the final beneficiary) — also stay null until you PATCH accounts and Tesser updates the 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: Wallet on the Tesser platform that funds will come from. Because this guide is about wallet payouts, ensure this is a managed wallet (is_managed = true).
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 will also update the route plan now that account ids are known: step-level account_id fields are populated and provider_key may be set on each step. If payment.quote_created already fired during creation with null account_ids, Tesser's payment.updated after PATCH carries the updated steps[].
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.
Payment Step Signing
Once a payout has been approved via risk review, Tesser will send a webhook with event type step.signature_requested with the necessary information to sign the on-chain payment step.
Payouts expire
Ensure you proceed with step signing prior to the expiration time, as indicated by the payout's expires_at timestamp. If a payout expires, it cannot be resurrected. To retry, create a new payout. See Expiration for details.
Example webhook schema for stablecoin payout requesting signature:
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 the Sign payment step API to execute the payment.
Wallet and recipient addresses are automatically resolved from the payment's account IDs; you only need to pass the payment 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/payments/${step.transfer_id}/steps/${step.id}/sign`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ signature: result.signature }), });
unsignedTransaction: the serialized unsigned transaction.
metadata: full signature details (stampHeaderName, stampHeaderValue, body)
Balance Check
When you submit the signature for a wallet step, Tesser also checks the balance of the wallet at desired.from.account_id. For the full status taxonomy, see Balance Statuses on the lifecycle overview.
If there are sufficient funds in the wallet, you receive a success response from the Sign payment step API, and two webhooks fire:
payment.balance_updated with balance_status: "reserved" and balance_reserved_at populated.
step.signed confirming the on-chain step was successfully signed; step status becomes signed.
Example webhook schema for stablecoin payout after successful balance check:
If the wallet has insufficient funds, the synchronous response is a 4XX with an error code, and payment.balance_updated fires with balance_status: "awaiting_funds". The payout is queued — Tesser will republish step.signature_requested (and refresh signature_requested_at) once the wallet is funded, until expires_at. Repeat the signing flow with the freshly published step to retry.
Example webhook schema for stablecoin payout after failed balance check:
After successful signing, Tesser broadcasts the payment on-chain. The step's status transitions through submitted → confirmed (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 wallet. Your organization is billed at the end of the month for accrued gas fees.
Example webhook schema for 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 at step.completed — the design defers population to the terminal step state 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 and progresses through the same step-status 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:
Below are the webhook events you will observe for each of the two wallet payout scenarios in this guide.
Both scenarios show the two-step creation flow (POST without account_ids, then PATCH to fill them) for completeness; if you supply all account_ids at creation, the PATCH row is omitted and payment.quote_created fires once with the full desired.* overlay populated.
You submit the payout request (you may supply all account_ids at creation, or only the minimum and supply the rest via PATCH)
HTTP response with id; desired.* populated with what you supplied
desired.from.amount or desired.to.amount (whichever you supplied); any account_ids you supplied
2
Tesser plans the route and obtains a quote
payment.quote_created webhook fires with the populated estimated overlay and a single on-chain transfer step. If account_ids were not supplied at creation, the quote fires with null account_ids in estimated.*/desired.* (updated route plan happens after PATCH).
Steps array; estimated.from.amount and estimated.to.amount at payout and step level
3
(only in the two-step flow) You PATCH the payout with funding_account_id, desired.from.account_id, and desired.to.account_id
payment.updated webhook fires with the now-complete desired.* overlay and updated steps[]
Top-level desired.from.account_id, desired.to.account_id, funding_account_id; updated estimated.* on steps
4
Tesser performs risk review on the destination
payment.risk_updated webhook fires with the risk outcome
risk_status; risk_status_reasons if rejected or manual_review
5
Tesser asks you to sign the on-chain transfer
step.signature_requested on the step
Step 1: status updates; signing payload supplied via the API
6
You submit the signature
POST /v1/payments/{paymentId}/steps/{stepId}/sign with { "signature": "0x..." }. Tesser checks the wallet balance and emits payment.balance_updated with balance_status: "reserved" once the signed step is accepted, then emits step.signed for the step.
The on-chain transaction is visible on the network
step.confirmed on the step
Step 1: confirmed_at
9
The transfer reaches finality; payout is complete
step.completed on the step, followed by payment.updated with the terminal Payment object
Step 1: completed_at, actual.from.amount, actual.to.amount. Payout-level actual.from.amount and actual.to.amount.
Scenario 2 — Fiat destination (wallet → fiat provider → external bank)
#
What happens
What you receive / do
Fields populated
1
You submit the payout request (you may supply all account_ids at creation, or only the minimum and supply the rest via PATCH)
HTTP response with id; desired.* populated with what you supplied
desired.from.amount or desired.to.amount (whichever you supplied); any account_ids you supplied
2
Tesser plans the route and obtains a quote
payment.quote_created webhook fires with two steps (on-chain transfer to the fiat provider, then fiat transfer from the fiat provider) and the populated estimated overlay. If account_ids were not supplied at creation, the quote fires with null account_ids in estimated.*/desired.* (route plan updating happens after PATCH).
Steps array; estimated.* at payout and step level
3
(only in the two-step flow) You PATCH the payout with funding_account_id, desired.from.account_id, and desired.to.account_id
payment.updated webhook fires with the now-complete desired.* overlay and updated steps[]
Top-level desired.from.account_id, desired.to.account_id, funding_account_id; updated estimated.* on steps
4
Tesser performs risk review on the destination
payment.risk_updated webhook fires with the risk outcome
risk_status; risk_status_reasons if rejected or manual_review
5
Tesser asks you to sign the on-chain transfer to the fiat provider
step.signature_requested on Step 1
Step 1: status updates; signing payload supplied via the API
6
You submit the signature
POST /v1/payments/{paymentId}/steps/{stepId}/sign with { "signature": "0x..." }. Tesser checks the wallet balance and emits payment.balance_updated with balance_status: "reserved" once the signed step is accepted, then emits step.signed for Step 1.
The fiat provider initiates the fiat wire to the external bank
step.submitted on Step 2
Step 2: submitted_at
11
The fiat provider confirms the transfer
step.confirmed on Step 2
Step 2: confirmed_at
12
Funds settle at the external bank; payout is complete
step.completed on Step 2, followed by payment.updated with the terminal Payment object
Step 2: completed_at, actual.from.amount, actual.to.amount. Payout-level actual.from.amount and actual.to.amount.
Failure Modes for Wallet Payouts
This section covers the common non-happy-path states a wallet payout may enter. For the full status taxonomy applicable to all funds-movement resources, see Statuses Reference.
Awaiting funds
If the wallet has insufficient funds when you submit the signature, the payout enters balance_status: "awaiting_funds" and waits until the wallet is funded or the payout expires. See Balance Check above for the full description and webhook example payloads.
Risk rejection
If risk review at payment.risk_updated returns risk_status: "rejected", the payout transitions to a terminal failed state before any signing is requested. No funds move; planned steps go directly from created to failed.
What you will observe:
A payment.risk_updated webhook fires with risk_status: "rejected" and risk_status_reasons populated with the reason codes.
A terminal payment.updated webhook follows. Top-level actual.* is all null (no movement occurred). Each step in steps[] transitions to status: "failed" with actual.* all null and status_reasons populated.
The payout is terminal in this state. To proceed, resolve the underlying risk factors and create a new payout.
Example terminal payment.updated webhook for a risk-rejected stablecoin payout:
A signed step can fail after broadcast, or a downstream provider step can fail after a customer-signed step completes.
Scenario 1 — On-chain broadcast failure
If the on-chain transfer step is signed and broadcast but fails to confirm (chain reorg, gas exhaustion, or other broadcast issue), the step transitions to failed. Per the failed-step rule, the step's actual.* is all null and status_reasons carries the failure detail. Top-level actual.* is all null because no step reached completed status (broadcast does not count as completion).
What you will observe:
A step.failed webhook fires for the on-chain step. Step-level actual.* is null; status_reasons carries the failure detail. submitted_at and transaction_hash are populated (the broadcast attempt yielded a hash before the on-chain failure); confirmed_at and completed_at are null; failed_at is populated.
A terminal payment.updated webhook follows. Top-level actual.* is all null because no step completed.
Example step.failed webhook for an on-chain broadcast failure:
If the on-chain transfer to the fiat provider succeeds but the subsequent fiat wire fails, Step 1 is completed and Step 2 is failed. The payout terminates with funds stranded at the fiat provider's holding ledger. Per the failed-step rule, Step 2's actual.* is all null. Per the first/last-completed rule, the top-level actual.from matches Step 1's actual.from (the wallet debit) and the top-level actual.to matches Step 1's actual.to (USDC at the fiat provider's holding ledger).
What you will observe:
Step 1 (step.completed) fires normally with both actual.* overlays populated. The customer's wallet has been debited; the fiat provider's holding ledger has received the USDC.
A step.failed webhook fires for Step 2. actual.* is all null; status_reasons carries the failure detail (e.g., bank rejection, OFAC issue).
A terminal payment.updated webhook follows. Top-level actual.from matches Step 1's actual.from (USDC at the source wallet); top-level actual.to matches Step 1's actual.to (USDC at the fiat provider's holding ledger). Funds are stranded at the fiat provider until manual recovery.
Example terminal payment.updated webhook for a fiat-side failure:
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:
A step.failed webhook fires for each step that didn't reach terminal state. Each failed step's actual.* is all null; status_reasons carries the expiration detail.
A payment.updated webhook fires reporting the final state. Top-level actual.* is all null because no movement occurred. balance_status is preserved as "awaiting_funds".
Example terminal payment.updated webhook for a payout that expired while awaiting_funds: