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.
Successful Payout API and Webhook Sequence
The diagrams below show the end-to-end sequence for a successful wallet payout: solid arrows are API calls you make; dashed arrows are synchronous responses, blockchain/provider responses, and webhook events. Webhook arrows are labelled type=<event> followed by any fields the event changes, then a plain-language annotation. Step numbering follows the create-then-PATCH variant (direct-create flows skip the PATCH steps), and the failure-path diagrams later on this page continue the numbering of the flow they belong to, from the step where it diverges. In multi-step payouts the strict ordering shown is illustrative — a later step may begin before an earlier one fully finalizes (see Execution); follow the emitted step.* events for actual sequencing.
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 the 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. risk_status_reasons is populated with the reason codes when the outcome is awaiting_decision, automatically_rejected, or manually_rejected; it stays empty on approvals. 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.
Payout Step Signing (step.signature_requested)
Once a payout has been auto-approved or manually 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, build a StepForSigning from the webhook step and pass it to LocalSigner.signStep. It needs three values: the unsigned_transaction, the source wallet's on-chain address (crypto_wallet_address from GET /v1/accounts/{estimated.from.account_id}), and the network. Submit the returned signature to the Sign payment step API to execute the payment. See Sign a Wallet Step for install and the full SDK reference.
Code
import { LocalSigner, type StepForSigning } from "@tesser-payments/sdk-ts";const signer = new LocalSigner({ signing: { 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 event.const step = webhookPayload.data.object;// 2. Resolve the source wallet's on-chain address.const account = await fetch( `https://api.tesser.xyz/v1/accounts/${step.estimated.from.account_id}`, { headers: { Authorization: `Bearer ${token}` } }).then((res) => res.json());// 3. Sign locally.const toSign: StepForSigning = { unsignedTransaction: step.unsigned_transaction, signWith: account.crypto_wallet_address, network: step.estimated.from.network,};const { signature } = await signer.signStep(toSign);// 4. Submit the signature to execute the payment.await fetch( `https://api.tesser.xyz/v1/payments/${step.payment_id}/steps/${step.id}/sign`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ signature }), });
signStep returns a signature to send to the Sign payment step API to execute the payment, plus metadata (stampHeaderName, stampHeaderValue, body) for debugging that stays client-side. (The Kotlin result also echoes unsignedTransaction for audit trails.)
Payout Balance Check (payment.balance_updated)
Wallet payouts debit funds from a self-custodial wallet. Tesser checks the wallet's balance synchronously inside your call to the Sign payment step API — the sign endpoint validates that the wallet at desired.from.account_id has sufficient funds before accepting your signature. See Balance Statuses on the lifecycle overview for the full status taxonomy.
If there are sufficient funds in the wallet, you receive a success response from the sign 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. Tesser fires step.submitted when it broadcasts the signed transaction; for wallet-originated steps that is an event-only marker — the persisted status goes directly from signed to confirmed. See Step Statuses in the lifecycle overview. The blockchain transaction_hash is populated on the step once confirmed, alongside gas fees in the per-step fees[] array. If the same wallet has an earlier payment awaiting broadcast, this one queues behind it — the sign call returns 200 with transaction_hash: null and the step stays signed until its turn; see signing a wallet step.
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.
Payout Fiat Step Processing
Tesser opens the off-ramp transaction with the provider as soon as the destination account is known — at creation, or when you PATCH the payment with account ids — supplying the desired.to.account_id beneficiary details against the quote and requesting the deposit address that the on-chain leg sends the USDC to. That deposit address is the wallet Tesser screens against your risk policy (see Payout Risk Review). Requesting it moves no funds and does not depend on screening — the on-chain transfer to the provider only runs once the payout is risk-approved. The provider then delivers the fiat once those funds land in its wallet. That last-mile transfer appears as the final step in the route and progresses through the same step-status path — see Step Statuses.
The fiat-leg step's transaction_hash is always null — the provider's last-mile transfer happens within the provider's books and never touches a chain.
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 the payout with account information).
Example terminal-state payment.updated webhook for a successful stablecoin payout
If a wallet payout 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.
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 Payout Balance Check above for the full description and webhook example payloads.
Risk rejection
If risk review returns a rejected outcome — either risk_status: "automatically_rejected" (policy-driven automatic rejection) or risk_status: "manually_rejected" (rejected after manual review) — 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: "automatically_rejected" or "manually_rejected" and risk_status_reasons populated with the reason codes. Top-level actual.* is all null (no movement occurred).
Each step in steps[] transitions to status: "failed" with actual.* all null. Step webhook events will fire.
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 an auto-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, insufficient gas, 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 is populated (Tesser handed off the broadcast attempt); confirmed_at, completed_at, and transaction_hash are null because the step never reached step.confirmed. failed_at is populated.
A terminal payment.updated webhook follows. Top-level actual.* is all null because no step completed. balance_status rolls back to "unreserved" and balance_reserved_at clears to null — the reservation that was held at step signing is released so the wallet's balance is no longer locked against this payout.
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. balance_status remains "reserved" and balance_reserved_at stays populated — funds actually left the wallet on Step 1, so there is no reservation rollback to the wallet's balance.
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 is [] because the step never started — the failure cause lives at the resource level (the payment timed out after remaining in awaiting_funds until expires_at).
A payment.updated webhook fires reporting the final state. Top-level actual.* is all null because no movement occurred. balance_status rolls back to "unreserved" and balance_reserved_at clears to null — the reservation held during awaiting_funds is released at terminal failure.
Example terminal payment.updated webhook for a payout that expired while awaiting_funds