# Tesser Documentation > Complete documentation for Large Language Models --- ## Document: Treasury Updates Webhook events fired during the deposit, withdrawal, and rebalance lifecycle URL: /webhooks/treasury-updates # Treasury Updates ## Deposit Events | Event | Fired when | | --- | --- | | `deposit.quote_created` | Route planning is complete; execution steps are created with exchange rate info | | `deposit.updated` | General notification that a field on the deposit has been updated | | `deposit.expired` | The deposit reached its expiration date-time and is no longer actively processing | OpenFX can report a completed fiat deposit that was not created through the Tesser API. In that case, Tesser creates the completed Deposit and emits `step.completed` for its steps followed by `deposit.updated`; there is no preceding `deposit.quote_created`. See [OpenFX Fiat Deposits Initiated Outside Tesser](/how-tos/manage-your-treasury/deposit-funds-via-a-liquidity-provider#openfx-fiat-deposits-initiated-outside-tesser). ## Withdrawal Events | Event | Fired when | | --- | --- | | `withdrawal.quote_created` | Route planning is complete; execution steps are created with exchange rate info | | `withdrawal.balance_updated` | Balance check result is available (`reserved` or `awaiting_funds`) | | `withdrawal.updated` | General notification that a field on the withdrawal has been updated | | `withdrawal.expired` | The withdrawal reached its expiration date-time and is no longer actively processing | ## Rebalance Events | Event | Fired when | | --- | --- | | `rebalance.quote_created` | Route planning is complete; execution steps are created with exchange rate info | | `rebalance.balance_updated` | Balance check result is available (`reserved` or `awaiting_funds`) | | `rebalance.updated` | General notification that a field on the rebalance has been updated | | `rebalance.expired` | The rebalance reached its expiration date-time and is no longer actively processing | ## Step Events All other lifecycle updates for a deposit, withdrawal, or rebalance fire at the step level as each step progresses: | Event | Fired when | | --- | --- | | `step.signature_requested` | Tesser has prepared an unsigned transaction and is waiting for your signature. The payload carries it as `unsigned_transaction` | | `step.signed` | Step has been cryptographically signed | | `step.submitted` | Step has been submitted for execution | | `step.confirmed` | Step execution is confirmed on-chain or by the partner | | `step.completed` | Step finished successfully | | `step.failed` | Step encountered an error and could not complete | | `step.updated` | General notification that a field on the step has been updated | The `data.object` for step events is a [DepositStep](/api/~schemas#deposit-step), [WithdrawalStep](/api/~schemas#withdrawal-step), or [RebalanceStep](/api/~schemas#rebalance-step), depending on the resource the step belongs to. The three differ in three ways only: - **The parent-resource id** — `deposit_id`, `withdrawal_id`, or `rebalance_id`. - **`unsigned_transaction`** — defined on withdrawal and rebalance steps; deposit steps have no such field. Where it exists it is populated only while a step your organization signs is sitting in `signature_requested` status; on every other step and status it is `null`. - **`provider_key`** — `circle_mint`, `openfx`, or `turnkey` on deposit, withdrawal, and rebalance steps. A rebalance remains first-party funds movement even when a liquidity provider executes a transfer or swap between your managed accounts. Signing applies only to steps your organization signs — on-chain steps out of a self-custodial wallet, where `provider_key` is `turnkey`. Deposits are never signed by you, so `step.signature_requested` and `step.signed` do not fire on a deposit. To sign a withdrawal or rebalance step, see [Sign a Wallet Step](/how-tos/sign-a-wallet-step). ## Expiration When a deposit, withdrawal, or rebalance reaches `expires_at` before completing, its unfinished steps are resolved and two kinds of event fire: - `step.failed`, one per step still in `created` or `signature_requested` — the only two statuses expiration fails. Each carries `failed_at` and a `status_reasons` entry with `error_code` [`transfers-9204`](/overviews/errors#transfers) ("The transfer expired before it could be executed"). A step that has reached `signed`, `submitted`, or `confirmed` is in flight on-chain, so expiration leaves it to resolve on its own outcome instead of failing it. - `.expired` — `deposit.expired`, `withdrawal.expired`, or `rebalance.expired` — carrying the final state of the resource. No `.updated` follows `.expired` — the expiration event itself carries the final top-level values, including whatever the `actual` overlay settled to. Steps never emit an event of their own for expiration; it is a property of the parent resource, and a failed step's outcome surfaces as `step.failed`. For how the resource's fields look at expiration, see [Expiration](/overviews/funds-movement-lifecycle-and-data-model#expiration). ## Payload The `data.object` for each top-level Treasury event contains the full resource. See [General](./general#example-payloads) for the envelope shape and example payloads, and the [Deposit schema](/api/~schemas#deposit), [Withdrawal schema](/api/~schemas#withdrawal), and [Rebalance schema](/api/~schemas#rebalance) for the full field reference. Unlike payments, Treasury resources carry no compliance arrays — there is no `risk_status`, `risk_status_reasons[]`, `participants[]`, or `provider_metadata[]` on a deposit, withdrawal, or rebalance. Withdrawals and rebalances do carry `balance_status`; deposits do not. --- ## Document: Payment Updates Webhook events fired during the payment lifecycle URL: /webhooks/payment-updates # Payment Updates ## Event Types | Event | Fired when | | --- | --- | | `payment.created` | A payment has been created in Tesser's system | | `payment.quote_created` | Route planning is complete; execution steps are created with exchange rate info | | `payment.balance_updated` | Balance check result is available (`reserved` or `awaiting_funds`) | | `payment.risk_updated` | Risk status has changed. See [Risk Statuses and Webhooks](/overviews/wallet-risk-screening#risk-statuses-and-webhooks) for the six `risk_status` values and what each one means | | `payment.updated` | General notification that a field on the payment has been updated. | | `payment.expired` | The payment reached its expiration date-time and is no longer actively processing | For a detailed breakdown of what each event means in the context of a payout, see [Funds Movement Lifecycle and Data Model](/overviews/funds-movement-lifecycle-and-data-model#lifecycle-phases). ## Step Events Once steps are created (`payment.quote_created`), individual step lifecycle events fire as each step progresses: | Event | Fired when | | --- | --- | | `step.signature_requested` | Tesser has prepared an unsigned transaction and is waiting for your signature. The payload carries it as `unsigned_transaction` | | `step.signed` | Step has been cryptographically signed | | `step.submitted` | Step has been submitted for execution | | `step.confirmed` | Step execution is confirmed on-chain or by the partner | | `step.completed` | Step finished successfully | | `step.failed` | Step encountered an error and could not complete | | `step.updated` | General notification that a field on the step has been updated | The `data.object` for Payment step events is a [PaymentStep](/api/~schemas#payment-step) resource. `step.signature_requested` and `step.signed` fire only for steps your organization signs — on-chain steps out of a self-custodial wallet, where `provider_key` is `turnkey`. Steps executed by a custodian or liquidity provider go straight to `step.submitted`. To sign, see [Sign a Wallet Step](/how-tos/sign-a-wallet-step) and [Payout Step Signing](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet#payout-step-signing-stepsignature_requested). ## Expiration When a payment reaches `expires_at` before completing, its unfinished steps are resolved and two kinds of event fire: - `step.failed`, one per step still in `created` or `signature_requested` — the only two statuses expiration fails. Each carries `failed_at` and a `status_reasons` entry with `error_code` [`transfers-9204`](/overviews/errors#transfers) ("The transfer expired before it could be executed"). A step that has reached `signed`, `submitted`, or `confirmed` is in flight on-chain, so expiration leaves it to resolve on its own outcome instead of failing it. - `payment.expired`, carrying the final state of the payment. No `payment.updated` follows `payment.expired` — the expiration event itself carries the final top-level values, including whatever the `actual` overlay settled to. Steps never emit an event of their own for expiration; it is a property of the payment, and a failed step's outcome surfaces as `step.failed`. For how the payment's fields look at expiration, see [Expiration](/overviews/funds-movement-lifecycle-and-data-model#expiration). ## Inbound Payments The events above describe an outbound payout. Inbound Payments use different event sequences depending on whether funds arrive at a managed wallet or an OpenFX ledger. ### Managed Wallet Inbound Payments Funds arriving at one of your managed wallets from an external sender use the same event types, with these differences: - **No planning events.** There is no quote and no `PATCH`, so `payment.quote_created` never fires. `payment.created` arrives with the `estimated` overlay already populated from the observed on-chain transaction. - **No signing events.** The external sender signed and broadcast the transaction, so `step.signature_requested` and `step.signed` never fire. - **One step.** The payment has exactly one `transfer` step, created in `confirmed` status, which moves to `completed` when the block finalizes. - **Risk screening targets the sender and does not gate the credit.** `payment.risk_updated` reports the screening outcome for the sending wallet. It runs in parallel with on-chain finalization, so it can arrive before or after `step.completed`, and the receiving wallet's available balance updates on finalization regardless of the outcome. For the full inbound event sequence and payloads at each stage, see [Receive an Inbound Payment to a Wallet](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet). ### OpenFX Ledger Inbound Payments When OpenFX reports an on-chain deposit that does not match a submitted withdrawal or rebalance, Tesser creates a completed inbound Payment for the receipt and adds the amount reported by OpenFX to the ledger's `available_balance`. - **One terminal event.** `payment.created` is the only lifecycle webhook. Its Payment contains one completed `transfer` step, with `estimated.*` and `actual.*` populated and `provider_key: "openfx"`. Because the Payment is already complete when Tesser creates it, no planning, signing, step-status, or `payment.updated` events follow. - **No balance check.** `balance_status` is `null`, and the received amount is available when `payment.created` arrives. - **No wallet-risk screening.** `risk_status` is `unchecked`; no `payment.risk_updated` event is emitted for this provider-ledger receipt. If an OpenFX deposit could match more than one submitted flow, Tesser does not create an inbound Payment or update the ledger's tracked balance. The submitted flows remain pending and require support to resolve. ## Payload The `data.object` for each top-level Payment event contains the full payment resource, including its steps. See [General](./general#example-payloads) for the envelope shape and an example payload, and the [Payment schema](/api/~schemas#payment) for the full field reference. For payments that undergo compliance screening, three arrays carry its output. They are populated by the time `payment.risk_updated` fires and are repeated on every later payment event: | Field | Carries | Reference | | --- | --- | --- | | `risk_status_reasons[]` | The findings behind the current `risk_status`, each marked decisive or not | [Reading risk_status_reasons[]](/overviews/wallet-risk-screening#reading-risk_status_reasons) | | `participants[]` | The parties Tesser identified in the flow of funds, each with the record it was reconciled to and the reconciliation outcome | [Payment Participants](/overviews/payment-participants) | | `provider_metadata[]` | Provider-defined wallet screening and Travel Rule responses for analytics and audit; screening data can reference networks other than the payment network | [Raw Screening Data](/overviews/wallet-risk-screening#raw-screening-data) | ## Key Status Fields For details on how `risk_status` and `balance_status` evolve through a payout, see [Risk Statuses](/overviews/funds-movement-lifecycle-and-data-model#risk-statuses) and [Balance Statuses](/overviews/funds-movement-lifecycle-and-data-model#balance-statuses). --- ## Document: General Common envelope structure shared by all Tesser webhook events URL: /webhooks/general # General All Tesser webhook events share a common envelope structure. The envelope wraps event-specific data in a consistent format so your handler can route and process events uniformly. ## Envelope Fields | Field | Type | Description | | --- | --- | --- | | `id` | string | Unique event identifier | | `type` | string | Event type in `scope.action` format (e.g. `payment.quote_created`) | | `created_at` | string | ISO 8601 timestamp of when the event was created | | `data` | object | Contains the event-specific payload under `data.object` | ## Event Type Format Event types follow the pattern **`scope.action`**. Current scopes are `payment`, `deposit`, `withdrawal`, `rebalance`, and `step`. See [Payment Updates](./payment-updates) and [Treasury Updates](./treasury-updates) for event details. ## Example Payloads The `data.object` carries the full resource for top-level events (`payment.*`, `deposit.*`, `withdrawal.*`, `rebalance.*`) and the individual step resource for `step.*` events. The top-level examples below use the terminal-state `*.updated` event, which carries the fullest payload — `desired`, `estimated`, and `actual` overlays all populated, and each step in a terminal status with its timestamps and `actual.*` filled in. The step example uses `step.completed`. The compliance arrays on the Payment example — `risk_status_reasons[]`, `participants[]`, and `provider_metadata[]` — are shown empty to keep the envelope readable. For populated examples of each, see [Outcome Examples](/overviews/wallet-risk-screening#outcome-examples) and [Participants Examples](/overviews/payment-participants#examples). Example terminal-state `payment.updated` webhook (USDC payout on Ethereum, complete): ```json { "id": "f4c8e1a3-9b76-4d2e-a058-3c5f9e1d4b27", "type": "payment.updated", "created_at": "2025-12-01T09:00:02.502Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "transaction_hash": "0xd65dc6bf6dcc ... e877f7be", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:02.500Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": "2025-12-01T09:00:02.500Z", "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:02.500Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` **Inbound variant.** The example above is an outbound payout. Inbound payments use the same envelope and the same Payment resource, with these differences: | Field | Outbound | Inbound | | --- | --- | --- | | `direction` | `outbound` | `inbound` | | `funding_account_id` | the account funding the payout | the originator's [generic account](/how-tos/create-an-account#generic-account) when Travel Rule data sent an account number, otherwise `null` | | `desired.*` | echoes what you requested | all `null` — you never requested the payment | | `estimated.*` | populated at planning (`payment.quote_created`) | populated when Tesser records the on-chain transaction (`payment.created`) | | `expires_at` | end of the quote and funding window | `null` | | `steps[]` | one entry per planned leg | exactly one, created in `confirmed` status | | `steps[].provider_key` | `circle_mint`, `openfx`, or `turnkey` | `null` — the external sender signed and broadcast the transaction | Travel Rule data can arrive after `payment.created`: when the funds land first, Tesser creates the payment from the on-chain transfer, and the originator's `participants[]` and `funding_account_id` arrive later in a `payment.updated`. When that data names the originator VASP, wallet screening runs again and its `payment.risk_updated` delivers them instead, unless a risk review decision is already recorded, in which case `payment.updated` delivers them. See [The Originator's Funding Account](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet#the-originators-funding-account). For full inbound payloads at each stage, see [Receive an Inbound Payment to a Wallet](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet). Example terminal-state `deposit.updated` webhook (USD deposit on-ramped to a USDC ledger at Circle, complete): ```json { "id": "e2c6d8b1-4f7a-4e93-9b8d-2a5c7f3e1d4b", "type": "deposit.updated", "created_at": "2025-12-02T14:30:00.700Z", "data": { "object": { "id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:25:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:25:00.400Z", "completed_at": "2025-12-02T14:25:00.500Z", "failed_at": null }, { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:30:00.400Z", "completed_at": "2025-12-02T14:30:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example terminal-state `withdrawal.updated` webhook (USDC off-ramped via Circle redemption to a USD bank account; swap step + transfer step, complete): ```json { "id": "a3d9e1f2-4b6c-4e58-8d2a-5f7b9c1d3e4f", "type": "withdrawal.updated", "created_at": "2025-12-01T11:30:00.700Z", "data": { "object": { "id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "steps": [ { "id": "3a8e2f5c-9d4b-4f73-a2c0-6b1d3e9f7c4a", "withdrawal_id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "unsigned_transaction": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:02:00.500Z", "submitted_at": "2025-12-01T10:02:00.300Z", "confirmed_at": "2025-12-01T10:02:00.400Z", "completed_at": "2025-12-01T10:02:00.500Z", "failed_at": null }, { "id": "6b9d4f2c-3e8a-4571-b0c6-8d2f1e9b4a3c", "withdrawal_id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "unsigned_transaction": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.500Z", "submitted_at": "2025-12-01T10:02:00.700Z", "confirmed_at": "2025-12-01T10:02:00.800Z", "completed_at": "2025-12-01T11:30:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example terminal-state `rebalance.updated` webhook (USDC rebalanced between two Circle ledger accounts, complete): ```json { "id": "d2e4f6a8-1c3b-4e57-9a8d-2c4e6f8a0b1d", "type": "rebalance.updated", "created_at": "2025-12-01T10:05:00.700Z", "data": { "object": { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "unsigned_transaction": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:00.500Z", "submitted_at": "2025-12-01T10:05:00.300Z", "confirmed_at": "2025-12-01T10:05:00.400Z", "completed_at": "2025-12-01T10:05:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `step.completed` webhook (Circle-internal transfer step on a rebalance): ```json { "id": "f1e2d3c4-5b6a-4798-8e0d-3f4a5b6c7d8e", "type": "step.completed", "created_at": "2025-12-01T10:05:00.500Z", "data": { "object": { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "unsigned_transaction": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:00.500Z", "submitted_at": "2025-12-01T10:05:00.300Z", "confirmed_at": "2025-12-01T10:05:00.400Z", "completed_at": "2025-12-01T10:05:00.500Z", "failed_at": null } } } ``` --- ## Document: Webhook Authentication Verify that webhooks originate from Tesser using Ed25519 signatures URL: /webhooks/authentication # Webhook Authentication import { WEBHOOK_PUBLIC_KEY, WEBHOOK_SANDBOX_PUBLIC_KEY, } from "../../src/generated/webhook-keys"; Tesser signs every outgoing webhook request with an **Ed25519** asymmetric signature. You should verify this signature on your server before processing the payload. ## Signature Header Each webhook request includes the following headers: | Header | Description | | --- | --- | | `X-Tesser-Signature` | Base64-encoded Ed25519 signature of the request body | | `Content-Type` | Always `application/json` | | `User-Agent` | `Tesser-Webhooks/1.0` | The signature is computed over the exact UTF-8 bytes of the JSON request body. Do not parse and re-serialize the body before verifying — use the raw bytes. ## Public Keys Tesser uses **separate public keys for production and sandbox**. Pick the one that matches the environment the webhook is being delivered from, and reject signatures verified against the wrong key.
Environment Constant API host Value (SPKI DER, base64)
Production WEBHOOK_PUBLIC_KEY https://api.tesser.xyz
Sandbox / Staging WEBHOOK_SANDBOX_PUBLIC_KEY https://sandbox.tesserx.co
Both keys are provided in **SPKI DER** format, base64-encoded. If you're using the TypeScript SDK, import the constants directly from `@tesser-payments/types`: ```ts import { WEBHOOK_PUBLIC_KEY, WEBHOOK_SANDBOX_PUBLIC_KEY, } from "@tesser-payments/types"; ``` Otherwise (including from Kotlin/JVM, which has no constants package), copy the literal values from the table above. ## Verifying Signatures Pass the public key for the environment the webhook is being delivered from: use `WEBHOOK_PUBLIC_KEY` for production webhooks and `WEBHOOK_SANDBOX_PUBLIC_KEY` for sandbox / staging webhooks. Verification uses each language's standard crypto library; the signer SDKs do not perform webhook verification. ```js import { createPublicKey, verify } from "node:crypto"; import { WEBHOOK_PUBLIC_KEY, WEBHOOK_SANDBOX_PUBLIC_KEY, } from "@tesser-payments/types"; function verifyWebhook(rawBody, signature, publicKey) { const publicKeyObj = createPublicKey({ key: Buffer.from(publicKey, "base64"), type: "spki", format: "der", }); return verify( null, Buffer.from(rawBody, "utf8"), publicKeyObj, Buffer.from(signature, "base64"), ); } // In your webhook handler — pick the key for the environment you're // receiving webhooks from (sandbox / staging shown here): const signature = req.headers["x-tesser-signature"]; if (typeof signature !== "string" || signature.length === 0) { return res.status(401).json({ error: "Missing signature" }); } const isValid = verifyWebhook( req.rawBody, signature, WEBHOOK_SANDBOX_PUBLIC_KEY, ); if (!isValid) { return res.status(401).json({ error: "Invalid signature" }); } ``` ```kotlin import java.security.GeneralSecurityException import java.security.KeyFactory import java.security.Signature import java.security.spec.X509EncodedKeySpec import java.util.Base64 // SPKI DER, base64-encoded; copy the literal values from the table above. const val WEBHOOK_SANDBOX_PUBLIC_KEY = "MCowBQYDK2VwAyEA..." // Ed25519 verification is built into the JVM (JDK 15+); no extra dependency. // Malformed base64 or key bytes throw, so treat any failure as "not verified". fun verifyWebhook(rawBody: ByteArray, signatureB64: String, publicKeyB64: String): Boolean = try { val keySpec = X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyB64)) val publicKey = KeyFactory.getInstance("Ed25519").generatePublic(keySpec) Signature.getInstance("Ed25519").run { initVerify(publicKey) update(rawBody) verify(Base64.getDecoder().decode(signatureB64)) } } catch (e: IllegalArgumentException) { false // malformed base64 } catch (e: GeneralSecurityException) { false // invalid key or signature } // In your webhook handler, pick the key for the environment you're receiving // webhooks from (sandbox / staging shown here). Verify the raw request bytes. val signature = request.header("X-Tesser-Signature") ?: return respond(401, """{"error":"Missing signature"}""") if (!verifyWebhook(rawBody, signature, WEBHOOK_SANDBOX_PUBLIC_KEY)) { return respond(401, """{"error":"Invalid signature"}""") } ``` ## Important Notes - Always verify using the **raw request body** bytes. Parsing the JSON and re-serializing may change whitespace or key order, which will invalidate the signature. - If verification fails, respond with `401` and do not process the event. - The public key may be rotated in the future. Key rotation will be announced in advance and communicated through the Tesser Dashboard. --- ## Document: Wallet Screening Risk Categories Category values the risk_status_reason_category field can carry, by risk status reason type URL: /overviews/wallet-screening-risk-categories # Wallet Screening Risk Categories When wallet screening surfaces a risk signal on a payment, each entry in `risk_status_reasons[]` carries a `risk_status_reason_category` identifying the specific risk. This page lists the values that field can carry, by `risk_status_reason_type`. Wallet screening is performed by a blockchain analytics provider. ## Ownership indicators `OWNERSHIP` reasons report risk from the screened wallet's own activity — the wallet is directly associated with or controlled by an entity engaged in the flagged activity. The category is one of the [supported risk categories](#supported-risk-categories). ## Counterparty indicators `COUNTERPARTY` reasons report risk from wallets the screened wallet has directly transacted with. The category is one of the [supported risk categories](#supported-risk-categories). ## Indirect indicators `INDIRECT` reasons report risk from wallets the screened wallet has indirect exposure to — two or more hops removed in the flow of funds. The category is one of the [supported risk categories](#supported-risk-categories). ## Entity categories `ENTITY` reasons report risk from the entity the screened wallet is attributed to. The category depends on the reason's `risk_status_reason_severity`: - For `Low`, `Medium`, `High`, or `Severe`: one of the [supported risk categories](#supported-risk-categories), describing the attributed entity. - For `ALLOWLISTED` or `BLOCKLISTED`: the role of the participant that matched your allowlist or blocklist — `ORIGINATOR`, `ORIGINATOR_VASP`, `BENEFICIARY`, `BENEFICIARY_VASP`, `CUSTODY_SERVICE`, or `INTERMEDIARY_VASP`. ## Error categories `ERROR` reasons carry one of two categories: - `SCREENING_FAILED` — wallet screening could not be completed. The payment is rejected because it could not be screened, not because of an identified risk signal. - `Token Contract` — the screened wallet is attributed to a token contract. Direct interaction with a token contract is not expected behavior for an end-user wallet, so the payment is routed to manual review. The reason's `risk_status_reason_participant_id` links it to the payment's `CONTRACT` participant — see [Payment Participants](/overviews/payment-participants). ## Supported risk categories The categories below are the full set the screening provider may return. On the payment record, `risk_status_reason_category` carries the category name; the category ID appears in the raw screening payload in the payment's `provider_metadata[]` entry. | Category ID | Category name | | --- | --- | | 1 | Wallet Cluster | | 2 | Adult Content | | 3 | Decentralized Application | | 4 | Decentralized File Sharing Service | | 5 | High-Risk dApp | | 6 | Decentralized Exchange | | 7 | P2P Crypto Marketplace | | 8 | Exchange | | 9 | Cash-to-Crypto | | 10 | High-Risk Exchange | | 11 | Decentralized Marketplace | | 12 | Non-Custodial Exchange | | 13 | Payment Processing Service | | 14 | Crypto Payment Card | | 15 | Faucet Service | | 16 | Lending Service | | 17 | Investment Services | | 18 | Decentralized Finance | | 19 | Custody Service | | 20 | Financial Services | | 21 | Unhosted Wallet | | 22 | Hosted Wallet | | 23 | Gambling Service | | 24 | Sports Betting | | 25 | Decentralized Gambling Service | | 26 | Goods and Services | | 27 | Gaming | | 28 | In-Game Virtual Goods | | 29 | Decentralized Gaming | | 30 | Decentralized Collectibles | | 31 | Online Username Reselling | | 32 | Government Controlled | | 33 | Hacking Victim | | 34 | Hacked or Exploited Funds | | 35 | Known Hacker Group | | 36 | Human Trafficking | | 37 | Sexual Exploitation | | 38 | Child Sexual Abuse Material (CSAM) Vendor | | 39 | Child Sexual Abuse Material (CSAM) Consumer | | 40 | Child Sexual Abuse Material (CSAM) | | 41 | Terrorist Financing | | 42 | Violent Extremism | | 43 | Escort Service / Prostitution | | 44 | Carding / PII Shop | | 45 | Cybercrime Services | | 46 | Darknet Market | | 47 | Banned or Controlled Substances | | 48 | Counterfeit Goods | | 49 | Piracy | | 50 | Illicit Goods and Services | | 51 | Internet Persona | | 52 | Multi-Level Marketing Scheme | | 53 | High Yield Investment Scheme | | 54 | Ponzi Scheme | | 55 | Investment Scheme | | 56 | Decentralized Investment Scheme | | 57 | Malware | | 58 | Ransomware | | 59 | Ransomware Victim | | 60 | Imposter Site or Service | | 61 | Scam | | 62 | Extortion / Blackmail | | 63 | Scam Victim | | 64 | ICO Scam | | 65 | Mining | | 66 | Mixer | | 67 | Non-Profit | | 68 | Politically Exposed Person | | 69 | Sanctions | | 70 | Hot Wallet | | 71 | Cold Wallet | | 72 | Genesis Address | | 73 | ICO Wallet Address | | 74 | Token Contract | | 75 | Transaction Spam or Dusting | | 76 | TRON Foundation Address | | 77 | TRON Representative | | 78 | Donation Address | | 79 | Validator Group | | 80 | Validator | | 81 | Foundation | | 82 | Smart Contract | | 83 | Founder Address | | 84 | Stablecoin | | 85 | Custom Cluster | | 86 | Proxy Services | | 584 | NFT Marketplace | | 585 | NFT Project | | 628 | Child Sexual Abuse Material (CSAM) Scam | | 629 | Investment Fraud | | 630 | Decentralized Investment Fraud | | 633 | Special Measures | | 636 | Blocklisted | | 638 | Community Complaint | | 639 | Trusted Community Complaint | ## Behavioral signatures `BEHAVIORAL` reasons report transaction patterns commonly associated with illicit activity or funds obfuscation. On the payment record, `risk_status_reason_category` carries the signature's identifier in snake case (for example, `cross_chain_swap` for Cross-Chain Swap); `risk_status_reason_message` carries a plain-English description. | Signature | Description | | --- | --- | | Cross-Chain Swap | Also known as an Atomic Swap. A type of currency exchange used as a mechanism to allow for the movement of tokens, typically via a smart contract, from one blockchain to another. | | Mixer | A service that programmatically co-mingles assets from multiple users into a centralized pool, often moving the funds in smaller chunks to multiple addresses, before sending funds to the intended recipient. Mixers (sometimes referred to as "tumblers") are often used in an attempt to obfuscate the ownership and source of funds. | | Peeling Chain | A single address begins with a relatively large amount of cryptocurrency. A small amount is "peeled off" and transferred to one address, often to be deposited at an exchange, while the remainder is transferred to another address owned by the entity. This pattern repeats until the original cryptocurrency has been fully broken up in smaller amounts. This pattern may indicate an attempt to obfuscate the source and destination of funds. | | Binary Tree | Funds are moved through a series of 1-to-2 transactions into similar amounts, creating a branching pattern. This may indicate layering or an attempt to make the source of funds difficult to detect. | | Mass Registration | A single address moves funds to multiple separate addresses belonging to the same entity. This may indicate structuring, or the use of multiple individuals and/or transactions to stay under a currency reporting threshold. | | CoinJoin Transaction | CoinJoin is a method of combining Bitcoin payments from multiple senders into a single transaction in order to obfuscate which senders are paying which recipients. | | Web Flow Pattern | A single address or entity sends to multiple other addresses in a web-like pattern, before flowing funds back to a single address or entity. This may indicate layering or an attempt to make the source of funds difficult to detect. | | Extortion with a Common Destination | An address or group of addresses linked to Extortion/Blackmail sends funds to a single, common destination address. This may indicate an entity aggregating proceeds from extortion or blackmail scams. | | Graphton | Funds are moved through a sequence of transactions that form a notable "trail" on the blockchain. The transactions appear to be conducted by the same entity because they share common traits (e.g., amount, timing, structure). | --- ## Document: Wallet Risk Screening Data Reading risk_status, risk_status_reasons[], and raw screening data on the payment record URL: /overviews/wallet-risk-screening # Wallet Risk Screening Data Every payment's external counterparty wallet is screened via a blockchain analytics provider. The sender wallet is screened on an inbound payment; the destination or destination VASP wallet on an outbound payment. This page explains how the outcome is reported on the payment record and how to read it. For what screening evaluates and how the decision is made, see [Compliance and Risk Management](/overviews/compliance-and-risk). Screening results appear on the payment resource in two places: - **`risk_status_reasons[]`** — the structured signals that determined the outcome, one object per signal. - **`provider_metadata[]`** — the raw screening response, passed through for your own analytics and audit. ## Risk Statuses and Webhooks Screening produces one `risk_status` per payment after the initial check: | `risk_status` | Meaning | | --- | --- | | `unchecked` | Initial state; screening has not yet resolved. | | `automatically_approved` | No material risk (no risk factors, or no risk factors above threshold). | | `awaiting_decision` | Material exposure warranting review by your organization. | | `automatically_rejected` | A severe finding, a blocklisted counterparty, or a screening failure (per your configured failure action). | If the initial status was `awaiting_decision` and your organization manually reviews the payment and reports a decision, then the `risk_status` will be updated to either: | `risk_status` | Meaning | | --- | --- | | `manually_approved` | A reviewer in your organization approved the payment. | | `manually_rejected` | A reviewer in your organization rejected the payment. | Every `risk_status` change fires a `payment.risk_updated` webhook. :::note On inbound payments, screening runs in parallel with on-chain finalization: `payment.risk_updated` and `step.completed` can fire in either order, and the screening outcome does not gate the balance credit. On outbound payments, an approving outcome is required before the payment executes. See [Receive an Inbound Payment](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet) for the full inbound event sequence. ::: ### How a Screen Resolves to a Status The checks below are applied in order; the first that resolves determines the `risk_status`. See [Compliance and Risk Management](/overviews/compliance-and-risk#how-the-decision-is-made) for the prose version. counterparty wallet] --> OK{Screening completed
after retries?} OK -->|No| FAIL[Configured screening-failure
action — default:
automatically_rejected] OK -->|Yes| HB{Severe finding about
the wallet itself — its
ownership, entity, or behavior?} HB -->|Yes| REJ[automatically_rejected] HB -->|No| TRL{Wallet owner on your
allowlist or blocklist, or the
wallet address itself on your
allowlist? Owner identified by
the Travel Rule provider, or
attributed with confidence
exceeding your threshold} TRL -->|Blocklisted| REJ TRL -->|Allowlisted| APP[automatically_approved] TRL -->|Not listed| CL[Classify custody as
VASP-hosted or self-hosted] CL --> TH[Apply materiality thresholds
by direction and custody] TH --> MAP{Highest severity among
material findings?} MAP -->|None, Low,
or Medium| APP MAP -->|High| REV[awaiting_decision] MAP -->|Severe| REJ`} /> ## Reading risk_status_reasons[] The array is designed so you can reconstruct the decision from the payment record alone: - Every reason that **decided** the outcome appears, tagged `DECISIVE`. - Every risk factor **above its materiality threshold** that did *not* decide the outcome also appears, tagged `NON_DECISIVE` — you see what was evaluated, not only what tipped the decision. - When the external entity was on an allowlist or blocklist, any reason that would otherwise have decided it appears tagged `SUPERSEDED_BY_ALLOWLIST` or `SUPERSEDED_BY_BLOCKLIST`. A hard block outranks both lists, so when one fires nothing is superseded: reasons that would have decided stay `DECISIVE`, an allowlist match is reported `NON_DECISIVE` because it did not describe the outcome, and a blocklist match stays `DECISIVE` because it rejected alongside the hard block. - Risk factors **at or below threshold** never appear here, but the complete raw screening data is always available in `provider_metadata[]`. - Each factor with thresholds carries both the **configured threshold** and the **observed value**, so you can determine which gate tripped — percentage, dollar, or both. - When the only above-threshold factors are Low or Medium severity, they are surfaced but the payment still approves automatically — severity, not the threshold breach, is what decides. ## Field Reference | Field | Description | | --- | --- | | `risk_status_reason_type` | One of `OWNERSHIP`, `COUNTERPARTY`, `INDIRECT` (risk-indicator types), `BEHAVIORAL` (wallet behavior patterns), `ENTITY` (entity attribution, including allowlist and blocklist matches), `ACCOUNT` (the screened wallet address itself is on your allowlist), or `ERROR` (screening failure, or a token-contract attribution). | | `risk_status_reason_category` | For `OWNERSHIP` / `COUNTERPARTY` / `INDIRECT`: a [wallet screening risk category](/overviews/wallet-screening-risk-categories). For `BEHAVIORAL`: a behavioral pattern from the same taxonomy. For `ENTITY`: the entity's category — or, for an allowlist or blocklist match, the participant role matched (`ORIGINATOR`, `ORIGINATOR_VASP`, `BENEFICIARY`, `BENEFICIARY_VASP`, `CUSTODY_SERVICE`, `INTERMEDIARY_VASP`, `CONTRACT`). For `ACCOUNT`: the role of the screened wallet — `BENEFICIARY` on an outbound payment, `ORIGINATOR` on an inbound one. For `ERROR`: `SCREENING_FAILED` when wallet screening could not be completed, or `Token Contract` when the screened wallet is attributed to a token contract (the payment is routed to manual review). | | `risk_status_reason_participant_id` | The `id` of the [participant](/overviews/payment-participants) referenced. Populated for `ENTITY` reasons and for the token-contract `ERROR` reason; for an `ACCOUNT` reason it is the `id` of the account holding the allowlisted address (its participant has `resource_type: ACCOUNT`); otherwise `null`. | | `risk_status_reason_message` | A plain-English description of the risk or the failure to screen. | | `risk_status_reason_severity` | `Low`, `Medium`, `High`, or `Severe`. For an `ENTITY` reason recording a list match: `ALLOWLISTED` or `BLOCKLISTED`. An `ACCOUNT` reason always carries `ALLOWLISTED`. The screening-failure reason carries `Severe`. | | `risk_status_reason_significance` | How the reason figured in the decision: `DECISIVE`, `NON_DECISIVE`, `SUPERSEDED_BY_ALLOWLIST`, `SUPERSEDED_BY_BLOCKLIST`, or `null`. | | `volume_usd_percent` | The observed share of the wallet's volume for this risk indicator (e.g., `"25"`). `OWNERSHIP` / `COUNTERPARTY` / `INDIRECT` only; else `null`. | | `volume_usd_percent_threshold` | The configured percentage threshold, if set; else `null`. `OWNERSHIP` / `COUNTERPARTY` / `INDIRECT` only. | | `volume_usd_amount` | The observed dollar volume for this risk indicator (e.g., `"999"`). `OWNERSHIP` / `COUNTERPARTY` / `INDIRECT` / `BEHAVIORAL` only; else `null`. | | `volume_usd_amount_threshold` | The configured dollar threshold, if set; else `null`. `OWNERSHIP` / `COUNTERPARTY` / `INDIRECT` / `BEHAVIORAL` only. | | `confidence_score` | The observed confidence level of the entity attribution (e.g., `HIGH_CONFIDENCE`, `NEAR_CERTAINTY`). `CERTAIN` — identifies an entity identity established through a Travel Rule authorization flow rather than inferred from on-chain analytics. `EXACT_MATCH` — carried by every `ACCOUNT` reason: the screened wallet address itself is on your allowlist, so no attribution and no confidence were involved. For `ENTITY` reasons only when `risk_status_reason_severity` = `ALLOWLISTED` or `BLOCKLISTED`. `null` otherwise. | | `confidence_score_threshold` | The confidence level the entity attribution must exceed in order for an entity to be eligible for allowlisting or blocklisting. For `ENTITY` reasons only when `risk_status_reason_severity` = `ALLOWLISTED` or `BLOCKLISTED`. `null` otherwise — including on an `ACCOUNT` reason, since an address match has no confidence bar. | :::note{title="Severity and thresholds"} A `Severe` finding in one of the [hard-block categories](/overviews/wallet-screening-risk-categories) (sanctions, terrorist financing, CSAM, and a few others) rejects automatically with no thresholds applied — whether it is on the wallet's own `OWNERSHIP` activity or its `ENTITY` attribution — as does any `Severe` behavioral signature. A `Severe` `OWNERSHIP` finding outside those categories is still weighed against its materiality threshold (it rejects only if material); a `Severe` `ENTITY` attribution outside them does not drive the decision on its own; and `COUNTERPARTY` or `INDIRECT` findings are always evaluated against their materiality thresholds. This ensures that when the screened wallet itself is a confirmed hard-block risk (e.g. owned by a sanctioned entity, linked to terrorist financing) it is blocked, whereas a legitimate, high-volume custodial wallet can pass despite incidental contact with risky addresses. ::: ## Outcome Examples **Approved, nothing surfaced.** A screen with no risk factors — or none above threshold — produces an empty array. The two cases differ only in the raw response in `provider_metadata[]`: ```json { "risk_status": "automatically_approved", "risk_status_reasons": [] } ``` **Approved with factors above threshold.** The "Medium" severity factor decided the outcome. When the most severe risk factor identified has a low or medium severity, the payment still approves: ```json { "risk_status": "automatically_approved", "risk_status_reasons": [ { "risk_status_reason_type": "COUNTERPARTY", "risk_status_reason_category": "Gambling Service", "risk_status_reason_message": "Address has COUNTERPARTY exposure to Gambling Service", "risk_status_reason_severity": "Medium", "risk_status_reason_significance": "DECISIVE", "risk_status_reason_participant_id": null, "volume_usd_percent": "3.40", "volume_usd_percent_threshold": "2", "volume_usd_amount": "2400", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null }, { "risk_status_reason_type": "INDIRECT", "risk_status_reason_category": "Decentralized Exchange", "risk_status_reason_message": "Address has INDIRECT exposure to Decentralized Exchange", "risk_status_reason_severity": "Low", "risk_status_reason_significance": "NON_DECISIVE", "risk_status_reason_participant_id": null, "volume_usd_percent": "6.20", "volume_usd_percent_threshold": "5", "volume_usd_amount": "310", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ] } ``` **Routed to manual review.** A "High" severity counterparty exposure above its materiality threshold decided the outcome; the "Low" severity factor above its threshold is surfaced for context: ```json { "risk_status": "awaiting_decision", "risk_status_reasons": [ { "risk_status_reason_type": "COUNTERPARTY", "risk_status_reason_category": "Darknet Market", "risk_status_reason_message": "Address has COUNTERPARTY exposure to Darknet Market", "risk_status_reason_severity": "High", "risk_status_reason_significance": "DECISIVE", "risk_status_reason_participant_id": null, "volume_usd_percent": "4.80", "volume_usd_percent_threshold": "2", "volume_usd_amount": "8200", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null }, { "risk_status_reason_type": "INDIRECT", "risk_status_reason_category": "Gambling Service", "risk_status_reason_message": "Address has INDIRECT exposure to Gambling Service", "risk_status_reason_severity": "Low", "risk_status_reason_significance": "NON_DECISIVE", "risk_status_reason_participant_id": null, "volume_usd_percent": "5.50", "volume_usd_percent_threshold": "5", "volume_usd_amount": "450", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ] } ``` **Rejected due to blocklist match.** The blocklist match decides ahead of every other check. The High counterparty exposure would have routed the payment to review on its own, so it is tagged `SUPERSEDED_BY_BLOCKLIST`: ```json { "risk_status": "automatically_rejected", "risk_status_reasons": [ { "risk_status_reason_type": "ENTITY", "risk_status_reason_category": "ORIGINATOR_VASP", "risk_status_reason_message": "Entity blocklisted", "risk_status_reason_severity": "BLOCKLISTED", "risk_status_reason_significance": "DECISIVE", "risk_status_reason_participant_id": "7d2e9f44-1a6b-4c58-8e03-b5f19c72a3d8", "volume_usd_percent": null, "volume_usd_percent_threshold": null, "volume_usd_amount": null, "volume_usd_amount_threshold": null, "confidence_score": "HIGH_CONFIDENCE", "confidence_score_threshold": "MODERATE_CONFIDENCE" }, { "risk_status_reason_type": "COUNTERPARTY", "risk_status_reason_category": "High-Risk Exchange", "risk_status_reason_message": "Address has COUNTERPARTY exposure to High-Risk Exchange", "risk_status_reason_severity": "High", "risk_status_reason_significance": "SUPERSEDED_BY_BLOCKLIST", "risk_status_reason_participant_id": null, "volume_usd_percent": "3.10", "volume_usd_percent_threshold": "2", "volume_usd_amount": "5600", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ] } ``` **Rejected because screening could not complete.** No screening response was obtained, so `provider_metadata[]` carries no wallet-screening entry. The rejection carries a dedicated screening-failure reason, so it is programmatically distinguishable from a risk-based rejection: ```json { "risk_status": "automatically_rejected", "risk_status_reasons": [ { "risk_status_reason_type": "ERROR", "risk_status_reason_category": "SCREENING_FAILED", "risk_status_reason_message": "Wallet screening could not be completed after retries", "risk_status_reason_severity": "Severe", "risk_status_reason_significance": "DECISIVE", "risk_status_reason_participant_id": null, "volume_usd_percent": null, "volume_usd_percent_threshold": null, "volume_usd_amount": null, "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ] } ``` ## Manual Review When `risk_status` is `awaiting_decision`, submit a decision via the [risk review endpoint](/api/payments#submit-risk-review) or in the Tesser dashboard. After the decision is recorded, `risk_status` transitions to `manually_approved` or `manually_rejected`, `risk_reviewed_by` and `risk_reviewed_at` populate, and a second `payment.risk_updated` fires. `risk_status_reasons[]` is unchanged from the automatic outcome — the record keeps showing what the screen found. ## Raw Screening Data The `provider_metadata[]` entry with `provider_metadata_type` = `WALLET_RISK_SCREENING` carries the raw screening response: the wallet's volumes and transaction counts, the full set of risk indicators (including those below threshold), behavioral patterns, and entity attributions. Use it for your own analytics and audit; Tesser's outcome is derived only from the material signals summarized in `risk_status_reasons[]`. :::note{title="Payment network and screening scope"} Read the payment network from `estimated.from.network`, `estimated.to.network`, `actual.from.network`, or `actual.to.network`. Wallet screening covers all supported chains on which the address exists, not only the payment network. Provider-defined fields inside `provider_metadata_data`, including `entities[].entity`, can therefore reference a different chain. For example, an entity value ending in `:polygon` can appear on an Ethereum payment and does not mean the payment was processed on Polygon. ::: Abridged payment example: ```json { "actual": { "from": { "network": "ETHEREUM" }, "to": { "network": "ETHEREUM" } }, "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "chain": "ALL", "network": "ALL", "entities": [ { "category": "Wallet Cluster", "confidenceScoreLabel": "High Confidence", "entity": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b:polygon", "riskScoreLevelLabel": "Unknown" } ], "screenedAt": "2026-01-26T12:30:45.123Z" } } ] } ``` :::note The raw payload retains the screening provider's response shape and camelCase field names after Tesser removes provider-internal URL and identifier fields. Treat it as provider-defined data rather than as the source of truth for payment routing. Use `risk_status`, `risk_status_reasons[]`, and `participants[]` for Tesser's normalized screening outcome. ::: ## What Your Organization Can Configure The thresholds, lists, and failure behavior that drive these outcomes ship with Tesser defaults and can be tuned for your organization — see [What Your Organization Can Configure](/overviews/compliance-and-risk#what-your-organization-can-configure). --- ## Document: Treasury Management Stablecoin balance and yield management URL: /overviews/treasury-management # Treasury Management Tesser provides comprehensive treasury management capabilities for stablecoins. > Note: Most treasury management features are currently under development. Contact us to learn more about our roadmap and early access programs. **Balance Management** Effective balance management ensures payments can be processed successfully: - **Balance checks:** Before payment processing, the system validates that the source treasury wallet has sufficient funds to cover the payment amount and associated network fees and processing costs - **Balance reconciliation:** Regular balance verification for wallets against blockchain state - **Balance monitoring:** Low balance alerts when balances fall below thresholds or based on typical usage - **Automatic rebalancing**: Rebalance across tokens and chains to maintain optimal distribution **Yield Generation** Generate yield on otherwise idle stablecoin balances: - **Multiple Providers**: Integrations with yield providers to offer a range of options - **Risk Spectrum**: From US treasury-backed yield (lower risk) to DeFi yield options (higher yield, higher risk) - **Policy Controls**: Enable yield only where allowed by your policy and risk appetite - **Operational Safety**: Emphasis on capital preservation and liquidity availability for payments **Treasury Analytics** Comprehensive reporting and analytics: - **Utilization Reports**: Track fund usage across corridors - **Yield Performance**: Monitor returns on treasury holdings - **Cash Flow Forecasting**: Predict funding needs based on historical patterns - **Cost Analysis**: Compare treasury costs across different strategies --- ## Document: Transfers Internal movement of funds URL: /overviews/transfers # Transfers A Transfer is a movement of funds on the Tesser platform among accounts that your organization or your customers own. There are no arms-length third parties involved in a transfer. Transfers involving two treasury/customer wallets are not routed through shield wallets. **Transfers occur during:** - **Rebalancing among treasury wallets:** To ensure sufficient funds in your organization’s wallet(s), you or Tesser with your authorization may redistribute funds among your treasury wallets. - **Funds flow through a shield wallet to/from a Treasury wallet:** When processing a payment, funds will move from a treasury wallet to a shield wallet prior to being distributed to a beneficiary (outbound payment) or from a shield wallet to a treasury wallet (inbound payment). --- ## Document: Supported Tokens and Networks Supported blockchain networks and stablecoins URL: /overviews/tokens-and-networks # Supported Tokens and Networks ## Self-Custodial Wallets The following tokens and networks are supported for self-custodial wallets:
**Chain** **Token** **Availability**
POLYGON USDC, USDT Available
ETHEREUM USDC, USDT Available
BASE USDC Available
STELLAR USDC Coming soon
SOLANA USDC, USDT Coming soon
> Note: Need support for other EVM-compatible networks? Contact us and we can quickly add support for additional chains. > Note: USDT isn't natively supported on Base, so only USDC is available there. ## Custodial Wallets When you hold funds through a third-party custodian instead of a self-custodial wallet, the supported tokens and networks are determined by the custodian rather than Tesser. See [Stablecoin Custody](./stablecoin-custody) for how custodial and self-custodial models differ. [Circle](https://www.circle.com/) is the currently supported custodian. With Circle, Tesser supports the full set of chains and stablecoins available through Circle Mint. For the current list, see Circle's [Supported Chains and Currencies](https://developers.circle.com/circle-mint/references/supported-chains-and-currencies). --- ## Document: Tenants Manage business customers and their integrations URL: /overviews/tenants # Tenants A tenant represents an organization’s customer when that customer is itself a platform with its own users who need to be represented in Tesser’s systems. When an organization’s customers are tenants, originators and/or beneficiaries are customers of the tenant and thus end-customers to the organization. Example structure of resources for an organization with tenants: ![image.png](Tenants/image.png) --- ## Document: Stablecoin Custody Secure non-custodial wallet architecture URL: /overviews/stablecoin-custody # Stablecoin Custody Tesser provides both non-custodial and third-party custodial options for financial institutions to hold and manage stablecoins. We generally recommend a non-custodial model (also called self-custodial) to eliminate dependency on third-party custodians and maintain full operational control. In the self-custodial model, you or your customer is the owner and custodian of the provisioned wallet. This section covers non-custodial (self-custodial) wallets. ## **Wallet Security** Tesser provides enterprise-grade security with a **non-custodial** architecture, meaning: - **Who can custody:** Either your organization or your customers can be the custodians of wallets Tesser provisions. Typically, your organization will custody all the wallets, whether you opt to have a few omnibus wallets for all originator activity or establish one wallet per originator, or a combination of the above. - **You maintain control**: Tesser cannot move your funds without your or your customer’s authorization. For wallets custodied by your customer, your customer can delegate access and authorization for payment instructions to your organization. - **Private key isolation**: Raw private keys are never exposed to Tesser, your software, or your team. - **Secure enclaves**: Private API keys are generated and used for access to the wallet. These keys are generated and stored in hardware-isolated secure enclaves. - **Cryptographic attestation**: Cryptographic proof that only authorized code is running - **Reproducible deployments**: Auditable system configurations with minimal attack surface - **End-to-end audit trail**: Complete cryptographic verification of all operations - **Automated backup management**: We handle secure backup and disaster recovery for your keys - **Multi-factor access controls**: Hardware-backed authentication and authorization - **Quorum-based operations**: Distributed security architecture preventing single points of compromise - **Enclave secure channels**: Direct encrypted communication between secure enclaves and authorized users ## **Wallet Provisioning** During integration, wallets can be created for your team to support testing. Certain payment operations are gated until full production approval is granted. Once in production, wallets can be provisioned on-demand for omnibus treasury management purposes or per customer. Retrieve wallets: ```bash curl -X GET https://api.tesser.xyz/wallets \ -H "Authorization: Bearer your-access-token" ``` Each wallet includes `id`, `workspace_id` ,`address` (and one or more associated token addresses `ata_address`for Solana), and friendly `name` . Optionally, wallets can be assigned to a counterparty by specifying a `counterparty_id` . Wallets can hold balances in more than one stablecoin, denominated by `currency_symbol`, and operate on one or more network, denominated by `network` . --- ## Document: Resources Overview Overview of the Platform API Resources URL: /overviews/resources-overview # Resources Overview The table below outlines the core resources of the Tesser platform and how they relate to each other. Where a resource has its own overview page, the resource name links to it.
Resource Description
Organization

An organization represents Tesser’s customer, with whom Tesser has a contractual relationship. All other resources belong to an organization.

Workspace

A workspace is a container for counterparties, accounts, payments, and deposits/withdrawals. Users and their roles, API keys, and webhooks are also established at the workspace level.

Every organization starts out with one workspace in the Tesser platform. Additional workspaces can be created by contacting Tesser Customer Support. You may want an additional workspace to segregate activity by line of business, business unit, geography, etc.

Tenant

A tenant represents an organization’s customer when that customer is itself a platform with its own users who need to be represented in Tesser’s systems. When an organization’s customers are tenants, originators and/or beneficiaries are customers of the tenant, and thus end-customers to the organization.

*Note: Most organizations will not need to use tenants to manage their integrations with Tesser.

Counterparty

A counterparty represents an originator (ultimate sender) or beneficiary (ultimate receiver) of a payment. Counterparties can be individuals or businesses. Counterparties are associated with Accounts and belong to a Workspace.

Registering counterparties in advance of submitting payments enables sanction screening and helps meet regulatory requirements. Counterparty information will be transmitted for fiat payouts and, as applicable, for stablecoin payouts to comply with Travel Rule obligations.

Account

An account represents a store of value that holds fiat currency(ies) and/or stablecoin(s). Accounts can belong to a counterparty or to a workspace. Accounts may be external to the Tesser platform (e.g. pre-existing fiat bank accounts or wallets) or provisioned by Tesser (e.g. self-custodial wallets or ledgers at a custodian).

Quote

A quote represents the cost to deliver a payment to a beneficiary. Quotes include the exchange rate between the source currency and the destination currency. Quotes can be based on a "from" (source) amount and currency or a "to" (destination) amount and currency.

Payment

A payment is the movement of funds from an account provisioned by Tesser to a third party, or vice versa. Most commonly, payments will move funds from an originator to a beneficiary. Payments may be outbound (aka payouts) or inbound (receiving stablecoins, aka pay-ins).

Payments also include funds movement to/from a yield provider.

Deposit/Withdrawal

Deposits and withdrawals are first-party movements of funds into or out of liquidity providers.

Deposits can occur by on-ramping funds through a liquidity provider. Withdrawals can occur by off-ramping funds through a liquidity provider.

Rebalance

A rebalance represents funds movement among Tesser-provisioned wallets or ledgers for treasury management purposes.

Step

A step represents funds movement from one account to another (step_type = transfer) or the exchange of one currency for another inside of an account (step_type = swap).

--- ## Document: Payments Movement of funds to/from counterparties URL: /overviews/payments # Payments A payment is the movement of funds from an account provisioned by Tesser to a third party, or vice versa. Most commonly, payments will move funds from an originator to a beneficiary. Payments may be outbound (aka payouts) or inbound (receiving stablecoins, aka pay-ins). Payments also include funds movement to/from a stablecoin yield provider. **Shield wallets in payments processing** Payments always involve a counterparty that is not you or your customer (direct or end-customer):
**Originator is you or your customer/end-customer** **Beneficiary is you or your customer/end-customer**
**Outbound payment** ✅ ❌
**Inbound payment** ❌ ✅
Because of these arms-length counterparties, Tesser processes payments through “shield” wallets. When funds flow through shield wallets, your organization’s treasury wallets are protected from direct exposure to wallets with potentially risky activity. This means your treasury wallets never directly transact with a wallet that is, or later becomes, tainted with undesirable activity. Outbound payments: ![image.png](./payments1.png) Inbound payments: ![image.png](./payments2.png) --- ## Document: Payment Participants How Tesser identifies the participants in a payment's flow of funds and records them in participants[] URL: /overviews/payment-participants # Payment Participants Every payment record carries a top-level `participants[]` array that identifies the participants in the flow of funds *other* than your organization and records how each one was reconciled to a record in your workspace. This is most valuable on **inbound payments**, where the sender is otherwise unknown to you. `participants[]` is how you learn who sent the funds, whether they were hosted by a custodian (VASP), and whether the supplied identity of the participant(s) matched your records. On **outbound payments** you have already supplied the participants (originator via `desired.from.account_id` and beneficiary via `desired.to.account_id`). So for outbound payments, the array reproduces your supplied participants and adds additional information about the destination wallet (for example, which VASP hosts it, if any). ## Where Identity Data Comes From Tesser obtains information for the `participants[]` from two sources: - **Travel Rule provider data (primary).** When the payment is facilitated through a Travel Rule provider authorization flow. For inbound payments, information about the originator and originator VASP is obtained from the originator VASP. For outbound payments, information about the beneficiary and beneficiary VASP is obtained from the beneficiary VASP. - **Wallet screening entity attribution (fallback).** When there is no Travel Rule provider-supplied data, Tesser uses the screening provider's entity attribution for the wallet when a named entity is identified. If both data sources are available, the Travel Rule data provider takes precedence. ## Participant Roles Each entry carries a `role`: | Role | Meaning | | --- | --- | | `ORIGINATOR` | The party ultimately instructing the funds transfer. | | `ORIGINATOR_VASP` | The VASP hosting the sending wallet (recorded on inbound flows). | | `BENEFICIARY` | The party receiving the funds. | | `BENEFICIARY_VASP` | The VASP hosting the destination wallet (recorded on outbound flows). | | `CUSTODY_SERVICE` | A third-party custody service in the flow (VASPs sometimes use one to custody their wallets). | | `INTERMEDIARY_VASP` | Any other intermediary VASP identified in the flow. | | `CONTRACT` | The counterparty wallet is a token contract address. | :::note A payment to or from a `CONTRACT` participant is always routed to manual review (`risk_status` = `awaiting_decision`) — direct interaction with a contract address is not expected behavior for end-user wallets. ::: ## Which Roles May Populate, by Scenario For **outbound payments**, the `ORIGINATOR` and `BENEFICIARY` entries come from the account IDs you supplied (see below). Tesser treats your supplied participants as "canonical" records and never overrides them in the `participants[]` with data returned by the Travel Rule data provider or wallet screening provider. However, for the `BENEFICIARY` role, where it's possible there can be a **mismatch** between the identity info your organization supplied and the info the Beneficiary VASP has on file (or the identity attributed by the wallet screening provider), Tesser will report the mismatch on the payment record so your organization can be aware (see below). **This table lays out the data source for each role, by outbound payment scenario:**
| Scenario | `ORIGINATOR` | `CUSTODY_SERVICE` / `INTERMEDIARY_VASP` | `BENEFICIARY` | `BENEFICIARY_VASP` | | --- | --- | --- | --- | --- | | **Travel Rule provider-facilitated flow** (VASP-hosted beneficiary) | Supplied by your organization | Populated from the Travel Rule data provider and/or wallet screening provider, if identified | Supplied by your organization | Populated from the Travel Rule data provider or wallet screening provider, if identified | | **On-chain transfer only, VASP-hosted beneficiary** | Supplied by your organization | Populated from the wallet screening provider, if identified | Supplied by your organization | Populated from the wallet screening provider, if identified | | **On-chain transfer only, self-hosted beneficiary** | Supplied by your organization | Populated from the wallet screening provider, if identified | Supplied by your organization | Not populated — a self-hosted wallet has no hosting VASP |
For **inbound payments,** the `BENEFICIARY` entry comes from the corresponding Counterparty (or Tenant) who owns the account ID that represents the wallet address the originator/originator VASP sends to. Similar to outbound payments, Tesser treats your supplied identity info for the beneficiary as the canonical record and does *not* replace that role in the `participants[]` with data returned by the Travel Rule data or wallet screening provider. However, when the flow is facilitated by a Travel Rule data provider, if there is a **mismatch** between the identity info your organization has on file for the beneficiary and the info the Beneficiary VASP supplied, Tesser will report the mismatch on the payment record so your organization can be aware (see below). **This table lays out the data source for each role, by inbound payment scenario:**
| Scenario | `ORIGINATOR` | `ORIGINATOR_VASP` | `CUSTODY_SERVICE` / `INTERMEDIARY_VASP` | `BENEFICIARY` | | --- | --- | --- | --- | --- | | **Travel Rule provider-facilitated flow** (VASP-hosted sender) | Populated from the Travel Rule data provider | Populated from the Travel Rule data provider | Populated from the Travel Rule data provider and/or wallet screening provider, if identified | Populated from lookup of Counterparty/Tenant who owns the account that represents the destination wallet address | | **On-chain transfer only, VASP-hosted sender** | Rarely populated — without Travel Rule data, the individual behind a VASP-hosted wallet typically cannot be identified. | Populated from the wallet screening provider entity attribution, if available | Populated from the wallet screening provider, if identified | Same as above. | | **On-chain transfer only, self-hosted sender** | Populated from the wallet screening provider attribution (a `CONTRACT` entry instead, if the wallet is a contract address). | Not populated — a self-hosted wallet has no hosting VASP | Populated from the wallet screening provider, if identified | Same as above. |
:::note{title="Where an inbound payment's originator comes from"} An inbound payment records a single originator (`originator_id`) alongside `participants[]`. When the Travel Rule data provider names the originator, that party is the payment's originator, and it is the same entity that owns the payment's `funding_account_id` when one is set. This holds whether the Travel Rule data arrives before or after the funds land on-chain: if wallet screening attributed the sending wallet first, the Travel Rule originator replaces that attribution. When the Travel Rule data names no originator, or there is no Travel Rule data, the originator comes from wallet screening (a named VASP the Travel Rule data provider supplied, otherwise the screening provider's attribution for the sending wallet). ::: :::note{title="Accounts and participants on inbound payments"} `participants[]` identifies *who* sent the funds. The sending *address* is recorded separately: the first time Tesser records an inbound payment from a wallet address it has not seen before, it creates an unmanaged account (`is_managed` = `false`) to represent that address and assigns it to the placeholder counterparty for inbound senders. That account is what `estimated.from.account_id` references, and subsequent payments from the same address reuse it. This happens for every unrecognized sending address, whether or not a provider named an entity — so a payment can carry a sending account and still have no `ORIGINATOR` entry in `participants[]`. It is also distinct from the Counterparty that name-matching may create (`COUNTERPARTY_CREATED`, below): that record represents a named entity a provider disclosed, not the address funds arrived from. See [Receive an Inbound Payment to a Wallet](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet#recording-the-sending-wallet-on-the-payment). ::: ## The `resource` Object Each participant entry carries a `resource` object describing the record it was reconciled to: | Field | Description | | --- | --- | | `resource.resource_type` | The kind of record: `COUNTERPARTY`; `TENANT` (the wallet belongs to one of your tenants directly); `WORKSPACE` (funds are moving to or from one of your own treasury wallets); or `ACCOUNT` (the account holding a wallet address on your allowlist — see below). | | `resource.id` | Identifier of the record the participant was reconciled to. | | `resource.name_match_score` | The name-match score, 0–100. `null` where no name matching applied. | | `resource.name_match_threshold` | The lowest score that counts as a match. The comparison is inclusive — a score equal to the threshold matches. `null` where no name matching applied. | | `resource.name_match_comparison_id` | The ID of the record whose name most closely matched the supplied name — the record the score was computed against. `null` where no name matching applied. | | `resource.resolution` | The reconciliation outcome. See below. | **The `resolution` values are:** - **`EXACT_MATCH`** — the supplied name and the record's name are identical once normalized, scoring 100. - **`PARTIAL_MATCH`** — the score reached the threshold without being a perfect 100: the two names agree on typo-level differences such as a swapped pair of letters or a single substitution. The participant resolves to that record exactly as an exact match does — the distinction is recorded for your audit trail, not to gate anything. Accents, an individual's trailing generational suffix (`Jr.`, `III`) and a business's legal form (`LLC`, `S.A. de C.V.`) are normalized away, so they produce `EXACT_MATCH`. - **`DID_NOT_MATCH`** — a provider supplied a name for a participant you had already identified, and it scored below the threshold against your record. This is the mismatch signal: your record stays canonical and Tesser does not create a second one for it. On an inbound Travel Rule flow, it is also how a rejected authorization appears on the payment record. - **`COUNTERPARTY_CREATED`** — Tesser discovered this participant from provider data, found no record of it in your workspace — either nothing matched, or the closest record scored below the threshold — and created a new Counterparty to represent it. Only discovered roles can carry this value; which roles those are depends on the direction of the payment (see the tables below). A participant you supplied never carries it. - **`EXACT_MATCH` on an `ACCOUNT` participant** — the screened wallet address itself is on your allowlist. The participant points at the account holding that address (`resource_type: ACCOUNT`), with the role of the screened side (`BENEFICIARY` outbound, `ORIGINATOR` inbound). No name matching ran, so the score, threshold, and comparison ID are `null`; the matching `risk_status_reasons[]` entry is typed `ACCOUNT`. It is added alongside, never instead of, the participants above — a counterparty that owns the account still appears as its own `BENEFICIARY`. - **`USER_SUPPLIED_ID`** — the participant was identified from an account on the payment rather than from provider data, so no name matching ran: the account ID your organization supplied (outbound), or the destination account of an inbound transfer. A `BENEFICIARY` entry also carries this value when no provider-supplied name was available to match at all — for example, an inbound payment that arrived with no Travel Rule data. No name matching applied, so the score, threshold, and comparison ID are `null`. ## Reconciling data from Travel Rule and wallet screening providers to your records Tesser reconciles the entity data supplied by the Travel Rule data provider and/or wallet screening provider to existing records by name-matching. **Name matching exists to resolve the participants you could not have told us about.** Whether a role is name-matched at all — and whether it can produce a new Counterparty record — depends on whether you already knew that participant when the payment was created: - **Participants you supplied are canonical.** On an outbound payment you supply both ends of the flow: the `ORIGINATOR` (your end user) and the `BENEFICIARY` you intend to pay. On an inbound payment you know the `BENEFICIARY` — it is the Counterparty or Tenant that owns the destination account. Tesser never overwrites these records and never creates a new one for them. Where a provider does supply a name for one of these roles, matching runs for a single purpose: to tell you whether the provider's name agrees with yours. - **Participants Tesser discovers come from provider data.** On an outbound payment you know who you are paying, but not whether a VASP hosts that wallet or which one — so the `BENEFICIARY_VASP` is an unknown party, as are any `CUSTODY_SERVICE` or `INTERMEDIARY_VASP` in the flow. On an inbound payment the unknown parties are the `ORIGINATOR` and `ORIGINATOR_VASP`, plus any `CUSTODY_SERVICE` or `INTERMEDIARY_VASP`. These are the only roles matched against your full set of Counterparty and Tenant records, and the only roles for which Tesser will create a new Counterparty. **Names are matched fuzzily.** Data is matched against your counterparty and tenant records and scored 0–100, and a score of 85 or above is a match. The scorer tolerates the differences that separate two spellings of one party rather than two parties: a transposed pair of letters, a single substitution, punctuation and accents, an individual's dropped generational suffix (`Jr.`, `III`), a business's legal form, and name parts given in a different order. Each normalization applies to its own kind of party only: a business named `Acme II` keeps its `II`, and an individual's `LLC` is part of the name. A name part present on only one side counts against the score rather than being ignored, so a shorter name never resolves to a fuller record that contains it — `Coinbase` scores 50 against a record named `Coinbase Custody`, and mints a new counterparty. A business's legal form is the exception: when the record being compared is a business, the legal form (`LLC`, `Inc.`, `Ltd`, `S.A. de C.V.`, `GmbH`, and the like) is dropped from both names before scoring, so `Circle Internet Financial` and `Circle Internet Financial LLC` are an exact match. Descriptive words such as `Group`, `Holdings` or `Trust` are not legal forms and still count. The score is the average across the name's parts, so how much a difference costs depends on how many parts there are. On a single-part name one wrong letter in eight falls short of the threshold. On a name of several parts one wrong part does not: `Bank of America` scores 95 against `Bank of Americo`. Read `PARTIAL_MATCH` on a multi-part name as evidence worth reviewing rather than proof of identity; `EXACT_MATCH` is the only value that says the two names are the same once normalized. Depending on the participant role and the direction of the payment, Tesser may create a new counterparty record (`resolution` = `COUNTERPARTY_CREATED`) and populate `participants[]` with that record if we have never seen an entity before or if an entity scores below the name match threshold. When name matching is happening for canonical records, we will identify the mismatch between your information and the data provider's information (`DID_NOT_MATCH`). :::note Not all participants will have name-matching conducted. See the below tables for which roles may be name-matched for each payment direction. ::: **This table outlines how name-matching works for outbound payments.**
| Role | Known to you? | Data universe for match | Potential `resolution` values | | --- | --- | --- | --- | | `ORIGINATOR` | Yes — you supplied it | N/A (Name matching cannot be performed) | `USER_SUPPLIED_ID` | | `CUSTODY_SERVICE` / `INTERMEDIARY_VASP` | No — discovered by Tesser | All Counterparty and Tenant records | `EXACT_MATCH`, `PARTIAL_MATCH`, `COUNTERPARTY_CREATED` | | `BENEFICIARY` | Yes — you supplied it | Counterparty record that owns the `desired.to.account_id` | `EXACT_MATCH`, `PARTIAL_MATCH`, `DID_NOT_MATCH`, `USER_SUPPLIED_ID` (populated when name matching not performed on Beneficiary name) | | `BENEFICIARY_VASP` | No — discovered by Tesser | All Counterparty and Tenant records | `EXACT_MATCH`, `PARTIAL_MATCH`, `COUNTERPARTY_CREATED` |
**This table outlines how name-matching works for inbound payments.**
| Role | Known to you? | Data universe for match | Potential `resolution` values | | --- | --- | --- | --- | | `ORIGINATOR` | No — discovered by Tesser | All Counterparty and Tenant records | `EXACT_MATCH`, `PARTIAL_MATCH`, `COUNTERPARTY_CREATED` | | `ORIGINATOR_VASP` | No — discovered by Tesser | All Counterparty and Tenant records | `EXACT_MATCH`, `PARTIAL_MATCH`, `COUNTERPARTY_CREATED` | | `CUSTODY_SERVICE` / `INTERMEDIARY_VASP` | No — discovered by Tesser | All Counterparty and Tenant records | `EXACT_MATCH`, `PARTIAL_MATCH`, `COUNTERPARTY_CREATED` | | `BENEFICIARY` | Yes — from your destination account | Counterparty or Tenant record that owns the `estimated.to.account_id` | `EXACT_MATCH`, `PARTIAL_MATCH`, `DID_NOT_MATCH`, `USER_SUPPLIED_ID` (populated when name matching not performed on Beneficiary name) |
## When participants[] Populates `participants[]` is populated once; when depends on the flow. It may be populated before or along with `funding_account_id`, `*.from.account_id`, and `*.to.account_id`.
| Flow | When `participants[]` populates | When account ids populated | | --- | --- | --- | | Inbound, Travel Rule authorization flow | At payment record creation, before funds move | After funds move on-chain | | Inbound, on-chain transfer only | After funds move on-chain, once wallet screening completes | After funds move on-chain | | Outbound | Your supplied entries at creation; a `BENEFICIARY_VASP` entry once screening completes. | At payment record creation |
## Examples **Inbound — Travel Rule authorization flow.** The originator could not be matched to any existing record (a new counterparty was created), and the originator's VASP and the beneficiary each matched an existing record exactly: ```json { "participants": [ { "role": "ORIGINATOR", "resource": { "resource_type": "COUNTERPARTY", "id": "3c9a1e57-8b2f-4d06-9e41-7a5c2f8d1b60", "name_match_score": "23", "name_match_threshold": "85", "name_match_comparison_id": "b41f7c29-6d83-45ea-a1c7-9f20d3e8b574", "resolution": "COUNTERPARTY_CREATED" } }, { "role": "ORIGINATOR_VASP", "resource": { "resource_type": "COUNTERPARTY", "id": "7d2e9f44-1a6b-4c58-8e03-b5f19c72a3d8", "name_match_score": "100", "name_match_threshold": "85", "name_match_comparison_id": "7d2e9f44-1a6b-4c58-8e03-b5f19c72a3d8", "resolution": "EXACT_MATCH" } }, { "role": "BENEFICIARY", "resource": { "resource_type": "COUNTERPARTY", "id": "e8b3d016-4f7a-49c2-95d8-2c61a0f9e735", "name_match_score": "100", "name_match_threshold": "85", "name_match_comparison_id": "e8b3d016-4f7a-49c2-95d8-2c61a0f9e735", "resolution": "EXACT_MATCH" } } ] } ``` **Inbound — on-chain transfer only, VASP-hosted sender.** No Travel Rule data accompanied the transfer, so no originator entry is possible. Screening attributes the sending wallet to a VASP whose name reached no existing counterparty's threshold, so a new counterparty was created. The beneficiary is identified from the destination account — with no Travel Rule data there is no beneficiary name to match, so it carries `USER_SUPPLIED_ID`: ```json { "participants": [ { "role": "ORIGINATOR_VASP", "resource": { "resource_type": "COUNTERPARTY", "id": "b8b8fc6c-62b5-4617-bc45-28ac30456f49", "name_match_score": "62", "name_match_threshold": "85", "name_match_comparison_id": "f6a2c81d-3e97-4b5a-a24e-8c1d0b9e6f42", "resolution": "COUNTERPARTY_CREATED" } }, { "role": "BENEFICIARY", "resource": { "resource_type": "COUNTERPARTY", "id": "e8b3d016-4f7a-49c2-95d8-2c61a0f9e735", "name_match_score": null, "name_match_threshold": null, "name_match_comparison_id": null, "resolution": "USER_SUPPLIED_ID" } } ] } ``` **Outbound — destination attributed to a VASP.** Your supplied originator and beneficiary are recorded as-is; screening identified the VASP hosting the destination wallet: ```json { "participants": [ { "role": "ORIGINATOR", "resource": { "resource_type": "COUNTERPARTY", "id": "1f7d0a92-5c34-4b8e-9d16-8e2a4c7b3f50", "name_match_score": null, "name_match_threshold": null, "name_match_comparison_id": null, "resolution": "USER_SUPPLIED_ID" } }, { "role": "BENEFICIARY", "resource": { "resource_type": "COUNTERPARTY", "id": "e8b3d016-4f7a-49c2-95d8-2c61a0f9e735", "name_match_score": null, "name_match_threshold": null, "name_match_comparison_id": null, "resolution": "USER_SUPPLIED_ID" } }, { "role": "BENEFICIARY_VASP", "resource": { "resource_type": "COUNTERPARTY", "id": "a4c1f0b8-2e9d-4a63-b7c5-1d8f6e0a3c92", "name_match_score": "100", "name_match_threshold": "85", "name_match_comparison_id": "a4c1f0b8-2e9d-4a63-b7c5-1d8f6e0a3c92", "resolution": "EXACT_MATCH" } } ] } ``` ## Raw Travel Rule Data For payments that arrived through a Travel Rule authorization flow, the raw transfer record — including the originator and beneficiary identity data exactly as the sending VASP transmitted it — is passed through on the payment in `provider_metadata[]`, in the entry with `provider_metadata_type` = `TRAVEL_RULE`. It is preserved regardless of the match outcome, so you can run your own screening and monitoring on the original data and compare it against the record the payment was reconciled to. Each `provider_metadata[]` entry has three fields: | Field | Description | | --- | --- | | `provider_metadata_type` | What the payload is: `TRAVEL_RULE` or `WALLET_RISK_SCREENING`. | | `provider_metadata_key` | The provider that produced it. | | `provider_metadata_data` | The raw provider payload. | The `WALLET_RISK_SCREENING` entry is covered in [Wallet Risk Screening Data](/overviews/wallet-risk-screening#raw-screening-data). --- ## Document: Networks Supported blockchain networks URL: /overviews/networks # Networks import { Badge } from "zudoku/ui/Badge"; import { Callout } from "zudoku/ui/Callout"; ## Networks The Networks resource provides information about the blockchain networks supported by the platform. ### Overview We support a curated list of major blockchain networks for crypto transactions. This resource allows you to query the available networks dynamically. We recommend dynamically querying the `/networks` endpoint rather than hardcoding values, as supported networks may expand over time. ### Supported Networks Commonly supported networks include: - ETHEREUM **Ethereum** - POLYGON **Polygon** - STELLAR **Stellar** - SOLANA **Solana** See the [API Reference](/api#tag/Networks) for the live list of supported networks. --- ## Document: Liquidity Providers Integration with global fiat on/off ramps URL: /overviews/liquidity-providers # Liquidity Providers Tesser partners with multiple liquidity providers, also known as on/off ramps, to provide conversion capabilities between stablecoins and fiat currencies. ## **Geographic Coverage** Tesser can tap into liquidity in any region through a global provider network. We activate new corridors with short turnaround based on customer demand. > Need coverage in another region? Contact us and we can quickly add support for additional markets. ## **Onboarding Experience** - If your organization has any existing provider integrations, for example, Circle for USDC, we will reuse those. - For a new provider, where possible, Tesser provides an embedded onboarding experience. For embedded onboarding, your organization will not have to contract with the liquidity provider or proceed through their onboarding flows. Instead, Tesser will use information already collected about your organization and request any additional information needed through the Tesser dashboard or API. ## **On-Ramping** When making a fiat payout to a beneficiary, on-ramps will typically be used to convert fiat to stablecoins: - **Depositing instructions**: Tesser will generate and communicate instructions specific to each deposit for you to follow. The supported funds delivery mechanisms to an on-ramp are dependent on the local market; on-ramps typically accept push methods (e.g. ACH credit or Wire in the U.S.) - **Same-Day Settlement**: Fast conversion for operational efficiency. *Settlement timing is dependent on the local market and may only be available during business hours* - **Competitive Rates**: Institutional-grade FX rates - **Automated Processing**: Scheduled conversions based on payment needs - **Funds distribution:** On-ramped funds can be deposited into one or more wallets that you specify during the deposit instruction process ## **Off-Ramping** As part of Treasury management, your organization may off-ramp funds back into your local fiat currency based on adjusted forecasts. When making a fiat payout to a beneficiary, off-ramps in the destination country will be used to convert stablecoins to the local fiat currency: ### **Payout Methods to Beneficiaries** Currently available: - **Bank Transfers**: Direct deposit to recipient bank accounts - **Digital Wallets**: Local e-wallet integrations Coming soon: - **Mobile Money**: Integration with mobile payment systems (under development) - **Cash Pickup**: Physical cash collection points (under development) ### **Key Benefits** - **Competitive Rates**: Market-leading conversion rates - **Real-time funds delivery:** When real-time payment methods are available in the local market, funds can be delivered to recipients instantly - **Detailed Reporting**: Complete reconciliation data for all conversions --- ## Document: Funds Movement Lifecycle and Data Model How Tesser models payments, deposits, withdrawals, and rebalances — the shared data shape, lifecycle phases, and statuses. URL: /overviews/funds-movement-lifecycle-and-data-model # Funds Movement Lifecycle and Data Model Tesser models funds movement using a single shared data shape and lifecycle. The resource types differ mainly in their **direction** and which **pre-execution checks** apply. Their data model, step structure, status taxonomy, and webhook conventions are the same. ## Resource Types Tesser exposes four funds-movement resources (payments, deposits, withdrawals, and rebalances). Each is modeled with the same shared envelope (described in [Resource Data Model](#resource-data-model)) and progresses through the same [Lifecycle Phases](#lifecycle-phases). - **Payment** — funds movement to or from an external counterparty. Payouts (`direction` = outbound) are created via [POST /v1/payments](/api/payments#create-payment). Inbound payments (`direction` = inbound) cannot currently be pre-registered but will be recorded and surfaced via webhooks and the API. - **Deposit** — inbound funds movement that on-ramps fiat from your bank to a Tesser-managed account, with optional conversion into stablecoin at a liquidity provider. Created via [POST /v1/treasury/deposits](/api/treasury#create-deposit). - **Withdrawal** — outbound funds movement that off-ramps stablecoin held at Tesser-managed accounts back to your bank. Created via [POST /v1/treasury/withdrawals](/api/treasury#create-withdrawal). - **Rebalance** — internal funds movement between two managed accounts (provider ledger to wallet, wallet to wallet, ledger to ledger). Created via [POST /v1/treasury/rebalances](/api/treasury#create-rebalance). ## Resource Data Model Each resource type is represented as a single JSON object with a consistent set of top-level fields, a `desired` / `estimated` / `actual` overlay, and the `steps[]` array. Resource-specific fields (`risk_status`, `balance_status`, `funding_account_id`) are added as applicable at the top-level of the record — see the [Resource-Specific fields](#resource-specific-fields) for which applies where. ### Shared Top-Level Fields All four resource types include the following top-level fields: - `id` — Tesser-assigned UUID for the resource. - `workspace_id` — UUID of the workspace that owns the resource. - `organization_reference_id` — optional client-supplied identifier (e.g., your internal payment or treasury operation ID). - `direction` — one of `outbound`, `inbound`, or `rebalance`. See the matrix below for which value each resource takes. - `created_at` — timestamp the resource was created at Tesser. - `updated_at` — timestamp of the most recent change to the resource. - `expires_at` — timestamp after which the resource will no longer execute. See [Expiration](#expiration). - `steps[]` — ordered array of execution steps Tesser plans for the resource. Populated shortly after creation and supplied via the `.quote_created` webhook. - `desired` / `estimated` / `actual` — the [overlay](#the-desired--estimated--actual-overlay) describing intent, projection, and outcome. Each contains `from` and `to` fields. ### Resource-Specific Fields Some fields and concepts apply only to a subset of resource types. The table below summarizes which top-level concepts appear on which resources. | Field / Concept | Payment | Deposit | Withdrawal | Rebalance | |---|---|---|---|---| | `risk_status` (risk check) | yes | no | no | no | | `balance_status` (balance check) | yes | no | yes | yes | | `funding_account_id` | yes | no | no | no | | `direction` value | `outbound`, `inbound` | `inbound` | `outbound` | `rebalance` | - **Risk check** screens the external sending or receiving wallet of a payment. - **Balance check** reserves funds in the `desired.from.account_id`; applied whenever funds are debited from a managed account (outbound payments, withdrawals, rebalances). - **`funding_account_id`** is unique to payments. It identifies the originator's account: who funds an outbound payout, or, on an inbound payment with travel-rule data, the originator's account at their VASP. On an inbound payment it is `null` when there is no travel-rule data, or when that data doesn't identify the originator or doesn't include their account number. See [Funding Account vs. From Account](#funding-account-vs-from-account) and [Travel Rule](/overviews/compliance-and-risk#travel-rule). #### Funding Account vs. From Account A payment names two accounts on the paying side, and they answer different questions: - **`funding_account_id`** says **who is paying**. It is identity for compliance and the travel rule. It must belong to a counterparty or tenant, never the workspace; Tesser rejects an outbound payment request whose `funding_account_id` belongs to the workspace with a `400` error, [`payments-3099`](/overviews/errors#payments). It may be the same account as `desired.from.account_id`, or a different one, such as the originator's bank account or their account at another institution. - **`desired.from.account_id`** says **where the funds move from**. It is the source leg of the funds movement, and its balance is what the [balance check](#balance-statuses) reserves. Which account types can fill each role: | Account type | `funding_account_id` | `from_account_id` / `to_account_id` | |---|---|---| | Managed wallet or ledger | Yes. When it holds an asset in the source currency (and, for a wallet, the source network), Tesser records that asset as the funding asset; otherwise the funding asset is empty. | Yes | | Bank account (`fiat_bank`) | Yes, identity only | Yes, where the flow moves fiat (deposit source, withdrawal or off-ramp destination) | | Unmanaged wallet | Yes, identity only | Yes, as a payment destination or an inbound payment's sender | | [Generic account](/how-tos/create-an-account#generic-account) (`generic`) | Yes, identity only. Tesser sets one on a travel-rule inbound payment when the data identifies the originator and includes their account number. | **Never** | A generic account has no network, currency, or balance, so it can't send or receive funds. Using one as `from_account_id` or `to_account_id` on a payment, as the account of a deposit, withdrawal, or rebalance, or requesting an on-chain address for it returns `400` with `accounts-3029`. The skeletons below show how those resource-specific differences manifest in the top-level shape of each non-payment resource. Overlay sub-objects and `steps[]` are elided — see [The Desired / Estimated / Actual Overlay](#the-desired--estimated--actual-overlay) below for the overlay shape and the [full payment example](#example-outbound-payment) further down for everything in context. `direction: "inbound"`. No risk fields, no `balance_status`, no `funding_account_id`. ```jsonc { "id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { /* from / to */ }, "estimated": { /* from / to */ }, "actual": { /* from / to */ }, "steps": [ /* one or more step objects */ ], "created_at": "2025-12-02T14:00:00.000Z", "updated_at": "2025-12-02T14:30:00.700Z", "expires_at": "2025-12-03T14:00:00.000Z" } ``` `direction: "outbound"`. Adds `balance_status` and `balance_reserved_at`. No risk fields, no `funding_account_id`. ```jsonc { "id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { /* from / to */ }, "estimated": { /* from / to */ }, "actual": { /* from / to */ }, "steps": [ /* one or more step objects */ ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.300Z", "expires_at": "2025-12-02T10:00:00.000Z" } ``` `direction: "rebalance"`. Adds `balance_status` and `balance_reserved_at`. No risk fields, no `funding_account_id`. ```jsonc { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { /* from / to */ }, "estimated": { /* from / to */ }, "actual": { /* from / to */ }, "steps": [ /* one or more step objects */ ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:30:00.200Z", "expires_at": "2025-12-02T10:00:00.000Z" } ``` By comparison, the [outbound payment example](#example-outbound-payment) below adds `risk_status`, `risk_status_reasons`, `risk_reviewed_by`, `risk_reviewed_at`, `funding_account_id`, plus `balance_status` and `balance_reserved_at` at the top level. ### The Desired / Estimated / Actual Overlay Funds movement at scale involves uncertainty: liquidity providers may not guarantee exchange rates, on-chain transactions may fail, and resources can terminate at intermediate steps if they don't complete by `expires_at`. To handle this gracefully, every resource carries three parallel snapshots of its `from` and `to` fields. - **Desired** — your stated intent at creation: the source and destination accounts, currencies, networks, and amount. Set on the create request and **never overwritten** afterward. This preserves intent for downstream reconciliation regardless of how the resource ultimately concludes. - **Estimated** — Tesser's best projection for how the resource will proceed if it succeeds. Populated after planning and quoting (see [Planning](#planning)). For resources that involve a swap or cross-currency step, the ratio of `estimated.from.amount` to `estimated.to.amount` is the indicative exchange rate. Fees can affect the destination amount even when the source and destination currencies are the same. - **Actual** — what actually settled. Populated as steps complete. Successful completion can produce amounts different from `estimated.*`, for example when a provider deducts a fee. On failure, `actual.*` reflects the funds movement completed so far — possibly a different account, currency, or network than `desired.*`. Each overlay contains a `from` and `to` field with the same four sub-fields: - `account_id` — the Tesser account UUID for that step. - `amount` — decimal string in currency units. Precision depends on the currency and the account's network or custody provider. - `currency` — currency code (e.g., `USD`, `USDC`, `MXN`). - `network` — blockchain network code (e.g., `BASE`, `POLYGON`), or `null` for non-network steps (bank accounts, provider ledgers). For example, a completed 100 CAD deposit with a 1.25 CAD provider fee can report `actual.from.amount: "100"`, `actual.to.amount: "98.75"`, and a step fee with `fee_amount: "1.25"`, `fee_currency: "CAD"`, and `fee_type: "provider"`. Use the actual amounts and recorded fees for reconciliation, including in webhook payloads. Each side uses the precision of its own account. Amounts are already in currency units, and trailing zeroes may be omitted. See [Currencies](/overviews/currencies) for precision lookup and decimal arithmetic guidance. #### Overlay applicability The desired fields are only included on the top-level of the record. The estimated and actual overlay structure is repeated on each step. | Overlay | Top-Level | Step | |---|---|---| | Desired | Yes | No | | Estimated | Yes | Yes| | Actual | Yes | Yes| The `estimated.from.*` fields at the top level of the record are populated from the `desired.from.*` fields. The `estimated.to.*` fields represent Tesser's best estimate for the outcome of last step of the resource. ### Steps The `steps[]` array represents the ordered sequence of steps Tesser plans to execute. Each step has the `estimated` and `actual` overlay, plus a `status` and timestamps for each phase transition. Two step types are currently used across all resource types: - `transfer` — funds move from one account to another. The source and destination accounts may differ in currency or network. Examples: an on-chain stablecoin transfer between two wallets; a fiat push from a bank to a liquidity provider's bank; a last-mile fiat payout via a local payment network. - `swap` — currencies are exchanged within the same account (the step's `estimated.from.account_id` and `estimated.to.account_id` are equal). Example: at a liquidity provider, selling USD and buying USDC inside the same provider ledger. Each step has a status. See [Step Statuses](#step-statuses) for more information. ### Example: Outbound Payment The JSON below shows the full shape of a successful USDC (Ethereum) → MXN payout, captured at terminal state. Note how the `desired` overlay appears only at the top level, while `estimated` and `actual` repeat on each step in `steps[]`. Resource-specific fields (`risk_status`, `balance_status`, `funding_account_id`) are all populated for outbound payments — compare against the deposit, withdrawal, and rebalance skeletons in [Resource-Specific Fields](#resource-specific-fields) above. Because this payout routes through a stablecoin off-ramp provider that quotes a deterministic delivery amount, `actual.to.amount` matches `estimated.to.amount` exactly; the only overlay divergence visible is on the source side, where `desired.from.amount` is `null` (the request fixed the `to.amount`) and `estimated.from.amount` carries Tesser's quote.
View full JSON resource ```json { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "transaction_hash": "0x1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7a8b9c0d1e2f", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:02.000Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": "2025-12-01T09:00:02.000Z", "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [ { "fee_amount": "1.50", "fee_currency": "USDC", "fee_type": "provider", "fee_metadata": {} } ], "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:02.500Z", "signature_requested_at": null, "signed_at": null, "submitted_at": "2025-12-01T09:00:02.100Z", "confirmed_at": "2025-12-01T09:00:02.300Z", "completed_at": "2025-12-01T09:00:02.500Z", "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:02.500Z", "expires_at": "2025-12-01T23:59:59.999Z" } ```
For the full webhook sequence leading to this terminal state, see [Create a payout from a wallet](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet). ### Fees Fees are reported as an array in the per-step `fees[]` object. Fees include blockchain network (gas) fees or provider fees. Each entry includes: - `fee_amount` - `fee_currency` - `fee_type` - (Optional) `fee_metadata` :::note Depending on the type, the amount of a fee may not be known until the step has executed (e.g. gas fees). Other times, the fee may be known in advance (e.g. if a fiat off-ramp charges a separate transaction fee for a payout). ::: ## Lifecycle Phases Every resource progresses through four phases: planning, pre-execution checks, execution, and terminal state. Each phase emits webhooks; the prefix is the resource type (`payment.*`, `deposit.*`, `withdrawal.*`, `rebalance.*`). ### Planning When you POST the create request, Tesser creates the resource synchronously and begins to plan the sequence of steps to execute the request, including the quote. The quote includes: - **Exchange rate** — The ratio of `estimated.from.amount` to `estimated.to.amount` is the indicative exchange rate between `estimated.from.currency` and `estimated.to.currency`. - For stablecoin-to-stablecoin same-currency transfers, the rate is always 1:1. - For cross-currency transfers, Tesser sources the best exchange rate from available liquidity providers. - **From or To amount** — For payments, you may request a quote based on the amount to send (specify `desired.from.amount`) or the to amount to receive (specify `desired.to.amount`). Tesser determines the other side of the overlay based on the prevailing rate. For deposits, withdrawals, and rebalances, you specify the `desired.from.amount`. Tesser then fires the `.quote_created` webhook. This event **always fires**, even for same-token, same-network moves, to keep the lifecycle uniform across resource types. The `.quote_created` webhook contains the planned `steps[]` array. Each step has a `status` of `created`. At this point the `desired` overlay is populated on the top-level of the resource. The `estimated` overlay is populated at the top level and on each step. `actual.*` fields are all null. ### Pre-Execution Checks Before steps begin executing, Tesser runs the checks that apply to the resource (per the [Resource-Specific fields](#resource-specific-fields) above). - **Risk check** (outbound payments only, as a pre-execution gate) — screens the destination wallet. The `payment.risk_updated` webhook reports the outcome via `risk_status` (see [Risk Statuses](#risk-statuses)). The outbound payment will not proceed to step signing until risk is approved (`automatically_approved` or `manually_approved`). If your organization's policy requires manual review for the result, a user with sufficient permissions can review the payment in the Tesser dashboard or submit a decision via [`POST /v1/payments/{paymentId}/review`](/api/payments#submit-risk-review). Inbound payments also undergo risk screening — but because the funds have already arrived on-chain, the screening is not a pre-execution gate. See [Receive an Inbound Payment to a Wallet](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet#inbound-payment-risk-review-paymentrisk_updated) for the inbound-specific timing and what it gates. - **Balance check** (payments, withdrawals, rebalances) — reserves source funds on the resource's source account. Two outcomes are possible: `reserved` (sufficient funds; the resource proceeds to execution) or `awaiting_funds` (insufficient funds; the resource is queued and will retry until `expires_at`). Reported via `.balance_updated`. Where the source account is a **provider ledger** (e.g. Circle ledger, OpenFX ledger), Tesser performs the balance check automatically by reading the ledger's available balance. Where the source account is a **self-custodial wallet**, the balance check is part of the step-signing process: Tesser emits `step.signature_requested`, you sign the step locally and submit the signature, and the synchronous response plus the subsequent `.balance_updated` webhook reflect the reservation outcome. ### Execution Once pre-execution checks pass, Tesser executes each step in order. Steps within the same resource may overlap (a fiat off-ramp step may begin before a preceding on-chain step has fully finalized), but each individual step transitions through the same status states in sequence. The step status progression when on-chain signing is not applicable is: ``` created → submitted → confirmed → completed ``` Any state after `created` (i.e., `submitted` or `confirmed`) may transition to `failed`. The step status path when on-chain signing is required is: ``` created → signature_requested → signed → submitted → confirmed → completed ``` Any state after `created` (i.e., `signature_requested`, `signed`, `submitted`, or `confirmed`) may transition to `failed`. Each transition emits a `step.` webhook. See [Step Statuses](#step-statuses) for definitions. :::note Sometimes, a step's `status` will progress directly from `created` to `completed` or `failed`. This can happen when providers emit limited webhooks to Tesser, which reduces the visibility Tesser has to granular intermediate movements. ::: ### Terminal State and Divergence When the last step reaches a terminal state, Tesser populates the top-level `actual.*` overlay and emits a `.updated` webhook carrying the full updated resource object. You can use this webhook to observe the terminal state without GET-ing the resource. - **`completed`** — `actual.from.*` and `actual.to.*` are populated. Where the resource includes a swap step, the actual exchange rate may differ slightly from the indicative quote. - **`failed`** — `actual.*` reflects what actually occurred. The resource may have terminated mid-route, leaving funds at an intermediate account in an intermediate currency. For example, an on-ramp deposit that fails at its swap step terminates with `actual.to.currency = "USD"` (fiat at the provider's ledger) even though `desired.to.currency = "USDT"`. ## Statuses Reference This section catalogs the status taxonomies used across the lifecycle. ### Risk Statuses Result of risk review performed on the external counterparty wallet of a payment — the destination wallet for outbound payments, the sending wallet for inbound payments. Risk statuses appear on payments only; see the [Resource-Specific fields](#resource-specific-fields). `risk_status_reasons` is populated with the reason codes when the status is `awaiting_decision`, `automatically_rejected`, or `manually_rejected`; it stays empty on approvals. | Status | Terminal | Webhook event type | Description | |---|---|---|---| | `unchecked` | No | Not sent. | Initial state on creation; risk screening has not yet been initiated. | | `awaiting_decision` | No | `payment.risk_updated` | The external wallet has been risk screened and requires manual review to determine whether the payment may proceed. | | `automatically_approved` | Yes | `payment.risk_updated` | The external wallet has been risk screened and automatically approved per your organization's policy. | | `manually_approved` | Yes | `payment.risk_updated` | The external wallet has been risk screened and has been manually reviewed and approved. | | `automatically_rejected` | Yes | `payment.risk_updated` | The external wallet has been risk screened and automatically rejected per your organization's policy. | | `manually_rejected` | Yes | `payment.risk_updated` | The external wallet has been risk screened and has been manually reviewed and rejected. | ### Balance Statuses Result of the balance check on the source account. Balance statuses appear on payments, withdrawals, and rebalances; see the [Resource-Specific fields](#resource-specific-fields). The webhook event type uses the resource-type prefix — e.g. `payment.balance_updated`, `withdrawal.balance_updated`, `rebalance.balance_updated`. | Status | Terminal | Webhook event type | Description | |---|---|---|---| | `unreserved` | No | Not sent. All resources at creation have a balance status of `unreserved`. | Source account has not been supplied or the reserve operation has not yet completed. | | `awaiting_funds` | No | `.balance_updated` | The balance of the source account was checked and there are insufficient funds. The resource is queued and awaits funding (e.g., from a deposit) until `expires_at`. | | `reserved` | Yes | `.balance_updated` | The balance of the source account was checked and funds were reserved to process the resource. | ### Step Statuses Result of step execution. Step statuses and their webhooks (`step.*`) are shared across all four resource types and all step types (`transfer`, `swap`, future types). | Status | Terminal | Webhook event type | Description | |---|---|---|---| | `created` | No | Not sent. All steps at creation have a status of `created`. | Tesser has created a record for this step. | | `signature_requested` | No | `step.signature_requested` | Signature has been requested for the step. Only applicable for self-custodial wallets. | | `signed` | No | `step.signed` | Step has been cryptographically signed. Only applicable for self-custodial wallets. | | `submitted` | No | `step.submitted` | Tesser submitted the step information to the blockchain or fiat payment network. | | `confirmed` | No | `step.confirmed` | The step was accepted by the network operator. For on-chain transfer steps, the transaction has been broadcast and accepted into the network's mempool — it is not yet included in a block, and can dwell here indefinitely (low gas, nonce conflict, mempool eviction); `step.completed` is the finality marker, not this. For off-chain and ledger-internal steps, this marks provider-side acknowledgment. | | `completed` | Yes | `step.completed` | The step delivered funds to the destination account. For on-chain steps, the transaction has been included in a block and reached the network's confirmation threshold. For fiat steps, indicates the local payment network has delivered funds. Some fiat networks may not provide formal confirmation, in which case funds are assumed delivered unless `failed` is indicated. | | `failed` | Yes | `step.failed` | The step was not successful; funds were not transferred from the source to the destination. | Some step instances depart from this canonical sequence: in one pattern, intermediate webhooks don't fire at all; in another, all webhooks fire but the recorded step state skips an intermediate stage. The notes below describe each. :::note For customer-initiated steps, e.g. fiat funds transfers to liquidity providers, only `step.completed` (or `step.failed`) fires. Tesser doesn't emit intermediate `step.submitted` or `step.confirmed` events; the step's `submitted_at` is null while `confirmed_at` populates from the provider notification timestamp. See [Execution](#execution). ::: :::note For self-custodial wallet-originated on-chain steps (provider `turnkey`), `step.submitted` is an event-only marker. Tesser fires the webhook when it broadcasts the signed transaction, and the step state transitions directly from `signed` to `confirmed` when the broadcast result lands. The `step.submitted` payload and subsequent GET responses still surface `status: "submitted"` and a populated `submitted_at`, so the lifecycle reads the same as other providers. ::: :::note{title="Inbound payments confirm differently"} The on-chain semantics above describe steps Tesser **initiates**: Tesser broadcasts the transaction, so `step.confirmed` marks mempool acceptance and `step.completed` marks block inclusion and finality. **Inbound payments** are the reverse — Tesser only **observes** a transfer it didn't send, and first becomes aware of it once the transaction has already been included in a block. For an inbound step, `step.confirmed` therefore means the transaction is **in a block but not yet finalized**, and `step.completed` means the block has reached finality. There is no mempool stage to report, because Tesser never held the transaction before it was mined. See [Receive an Inbound Payment to a Wallet](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet) for the full inbound lifecycle. ::: ## Expiration Every resource has an `expires_at` timestamp. The validity period varies by resource type, currency pair, route, and amount. After expiration, the resource will not execute and a new resource must be created to retry. :::warning{title="Why resources expire"} Even same-currency, same-network resources have an `expires_at`. Risk and compliance checks must be re-run after a certain amount of time has passed if the resource has not been executed, and cross-currency transfers need their quotes refreshed. ::: A resource enters the expired state when its `expires_at` passes before it has reached a completed terminal state. This can happen when steps were planned but didn't all reach `completed` in time, or when steps were never planned at all — for example, when no provider quote could be sourced before expiration. Concrete examples: a payment whose `balance_status` never reached `reserved` because the `desired.from.account_id` was not replenished in time; a deposit whose terminal step never confirmed; a withdrawal that expired before any provider quote was available. When this happens, Tesser emits a `.expired` webhook (`payment.expired`, `deposit.expired`, `withdrawal.expired`, `rebalance.expired`) carrying the resource's final state. The `actual.*` overlay on the payload reflects the last observed state. You can use this webhook to observe the expiration without GET-ing the resource — see [Payment Updates](/webhooks/payment-updates) and [Treasury Updates](/webhooks/treasury-updates) for the full event tables. To retry, create a new resource with fresh `desired.*` fields; you cannot resurrect an expired resource. --- ## Document: Errors Complete reference of all API error codes and their meanings URL: /overviews/errors # Errors {/* AUTO-GENERATED - DO NOT EDIT. Run: bun run apps/gateway/docs/scripts/generate-error-docs.ts */} ## Error Response Format All API errors return a consistent JSON response: ```json { "errors": [ { "error_code": "domain-YZZZ", "error_message": "Human-readable error description" } ] } ``` ## Error Code Convention Error codes follow the format `{domain}-{YZZZ}` where: - **domain** identifies the resource area (e.g., `accounts`, `payments`, `treasury`) - **Y** indicates the HTTP status category - **ZZZ** is the specific error number within that category | Range | HTTP Status | Meaning | | --- | --- | --- | | 1000–1999 | 404 | Not Found | | 2000–2999 | 401 / 403 | Unauthorized / Forbidden | | 3000–3999 | 400 / 409 | Bad Request / Conflict | | 4000–4999 | 429 | Too Many Requests | | 5000–5999 | 502 / 503 | Bad Gateway / Service Unavailable | :::note Some domains (Circle, Idempotency, Zuba) use legacy numbering that does not follow the range convention above. ::: ## Status Reasons When a step of a payment, deposit, withdrawal, or rebalance transitions to `failed`, or is held for an operator, the step carries a `status_reasons` array explaining why. Each entry reuses the error vocabulary above — an `error_code` and `error_message`: ```json "status_reasons": [ { "error_code": "transfers-9201", "error_message": "An upstream step failed, so this step was not executed" } ] ``` `status_reasons` is a single shared taxonomy across all resources, so the codes carry the resource-neutral `transfers-` prefix (see the [Transfers](#transfers) table below) regardless of which resource's step failed. They occupy a reserved `9xxx` block — `91xx` precondition, `92xx` lifecycle, `93xx` execution — and are present on `failed` steps and on a step held for an operator, which carries its `92xx` hold code while it is not terminal; any other step's `status_reasons` is an empty array. Provider or chain error detail is incorporated into the `error_message`; the `error_code` always stays within this taxonomy. ## Accounts
Error Code HTTP Status Error Message Description
`accounts-1000` 404 Account with id '{id}' not found The specified account ID does not exist in this workspace
`accounts-1001` 404 Entity with id '{id}' not found The tenant or counterparty specified does not exist
`accounts-2000` 403 The entity does not belong to your workspace User tries to link an account to an entity that belongs to a different workspace
`accounts-3000` 400 Cannot provide both tenant_id and counterparty_id User provides both tenant_id and counterparty_id when only one is allowed
`accounts-3001` 400 A workspace-level bank account already exists. Only one is allowed per workspace. User tries to create a second workspace-level bank account
`accounts-3002` 400 wallet_address is required for unmanaged wallets (is_managed=false) User creates an unmanaged wallet without providing a wallet_address
`accounts-3003` 400 Cannot determine network for wallet type '{type}' The provided wallet type does not map to a known blockchain network
`accounts-3004` 400 signature is required for managed wallets (is_managed=true) User creates a managed wallet without providing the required signature
`accounts-3005` 400 Invalid signature format. Expected base64-encoded JSON with body and stamp fields. The signature parameter could not be decoded or is missing required fields
`accounts-3006` 400 Circle Mint ledgers require a tenant_id or counterparty_id. Workspace-level sub-ledgers are not supported. User tries to create a Circle Mint ledger without linking it to a tenant or counterparty
`accounts-3007` 400 This entity already has a Circle Mint ledger. Only one Circle Mint ledger per tenant/counterparty is allowed. User tries to create a duplicate Circle Mint ledger for the same entity
`accounts-3008` 400 A workspace-level Circle Mint master wallet ledger already exists. User tries to create a second master wallet ledger for the workspace
`accounts-3009` 400 CIRCLE_MINT provider requires a tenant_id or counterparty_id Circle Mint metadata preparation requires an entity to be linked
`accounts-3010` 400 CIRCLE_MINT provider requires a business entity (not individual) Circle Mint only supports business-classified entities, not individuals
`accounts-3011` 400 Entity is missing required fields for Circle The entity does not have all required business fields populated for Circle onboarding
`accounts-3012` 400 Cannot create VAN: compliance state is not ACCEPTED Virtual account number creation requires the account to have ACCEPTED compliance state
`accounts-3013` 400 Cannot create VAN: circle_wallet_id is missing from account metadata The ledger account does not have a Circle wallet ID configured
`accounts-3014` 400 No workspace-level bank account found. Please create a bank account first. A workspace-level bank account is required but none exists
`accounts-3015` 400 No wire bank accounts found in Circle. Please register a bank account on Circle dashboard first. Circle does not have any wire bank accounts registered for this workspace
`accounts-3016` 400 Multiple wire bank accounts found in Circle Production requires exactly one bank account in Circle, but multiple were found
`accounts-3017` 400 Could not determine Circle bank ID Failed to resolve the Circle bank ID needed for wire transfers
`accounts-3018` 400 Unsupported provider. Must be one of : CIRCLE_MINT, KRAKEN The specified provider is not supported for this operation. Must be one of : CIRCLE_MINT, KRAKEN
`accounts-3019` 400 Account does not have a provider configured The account metadata does not contain provider configuration
`accounts-3020` 400 Entity type must be 'counterparty' or 'tenant' The referenced entity is not a valid type for account linking
`accounts-3022` 400 Signature body has unexpected activity type '{actual}', expected '{expected}' The stamped Turnkey activity inside the signature is not the expected CREATE_WALLET activity
`accounts-3023` 400 Signature body organizationId does not match the authenticated workspace's Turnkey sub-organization The caller stamped an activity targeting a sub-organization that does not belong to their workspace
`accounts-3024` 400 Signed wallet account addressFormat '{actual}' does not match type '{type}' (expected '{expected}') The stamped CREATE_WALLET parameters describe an address format that does not match the dto.type
`accounts-3025` 400 Managed wallets are only supported for type 'stablecoin_ethereum' (got '{type}') User attempted to create a managed (is_managed=true) wallet for a non-Ethereum wallet type
`accounts-3026` 409 The provider's MXN virtual account for this originator is already registered to a different counterparty. The cross-ramp provider returned a deposit CLABE that Tesser already mirrors as a Capa virtual account owned by another entity. Refused rather than re-pointing the deposit leg; needs manual investigation with the provider.
`accounts-3027` 400 Generic accounts require a tenant_id or counterparty_id User creates a generic account without linking it to a tenant or counterparty. A generic account identifies an account held by exactly one owner.
`accounts-3028` 409 A generic account with this generic_account_number already exists for this owner: '{id}' The owner already has a generic account with the same generic_account_number, created by the customer or by Tesser from a travel-rule inbound. The message names the existing account's id; use that account instead.
`accounts-3029` 400 Account '{id}' is a generic account and cannot send or receive funds User passes a generic account as the source or destination of a payment, deposit, withdrawal or rebalance, or requests an on-chain address for it. A generic account has no network, currency or balance, so it can only be a payment's funding_account_id.
`accounts-3100` 400 account_name is required and must be between 1-255 characters User provides an account name that doesn't meet length requirements
`accounts-3101` 400 tenant_id must be a valid UUID User provides a tenant_id that is not a valid UUID
`accounts-3102` 400 counterparty_id must be a valid UUID User provides a counterparty_id that is not a valid UUID
`accounts-3103` 400 bank_name is required and must be between 1-255 characters User provides a bank name that doesn't meet length requirements
`accounts-3104` 400 bank_code_type must be one of: SWIFT, BIC, IBAN, ROUTING User provides a bank_code_type that is not in the allowed enum
`accounts-3105` 400 bank_identifier_code is required User creates a bank account without providing the bank identifier code
`accounts-3106` 400 bank_account_number is required User creates a bank account without providing the account number
`accounts-3107` 400 type must be one of: stablecoin_ethereum, stablecoin_solana, stablecoin_stellar User provides a wallet type that is not in the allowed enum
`accounts-3108` 400 wallet_address must be a valid blockchain address for the specified network User provides a wallet address that doesn't match the expected format for the network
`accounts-3109` 400 Wallet signature is malformed or invalid User provides a signature that is not properly formatted or cannot be verified
`accounts-3110` 400 provider must be one of: CIRCLE_MINT, KRAKEN User provides a provider value that is not in the allowed enum
`accounts-3111` 400 This account does not support on-chain deposit addresses User requests an on-chain deposit address for an account whose type or provider does not support them
`accounts-3112` 400 Ledger account has not completed compliance review User requests an on-chain deposit address before the ledger's provider compliance review is accepted
`accounts-3113` 400 network is not supported for on-chain deposit addresses User requests an on-chain deposit address on a network Circle does not support in this environment
`accounts-3114` 400 account has too many transfers to sort by amount Dashboard user sorts an account's activity by amount when the account has more transfers than the amount sort reads
`accounts-5000` 502 Failed to create wallet. Please try again or contact support if the issue persists. Turnkey or other wallet provider failed during wallet creation
`accounts-5001` 502 Failed to create Circle external entity Circle API call to create the external entity failed
`accounts-5002` 502 Unable to create virtual account number (VAN) for this ledger account. Circle API call to create the virtual account number failed
`accounts-5003` 502 Failed to retrieve entity business fields from vault Basis Theory vault call to retrieve entity fields failed
`accounts-5004` 502 Failed to provision the on-chain deposit address Provider API call to list or create the deposit address failed
## Counterparties
Error Code HTTP Status Error Message Description
`counterparties-1000` 404 Counterparty not found The specified counterparty ID does not exist
`counterparties-1001` 404 Tenant with id '{id}' not found. Cannot assign counterparty to non-existent tenant. User tries to assign a counterparty to a tenant_id that doesn't exist
`counterparties-2000` 403 You do not have access to this counterparty User tries to access a counterparty that belongs to a different workspace
`counterparties-3002` 400 classification must be 'individual' or 'business' User provides a classification value that is not 'individual' or 'business'
`counterparties-3003` 400 business_legal_name is required for business counterparties and must be 1-255 characters User creates a business counterparty with missing or improperly formatted business_legal_name
`counterparties-3004` 400 business_address_country is required for business counterparties and must be a valid ISO 3166-1 alpha-2 country code User provides a business_address_country that is not a valid 2-letter country code (e.g., "US", "GB")
`counterparties-3005` 400 individual_first_name is required for individual counterparties and must be 1-255 characters User creates an individual counterparty with missing or improperly formatted first name
`counterparties-3006` 400 individual_last_name is required for individual counterparties and must be 1-255 characters User creates an individual counterparty with missing or improperly formatted last name
`counterparties-3007` 400 individual_address_country is required for individual counterparties and must be a valid ISO 3166-1 alpha-2 country code User provides an individual_address_country that is not a valid 2-letter country code
`counterparties-3008` 400 individual_date_of_birth must be a valid date in YYYY-MM-DD format User provides a date_of_birth that doesn't match the required format or is not a valid date
`counterparties-3009` 400 tenant_id must be a valid UUID User provides a tenant_id that is not a validly formatted UUID
`counterparties-3010` 400 individual_street_address1 is required for individual counterparties User creates an individual counterparty without providing the street address
`counterparties-3011` 400 individual_city is required for individual counterparties User creates an individual counterparty without providing the city
`counterparties-3012` 400 individual_postal_code is required for individual counterparties User creates an individual counterparty without providing the postal code
`counterparties-3013` 400 business_street_address1 is required for business counterparties User creates a business counterparty without providing the street address
`counterparties-3014` 400 business_city is required for business counterparties User creates a business counterparty without providing the city
`counterparties-3015` 400 business_postal_code is required for business counterparties User creates a business counterparty without providing the postal code
## Currencies
Error Code HTTP Status Error Message Description
`currencies-1000` 404 Currency not found The specified currency code does not exist
`currencies-3000` 400 Invalid currency code format User provides a currency code that is not a valid ISO 4217 format (e.g., USD, EUR, GBP)
`currencies-3001` 400 Currency is not supported for this operation User attempts to use a currency that is valid but not supported for the requested operation
`currencies-3002` 400 Invalid currency pair for exchange User attempts to exchange between currencies where the pair is not supported
## Networks
Error Code HTTP Status Error Message Description
`networks-1000` 404 Network not found The specified network identifier does not exist
`networks-3000` 400 Invalid network identifier User provides a network identifier that is not a valid format (e.g., ethereum, polygon, base)
`networks-3001` 400 Network is not supported for this operation User attempts to use a network that is valid but not supported for the requested operation
`networks-3002` 400 Currency is not compatible with the specified network User provides a currency/network combination that is not compatible (e.g., BTC on Ethereum network)
`networks-3003` 400 Network is currently unavailable or under maintenance User attempts to use a network that is temporarily unavailable
`networks-3004` 400 Currency is not yet transactable on this network The requested currency/network pair is supported for balance tracking but transactional flows (deposit, payment, rebalance) are not yet enabled.
`networks-5000` 502 Failed to connect to network RPC endpoint Connection to the blockchain network RPC endpoint failed due to external service error
## Payments
Error Code HTTP Status Error Message Description
`payments-0007` 500 Account asset balance is in an invariant-violating state The stored account_assets row for {account_asset_id} has available_balance ({available}) greater than total_balance ({total}); refusing to mutate corrupt state
`payments-0008` 500 Balance mutation amount must be non-negative BalanceService.{method} was called with a negative amount ({amount}); refusing to mutate
`payments-1000` 404 Payment not found The specified payment ID does not exist
`payments-1001` 404 Payment transfer step not found with id '{id}' The step does not exist or does not belong to this payment
`payments-1002` 404 Destination account not found to_account did not resolve to an account in this workspace
`payments-2001` 403 Cross-ramp payments are not enabled for this workspace. The corridor is served on this deployment, but the cross-ramp feature flag is not enabled for the requesting workspace. Enablement is per workspace; contact support to request access.
`payments-3000` 400 from_network must equal to_network User provides different networks for from_network and to_network
`payments-3001` 400 invalid from_amount or to_amount from_amount and to_amount should be positive and be valid numbers
`payments-3002` 400 Either from_amount or to_amount must be provided. Cannot create payment without an amount. User does not provide either from_amount or to_amount
`payments-3003` 400 Only one of from_amount or to_amount should be provided. The other will be calculated using the exchange rate. User provides both from_amount and to_amount when only one should be provided
`payments-3004` 400 from_network is required for crypto-to-crypto payments User creates a payment with stablecoin from_currency but does not provide from_network
`payments-3005` 400 to_network is required for crypto-to-crypto payments User creates a payment with stablecoin to_currency but does not provide to_network
`payments-3006` 400 Onramp (fiat-to-crypto) payments are not yet supported. Coming soon! User attempts to create an onramp payment which is not supported yet
`payments-3007` 400 Invalid from_currency User provides a from_currency that is not supported
`payments-3008` 400 Invalid to_currency User provides a to_currency that is not supported
`payments-3009` 400 funding_account_id not found User provides a funding_account_id that does not exist
`payments-3010` 400 to_account_id not found User provides a to_account_id that does not exist
`payments-3011` 400 from_account_id not found User provides a from_account_id that does not exist
`payments-3012` 400 Missing input parameter is_approved User submits a payment review without the required is_approved parameter
`payments-3015` 400 The exchange rate quote has expired. Please create a new payment to get a fresh quote. User attempts to execute a payment with an expired exchange rate quote. Quote lifetimes vary by provider, from about 10 seconds to 24 hours.
`payments-3016` 400 Payment quote expiration data is missing. Please create a new payment to get a fresh quote. Payment has an exchange rate but is missing expiration data, indicating a data integrity issue
`payments-3017` 400 to_account has not yet been risk approved by custodian Beneficiary account has a Circle recipient that is still pending verification or has not been registered yet
`payments-3018` 400 from_account_id and to_account_id should not both be managed accounts for payments. Use /v1/treasury/rebalances instead. Transfers between two managed accounts are treasury operations. Use /v1/treasury/rebalances instead.
`payments-3019` 400 Account asset not found for {from_account_id} No matching account asset found for the from account with the specified currency and network
`payments-3020` 400 No {currency} balance found on network {network} for {to_account_id} The to account does not have a balance for the specified currency and network
`payments-3021` 400 Amounts not yet calculated. Payment may still be processing. Payment amounts have not been calculated yet, the payment may still be processing
`payments-3022` 400 Payment missing currency information Payment is missing required currency or network data
`payments-3023` 400 Payment has not passed compliance screening. Cannot execute. Payment has not been approved through compliance/risk screening
`payments-3024` 400 Payment does not have reserved balance. Cannot execute. Payment balance has not been reserved prior to execution
`payments-3025` 400 No transfer step found for payment Payment does not have a transfer step
`payments-3026` 400 Transfer step is already in status {status}. Cannot execute. Transfer step is in a status that does not allow execution
`payments-3027` 400 Transfer step missing account asset IDs. Cannot execute. Transfer step is missing the required from or to account asset IDs
`payments-3028` 400 Could not load accounts for execution Failed to load the from or to account required for payment execution
`payments-3029` 400 Payment missing required data for step creation Payment is missing required currency, amount, or network information for step creation
`payments-3030` 400 To asset is required for onchain payments Onchain payment steps require a to account asset to be resolved
`payments-3031` 400 To account {id} is not a fiat account The to account is not the expected fiat_bank type for offramp payments
`payments-3032` 400 Account {id} does not belong to workspace The referenced account does not belong to the payment's workspace
`payments-3033` 400 Account not found for address {address} No account found with the specified wallet address
`payments-3034` 400 Account {id} has no entity ID The account is missing the required entity ID for payment processing
`payments-3035` 400 Fiat account {id} not found for workspace The specified fiat account was not found in the workspace
`payments-3036` 400 Payment has not been updated with accounts. Please call PATCH /payments/:paymentId first. Payment must be updated with account IDs before execution
`payments-3038` 400 Unsupported network for signing: {network} The payment network is not supported for transaction signing
`payments-3040` 400 Cannot execute payment from external account {id} External accounts support CRUD operations only. Use managed accounts for payment execution.
`payments-3041` 400 No payment execution provider available for this payment No supported provider (Circle) could be resolved for this payment type and account combination
`payments-3042` 400 Source account asset not found The source account asset could not be found for payment execution
`payments-3043` 400 Source account wallet address not found The source account does not have a crypto wallet address
`payments-3044` 400 Destination account asset not found The destination account asset could not be found for payment execution
`payments-3045` 400 Destination account wallet address not found The destination account does not have a crypto wallet address
`payments-3047` 400 Wallet address mismatch The provided wallet address does not match the account's wallet address
`payments-3048` 400 Amount mismatch between transfer step and provided amount The provided amount does not match the transfer step amount
`payments-3049` 400 Transfer step missing from account asset ID The transfer step does not have a from account asset ID set
`payments-3052` 400 direction must be 'inbound' or 'outbound' GET /payments only filters transfers by direction; rebalance and other internal directions are rejected
`payments-3053` 400 Simulated inbound payments are not available in production Inbound payment simulation is not available in production environments
`payments-3054` 400 Unsupported network for inbound payment simulation The network is not supported by this inbound simulation endpoint; each endpoint documents its supported USDC testnets
`payments-3055` 400 Destination account cannot receive a simulated inbound to_account exists but is not a managed stablecoin_ethereum wallet with an on-chain address
`payments-3060` 400 Payment step is not in a signable state The step must be a turnkey step in 'signature_requested' state with a prepared unsigned transaction to accept a signature
`payments-3061` 400 Payment step is missing an unsigned transaction payload The payment has not yet prepared an unsigned transaction — retry in a moment
`payments-3062` 400 Payment step is missing the source amount or currency The step's estimated source amount and currency must be set before signing
`payments-3063` 400 Source wallet has insufficient on-chain balance The live on-chain balance of the source wallet is below the payment amount; fund the wallet and retry before the payment expires
`payments-3064` 400 Turnkey could not produce a signed transaction Turnkey rejected or failed the stamped sign_transaction activity. Verify the stamp matches the body bytes, the API key is registered in the sub-org, and the wallet exists in that sub-org.
`payments-3065` 400 Signature is invalid or could not be verified The Turnkey stamp did not produce a valid signed transaction
`payments-3066` 400 Signed transaction does not match the payment details The destination address or amount in the signed transaction does not match the prepared payment step
`payments-3067` 400 Signature is not a valid base64-encoded {body, stamp} envelope The signature field must be base64-encoded JSON containing the Turnkey activity body and stamp header value
`payments-3068` 400 Currency is not supported for on-chain payment signing Only currencies with a registered token contract on the payment network can be signed
`payments-3069` 400 Network is not supported for on-chain payment signing The payment network has no registered on-chain signing configuration
`payments-3070` 400 Stamped activity type must be '{expected}', received '{actual}' The stamped Turnkey activity must be ACTIVITY_TYPE_SIGN_TRANSACTION_V2 to sign a payment step
`payments-3071` 400 Stamped activity unsigned transaction does not match the payment step The unsigned transaction inside the stamped Turnkey activity must equal the unsigned transaction prepared by the payment step
`payments-3072` 400 Stamped activity sign_with address does not match the payment source wallet The sign_with parameter inside the stamped Turnkey activity must equal the source wallet's on-chain address
`payments-3073` 400 Stamped activity transaction type must be '{expected}', received '{actual}' The transaction type inside the stamped Turnkey activity must match the payment network's Turnkey transaction type
`payments-3074` 400 Stamped activity organization does not match the workspace's Turnkey sub-organization The stamped Turnkey activity's organization id must equal the workspace's configured Turnkey sub-org ID
`payments-3075` 400 Network '{network}' is not supported in this environment Payments from managed wallets only support mainnet networks in production and their testnet counterparts in non-production environments
`payments-3076` 400 from_account_id '{from_account_id}' cannot fund outbound payments: no execution provider available for this account The source account does not resolve to a payment execution provider. Outbound payments must be funded from a managed wallet or a provider-backed ledger account (e.g. the Circle Mint master ledger).
`payments-3077` 400 Required CPN compliance field {field} could not be resolved from the payment's counterparty/account data The Circle Payments Network quote requires a travel-rule or beneficiary-account field for which no value (or no mapping) exists on the originator, beneficiary, beneficiary bank account, or OFI identity. Tesser validates existing data and fails fast rather than collecting new data.
`payments-3078` 400 Cross-currency payments are not supported. from_currency and to_currency must match. User attempts a cross-currency payment; payments are same-currency stablecoin only
`payments-3079` 409 Payment accounts can no longer be changed; the payment has already been planned. PATCH attempted account changes after the payment's steps were planned; accounts are frozen once planning runs
`payments-3080` 409 The on-chain transaction was rejected because its nonce was already used by another transaction from this wallet. This payment is now in a failed state. You must create a new payment in order to try again. Another transaction from the same wallet consumed this nonce before the signed transaction reached the network. The step is already failed and its signed transaction carries the spent nonce, so this payment cannot be retried — create a new payment, which is planned against a fresh nonce.
`payments-3081` 500 The signed transaction could not be broadcast The network rejected the signed transaction for a reason other than a nonce conflict; the node's reason is on the step's status_reasons.
`payments-3082` 409 This payment waited for earlier payments from the same wallet to be submitted, but the wait expired. This payment is now in a failed state. You must create a new payment in order to try again. The signed transaction's nonce was ahead of the wallet's submission watermark and the missing lower nonces were not submitted within the wait window. The step is already failed; a new payment is planned against a fresh nonce.
`payments-3083` 400 Travel-rule inbound simulation is not enabled for this organization The notabene-travel-rule-inbound feature flag must be enabled for the calling organization
`payments-3084` 503 Travel-rule inbound simulation is not configured in this environment The platform-level simulator VASP credentials are absent in this environment. Nothing in the request can fix this — the environment is missing configuration.
`payments-3085` 400 This organization has no Notabene VASP configured The organization must have Notabene credentials, including its vaspDid, stored before it can receive a simulated travel-rule inbound
`payments-3086` 400 The destination account '{id}' has no beneficiary name to claim The simulated counterparty claims the destination owner's own name by default, and the destination resolved to no usable party name. Supply claimed_beneficiary_name, or give the account's entity a name.
`payments-3087` 400 Cross-ramp payments are quoted by the delivery amount. Provide desired.to.amount instead of desired.from.amount. User creates a cross-currency payment on a served corridor with only the source amount; the quote-first provider quotes by the target amount
`payments-3088` 409 This originator already has a locked cross-ramp awaiting funds. Wait for it to be funded or expire, then retry. Serialization guard (launch default): only one locked, awaiting-funds cross-ramp per originator, so concurrent deposits to the shared MXN VA cannot be confused. Retriable once the in-flight order funds or expires.
`payments-3089` 400 The originator behind desired.from.account_id has not been onboarded with the cross-ramp provider. The counterparty owning the funding account carries no cross-ramp provider user id; the originator must be onboarded with the provider before a cross-ramp can be locked
`payments-3090` 400 The originator behind desired.from.account_id has not completed KYB with the cross-ramp provider yet. The originator is onboarded but its provider verification status is not VERIFIED; the lock is refused before any provider call so the quote window and originator claim are not spent on an order the provider would reject
`payments-3093` 400 The cross-ramp provider could not produce a quote for this payment. Recorded on the failed step when the async quote could not be minted for a reason a retry will not change (unmapped payout country, empty provider plan)
`payments-3094` 400 This corridor is quoted by the send amount. Provide desired.from.amount and leave desired.to.amount empty. User creates a cross-currency payment on a corridor priced forward from the source amount (e.g. CAD to NGN) with the delivery amount instead. Nothing was created.
`payments-3095` 400 This field cannot be set when requesting a quote on this corridor The corridor is quoted without accounts or networks: Tesser chooses the network of every leg. Payments on this corridor carry a quote only and cannot yet be accepted: PATCH returns a 409 conflict and attaches no accounts. Nothing was created.
`payments-3096` 409 The payment has started executing; organization_reference_id and payment_info can no longer be changed. PATCH repeated the committed accounts with a different organization_reference_id or payment_info after the commit started execution. Nothing was changed; resend the committed values or omit them.
`payments-3097` 400 funding_account_id '{id}' must be the originator's bank account, owned by a tenant or counterparty On a corridor funded from a pre-funded fiat ledger, funding_account_id names the originator for the Travel Rule, so it must be a bank account attached to the originator's tenant or counterparty. Nothing was changed.
`payments-3098` 409 This workspace has no {account} to route the payment through The payment moves funds through accounts Tesser manages for the workspace: its system Default Wallet, created with the workspace, and its Zuba ledger. One is missing, so the accounts were not attached. Provision it, then retry the PATCH.
`payments-3099` 400 funding_account_id must belong to a counterparty or tenant User provides a funding_account_id owned by the workspace
## Tenants
Error Code HTTP Status Error Message Description
`tenants-1000` 404 Tenant not found The specified tenant ID does not exist
`tenants-2000` 403 You do not have access to this tenant User tries to access a tenant that belongs to a different workspace
`tenants-3000` 400 business_legal_name is required to create a tenant User creates a tenant without providing the required business_legal_name field
`tenants-3001` 400 business_legal_name is required and must be 1-255 characters User provides a business_legal_name that doesn't meet length requirements
`tenants-3002` 400 business_address_country must be a valid ISO 3166-1 alpha-2 country code User provides a business_address_country that is not a valid 2-letter country code
`tenants-3003` 400 webhook_url must be a valid HTTPS URL User provides a webhook_url that is not a properly formatted HTTPS URL
## Treasury
Error Code HTTP Status Error Message Description
`treasury-1000` 404 Rebalance with id '{id}' not found The specified rebalance ID does not exist
`treasury-1001` 404 Transfer step not found for rebalance with id '{id}' The transfer step associated with the rebalance could not be found
`treasury-1002` 404 Account with id '{id}' not found The specified account ID does not exist or does not belong to this workspace
`treasury-1100` 404 Deposit with id '{id}' not found The specified deposit ID does not exist in this workspace
`treasury-1101` 404 Account with id '{id}' not found The specified account ID does not exist or does not belong to this workspace
`treasury-1102` 404 Source account with id '{id}' not found The source bank account for this deposit could not be found
`treasury-1103` 404 VAN account with id '{id}' not found The virtual account number (VAN) account could not be found
`treasury-1200` 404 Withdrawal with id '{id}' not found The specified withdrawal ID does not exist in this workspace
`treasury-1201` 404 Source account '{id}' not found The withdrawal source account does not exist or does not belong to this workspace
`treasury-1202` 404 Destination bank account '{id}' not found The withdrawal destination bank account does not exist or does not belong to this workspace
`treasury-1203` 404 Transfer step not found for withdrawal with id '{id}' The transfer step associated with the withdrawal could not be found
`treasury-2100` 409 Deposit is still being planned. Wait for the deposit.created webhook before fetching instructions. Instructions were requested while the deposit's async planning step is still running (steps array is empty). Retry after the deposit.created webhook fires.
`treasury-3000` 400 Unsupported rebalance direction. Supported: ledger→ledger, ledger→wallet, wallet→wallet The source/destination account types are not a supported rebalance flow
`treasury-3001` 400 Network must not be specified for ledger accounts Ledger-to-ledger rebalances operate without a blockchain network
`treasury-3002` 400 At least one of from_amount or to_amount must be provided A rebalance requires at least one amount to determine the transfer size
`treasury-3003` 400 Invalid currency pair for rebalance The specified from_currency and to_currency combination is not supported for Circle rebalancing
`treasury-3004` 400 from_amount and to_amount must be equal when currencies are the same When both amounts are provided for same-currency rebalances, they must match
`treasury-3005` 400 Source or destination account is missing Circle wallet ID The account does not have a Circle wallet ID configured, which is required for ledger transfers
`treasury-3006` 400 Transfer step is missing account asset IDs The transfer step does not have the required from/to account asset references
`treasury-3007` 400 Network is required when rebalancing between wallet accounts On-chain wallet-to-wallet rebalances need from_network and to_network to be set
`treasury-3008` 400 from_network and to_network must match for wallet-to-wallet rebalance Cross-chain wallet-to-wallet rebalance is not supported yet; source and destination network must be identical
`treasury-3009` 400 Wallet account is missing a crypto wallet address On-chain wallet-to-wallet rebalance requires both accounts to have a crypto wallet address
`treasury-3010` 400 Wallet-to-wallet rebalance is only supported between managed wallets Both source and destination accounts must be Tesser-managed (is_managed = true) wallet accounts
`treasury-3011` 400 Currency '{currency}' is not supported for wallet-to-wallet rebalance On-chain wallet-to-wallet rebalance currently supports USDC only
`treasury-3012` 400 Network '{network}' is not supported for wallet-to-wallet rebalance On-chain wallet-to-wallet rebalance currently supports BASE and BASE_SEPOLIA only
`treasury-3013` 400 Rebalance step is not in a signable state The step must be a turnkey step in 'signature_requested' state with a prepared unsigned transaction to accept a signature
`treasury-3014` 400 Rebalance step is missing an unsigned transaction payload The rebalance has not yet prepared an unsigned transaction — retry in a moment
`treasury-3015` 400 Signature is invalid or could not be verified The Turnkey stamp did not produce a valid signed transaction
`treasury-3016` 400 Signed transaction does not match the rebalance details The destination address or amount in the signed transaction does not match the prepared rebalance step
`treasury-3017` 400 Destination wallet is not registered with Circle or compliance is not active The destination managed wallet must have an active Circle business recipient registration before it can receive rebalance transfers
`treasury-3018` 400 network is required when the account is a wallet Rebalance legs referencing a stablecoin wallet must specify that leg's blockchain network; the appended field names the leg
`treasury-3019` 400 Network '{network}' is not supported in this environment Rebalances to managed wallets only support BASE in production and BASE_SEPOLIA in non-production environments
`treasury-3020` 400 Signature is not a valid base64-encoded {body, stamp} envelope The signature field must be base64-encoded JSON containing the Turnkey activity body and stamp header value
`treasury-3021` 400 Stamped activity type must be '{expected}', received '{actual}' The stamped Turnkey activity must be ACTIVITY_TYPE_SIGN_TRANSACTION_V2 to sign a rebalance step
`treasury-3022` 400 Stamped activity unsigned transaction does not match the rebalance step The unsignedTransaction inside the stamped Turnkey activity must equal the unsigned transaction prepared by the rebalance step
`treasury-3023` 400 Stamped activity signWith address does not match the rebalance source wallet The signWith parameter inside the stamped Turnkey activity must equal the source wallet's on-chain address
`treasury-3024` 400 Stamped activity organizationId does not match the workspace's Turnkey sub-organization The stamped Turnkey activity's organizationId must equal the workspace's configured Turnkey sub-org ID
`treasury-3025` 400 Turnkey could not produce a signed transaction Turnkey rejected or failed the stamped sign_transaction activity. Verify the stamp matches the body bytes, the API key is registered in the sub-org, and the wallet exists in that sub-org.
`treasury-3026` 400 Stamped activity parameters.type must be '{expected}', received '{actual}' The Turnkey transaction type inside parameters.type must match the network of the rebalance step (e.g. TRANSACTION_TYPE_ETHEREUM for BASE)
`treasury-3027` 500 OpenFX rebalance step is missing required metadata ({reason}) for id '{id}' The transfer step or destination account is missing fields the OpenFX withdrawal trigger needs to dispatch the rebalance
`treasury-3028` 409 Source wallet has insufficient on-chain balance to sign The wallet's live on-chain balance is below the rebalance amount at sign time. The balance is re-checked when you retry; rebalance.balance_updated fires with balance_status 'awaiting_funds'.
`treasury-3029` 502 Failed to generate an OpenFX quote for {fromCurrency} → {toCurrency} OpenFX could not return a projection quote for the cross-currency rebalance swap leg. Retry; if it persists the pair may be unsupported by OpenFX.
`treasury-3030` 400 Destination wallet is registered with Circle on a different network than the requested one Rebalance to a Circle-bound wallet whose registered recipient chain doesn't match the requested to_network.
`treasury-3031` 400 Destination wallet is not registered with OpenFX for the requested coin and network Rebalance to a wallet that has no matching active OpenFX withdrawal address for the coin+network.
`treasury-3100` 400 Account '{id}' is not a ledger account Deposits can only be made to ledger accounts
`treasury-3101` 400 Account '{id}' is not configured for deposits. No supported provider found The destination account does not have a supported deposit provider configured
`treasury-3102` 400 Ledger '{id}' is missing Circle Mint metadata The ledger account does not have the required Circle Mint configuration
`treasury-3103` 400 Circle compliance not yet accepted for ledger. Cannot create deposit Circle compliance must be accepted before deposits can be created
`treasury-3104` 400 Source account '{id}' does not match the configured source bank for this ledger The provided source account does not match the bank account configured for this ledger
`treasury-3105` 400 Source bank account '{id}' not found or is not a fiat bank account The source bank account could not be found or is not the correct account type
`treasury-3106` 400 Ledger account '{id}' does not have a '{currency}' asset The destination ledger account does not hold the requested currency
`treasury-3107` 400 Invalid currency combination for Circle deposit: {fromCurrency} → {toCurrency} The specified from_currency and to_currency combination is not supported for Circle deposits
`treasury-3108` 400 Deposit '{id}' is already finalized. Wire instructions are no longer available The deposit has already been finalized and instructions cannot be retrieved
`treasury-3109` 400 Deposit '{id}' has no transfer step The deposit does not have the expected transfer step
`treasury-3110` 400 Deposit '{id}' transfer step has no source account The deposit transfer step is missing its source account reference
`treasury-3111` 400 Deposit '{id}' transfer step has no destination account The deposit transfer step is missing its destination account reference
`treasury-3112` 400 Source account '{id}' is missing required bank details The source account does not have all required bank details (bank name, code type, identifier code, or account number)
`treasury-3113` 400 VAN account '{id}' missing virtual_account_number metadata The VAN account does not have the required virtual account number metadata
`treasury-3114` 400 VAN account '{id}' is missing required field: {field} The VAN account is missing a required field for wire instructions (e.g., bank name, account number, SWIFT code, beneficiary name)
`treasury-3115` 400 Simulated deposits are not available in production Deposit simulation is only available in sandbox environments
`treasury-3116` 400 to_network is required when destination is a managed wallet Deposits to a managed stablecoin wallet must specify the target blockchain network
`treasury-3117` 400 to_network must not be specified for ledger accounts Deposits to ledger accounts operate without a blockchain network
`treasury-3118` 400 Network '{network}' is not supported in this environment Deposits to managed wallets only support BASE in production and BASE_SEPOLIA in non-production environments
`treasury-3119` 400 Deposit '{id}' has no estimated source amount yet The deposit's estimated.from.amount or estimated.from.currency is not populated. The estimate is filled in after planning; this can occur if instructions are requested before the deposit has been planned.
`treasury-3120` 400 No deposit provider can handle this configuration. Verify the source bank account, destination account compliance state, and currency pair. getEligibleDepositProviders returned an empty array — no provider matches the deposit's source account, destination account, currency pair, or workspace provider configuration (e.g., missing Circle API key in the vault).
`treasury-3133` 400 Ledger '{id}' is missing OpenFX metadata The destination ledger account does not have OpenFX configuration (metadata.openfx)
`treasury-3134` 400 Currency pair {fromCurrency} → {toCurrency} is not supported for OpenFX deposits OpenFX deposits support same-currency fiat→fiat or fiat→stablecoin (on-ramp) pairs only
`treasury-3136` 502 Failed to obtain OpenFX quote for {fromCurrency} → {toCurrency} OpenFX rejected or did not respond to the projection quote during deposit planning. The deposit cannot be created until OpenFX returns a valid rate.
`treasury-3137` 502 OpenFX trade failed for deposit '{id}' after retry budget The swap leg of an OpenFX cross-currency deposit could not be executed within the retry budget. The swap step has been marked as failed.
`treasury-3138` 502 OpenFX quote expired before trade could execute OpenFX rejected the trade because the quote had expired (HTTP 422). The next attempt will request a fresh quote.
`treasury-3139` 400 Wallet address '{address}' is not registered in OpenFX for {coin} on {network} The wallet must be manually registered in the OpenFX dashboard before it can receive deposits
`treasury-3140` 400 Multiple OpenFX withdrawal addresses matched ({count}) — registration is ambiguous More than one verified active withdrawal address matched; resolve via the OpenFX dashboard
`treasury-3141` 400 OpenFX coinNetworkName '{coinNetworkName}' is not a known Tesser network The network returned by OpenFX does not map to a supported Tesser network identifier
`treasury-3142` 400 to_network is required for OpenFX wallet deposits OpenFX wallet deposits must specify the target blockchain network via to_network
`treasury-3143` 502 OpenFX withdrawal failed for deposit '{id}' (reason: {reason}) The wallet leg of an OpenFX deposit could not be submitted to OpenFX. The withdrawal step has been marked as failed.
`treasury-3144` 400 Source bank '{id}' is not registered with OpenFX (no fiatWithdrawalAddressId) The funding bank account has no OpenFX fiatWithdrawalAddressId and lazy rematch failed. Register the bank in OpenFX (or fix the rematch) before retrying the deposit.
`treasury-3145` 400 Wallet account '{id}' is missing a crypto wallet address The managed wallet has no on-chain address, so it cannot be matched to an OpenFX withdrawal address. This is a permanent data fault, not a transient error.
`treasury-3146` 400 Destination wallet is not an active Circle business recipient Deposit to a Circle-bound wallet whose circleBusinessRecipientId is missing or whose status is not active.
`treasury-3147` 400 Destination wallet is registered with Circle on a different network than the requested one Deposit to a Circle-bound wallet whose registered recipient chain doesn't match the requested to_network.
`treasury-3148` 400 Source bank is not activated with OpenFX yet The funding bank account is registered with OpenFX but its fiat withdrawal address is not active yet (e.g. under review). Wait for OpenFX approval before retrying the deposit.
`treasury-3200` 400 from_currency must be a valid currency code User provides an invalid from_currency value
`treasury-3201` 400 to_currency must be a valid currency code User provides an invalid to_currency value
`treasury-3202` 400 from_amount must be a valid decimal string User provides a from_amount that is not a valid number
`treasury-3203` 400 to_amount must be a valid decimal string User provides a to_amount that is not a valid number
`treasury-3204` 400 from_account_id must be a valid UUID User provides a from_account_id that is not a valid UUID
`treasury-3205` 400 to_account_id must be a valid UUID User provides a to_account_id that is not a valid UUID
`treasury-3206` 400 from_network must be a valid network identifier User provides an invalid from_network value
`treasury-3207` 400 to_network must be a valid network identifier User provides an invalid to_network value
`treasury-3300` 400 from_amount must be provided Withdrawals must specify the source amount in major units
`treasury-3301` 400 from_network must not be specified for ledger sources Ledger-source withdrawals operate without a blockchain network; from_network is only valid for wallet-source withdrawals
`treasury-3302` 400 Currency pair {fromCurrency} → {toCurrency} is not supported for withdrawals Only USDC → USD withdrawals are supported in this release
`treasury-3303` 400 Source account '{id}' must be a Circle USDC ledger for ledger-source withdrawals Ledger-source withdrawals require a managed Circle Mint ledger account as the source
`treasury-3304` 400 Destination account '{id}' must be a workspace bank account Withdrawals can only target a workspace-level bank account (type=fiat_bank, no entity_id)
`treasury-3305` 400 No master Circle USDC ledger found for this workspace Wallet-source withdrawals route through the workspace's master Circle ledger, which is created when the Circle API key is configured
`treasury-3306` 400 Withdrawal step is not in a signable state The step must be a turnkey step in 'signature_requested' state with a prepared unsigned transaction to accept a signature
`treasury-3307` 400 Withdrawal step is missing an unsigned transaction payload The withdrawal has not yet prepared an unsigned transaction — retry in a moment
`treasury-3308` 400 Signature is invalid or could not be verified The Turnkey stamp did not produce a valid signed transaction
`treasury-3309` 400 Signed transaction does not match the withdrawal details The destination address or amount in the signed transaction does not match the prepared withdrawal step
`treasury-3310` 400 Signature is not a valid base64-encoded {body, stamp} envelope The signature field must be base64-encoded JSON containing the Turnkey activity body and stamp header value
`treasury-3311` 400 Stamped activity type must be '{expected}', received '{actual}' The stamped Turnkey activity must be ACTIVITY_TYPE_SIGN_TRANSACTION_V2 to sign a withdrawal step
`treasury-3312` 400 Stamped activity unsigned transaction does not match the withdrawal step The unsignedTransaction inside the stamped Turnkey activity must equal the unsigned transaction prepared by the withdrawal step
`treasury-3313` 400 Stamped activity signWith address does not match the withdrawal source wallet The signWith parameter inside the stamped Turnkey activity must equal the source wallet's on-chain address
`treasury-3314` 400 Stamped activity organizationId does not match the workspace's Turnkey sub-organization The stamped Turnkey activity's organizationId must equal the workspace's configured Turnkey sub-org ID
`treasury-3315` 400 Turnkey could not produce a signed transaction Turnkey rejected or failed the stamped sign_transaction activity. Verify the stamp matches the body bytes, the API key is registered in the sub-org, and the wallet exists in that sub-org.
`treasury-3316` 400 Stamped activity parameters.type must be '{expected}', received '{actual}' The Turnkey transaction type inside parameters.type must match the network of the withdrawal step (e.g. TRANSACTION_TYPE_ETHEREUM for BASE)
`treasury-3317` 400 Currency '{currency}' is not supported for wallet-source withdrawal On-chain wallet-source withdrawal currently supports USDC only
`treasury-3318` 400 Network '{network}' is not supported for wallet-source withdrawal The network on the withdrawal step is not a supported EVM network for on-chain signing
`treasury-3319` 409 Source wallet has insufficient on-chain balance to sign The wallet's live on-chain balance is below the withdrawal amount at sign time. The balance is re-checked when you retry; withdrawal.balance_updated fires with balance_status 'awaiting_funds'.
`treasury-3320` 400 No withdrawal provider can handle this configuration. Verify the source account, destination bank account, and currency pair. getEligibleWithdrawalProviders returned an empty array — no provider matches the withdrawal's source account, destination bank account, currency pair, or workspace provider configuration (e.g., missing Circle API key in the vault).
`treasury-3321` 400 Deposit '{id}' has already received its funding wire. Wire instructions are no longer available The funding wire has already been received and instructions cannot be retrieved
`treasury-3322` 400 Circle compliance not yet accepted for the source ledger. Cannot create withdrawal Withdrawal source is a Circle ledger whose circleComplianceState is not ACCEPTED.
`treasury-3323` 400 Same-account rebalances require different currencies A same-account rebalance is a currency swap and cannot have matching from/to currencies
`treasury-3324` 400 Currency '{currency}' is not supported for a same-account swap A same-account swap settles on the provider ledger, so both currencies must be representable in the Tesser currency registry
`treasury-3325` 400 Destination bank '{id}' has no active OpenFX fiat withdrawal address for currency {currency} OpenFX fiat withdrawals pay out to pre-registered, per-currency fiat withdrawal addresses. No active address matched the destination bank's account number for the payout currency.
`treasury-3326` 400 OpenFX could not quote the withdrawal pair {fromCurrency} → {toCurrency} The plan-time OpenFX quote for the withdrawal's swap leg failed. Pair eligibility is quote-driven (no hardcoded whitelist), so a failed quote means the pair is unsupported or the provider is unavailable.
`treasury-3327` 400 Destination bank '{id}' is not activated with OpenFX yet (status: {status}) The destination bank matched an OpenFX fiat withdrawal address, but none of its addresses are active yet (e.g. under review). Wait for OpenFX approval before retrying the withdrawal.
`treasury-3328` 400 OpenFX ledger '{id}' has no deposit address registered for {currency} on {network} No OpenFX deposit address is registered for the requested asset and network. This prevents wallet-source withdrawals in production; sandbox and staging do not require a registered OpenFX deposit address.
`treasury-3329` 400 Workspace '{workspaceId}' has no OpenFX-bound ledger Wallet-source withdrawals settle through the workspace's OpenFX ledger, and none is bound to this workspace.
`treasury-3330` 400 Amount {amount} {currency} is below the OpenFX minimum on-chain deposit of {minimum} {currency} OpenFX never credits on-chain deposits below its minimum — sub-minimum funds are not shown on the ledger and are unrecoverable, so wallet-source withdrawals below the minimum fail closed before anything is dispatched on-chain.
`treasury-3331` 400 Amount {amount} {currency} is below the provider's minimum tradable amount of {minimum} {currency} The route includes a currency conversion, and the provider enforces a per-currency minimum on the sold amount. A sub-minimum trade would be rejected at execution — after inbound funds already landed on the provider ledger — so the request fails closed at creation instead.
`treasury-3332` 400 Amount {amount} {currency} is above the provider's maximum tradable amount of {maximum} {currency} The route includes a currency conversion, and the provider enforces a per-currency maximum on the sold amount. An above-maximum trade would be rejected at execution — after inbound funds already landed on the provider ledger — so the request fails closed at creation instead.
`treasury-3333` 400 OpenFX ledger '{id}' has no deposit address registered for {currency} on {network} No OpenFX deposit address is registered on the destination ledger for the requested asset and network. This prevents wallet-source rebalances in production; sandbox and staging do not require a registered OpenFX deposit address.
`treasury-3334` 400 Amount {amount} {currency} is below the OpenFX minimum on-chain deposit of {minimum} {currency} OpenFX never credits on-chain deposits below its minimum — sub-minimum funds are not shown on the ledger and are unrecoverable, so wallet-source rebalances below the minimum fail closed before anything is dispatched on-chain.
`treasury-3335` 400 Destination ledger '{id}' has no on-chain deposit address for {currency} on {network}. Provision one via POST /v1/accounts/{id}/onchain-addresses OpenFX→Circle rebalances ride an on-chain withdrawal to the destination ledger's deposit address. The corridor never creates addresses — provision via the onchain-addresses endpoint, then register the address as an OpenFX withdrawal address (manual provider step).
`treasury-3336` 400 to_network is required for rebalances from an OpenFX ledger to a Circle ledger account OpenFX→Circle rebalances move funds on-chain, so the caller must pick the transit network via to_network. There is no default.
`treasury-3337` 400 Deposit address for ledger '{id}' ({currency} on {network}) is not registered as an OpenFX withdrawal address The destination ledger's on-chain deposit address exists at Circle but has no matching verified OpenFX withdrawal address for the coin+network, so OpenFX would reject the withdrawal. Registration is a manual OpenFX dashboard step.
`treasury-3338` 409 The on-chain transaction was rejected because its nonce was already used by another transaction from this wallet. This rebalance is now in a failed state. You must create a new rebalance in order to try again. Another transaction from the same wallet consumed this nonce before the signed transaction reached the network. The step is already failed and its signed transaction carries the spent nonce, so this rebalance cannot be retried — create a new rebalance, which is planned against a fresh nonce.
`treasury-3339` 500 The signed transaction could not be broadcast The network rejected the signed transaction for a reason other than a nonce conflict; the node's reason is on the step's status_reasons. The remedy is the same for every resource, so this code is shared.
`treasury-3340` 409 The on-chain transaction was rejected because its nonce was already used by another transaction from this wallet. This withdrawal is now in a failed state. You must create a new withdrawal in order to try again. Another transaction from the same wallet consumed this nonce before the signed transaction reached the network. The step is already failed and its signed transaction carries the spent nonce, so this withdrawal cannot be retried — create a new withdrawal, which is planned against a fresh nonce.
`treasury-3341` 409 This rebalance waited for earlier transfers from the same wallet to be submitted, but the wait expired. This rebalance is now in a failed state. You must create a new rebalance in order to try again. The signed transaction's nonce was ahead of the wallet's submission watermark and the missing lower nonces were not submitted within the wait window. The step is already failed; a new rebalance is planned against a fresh nonce.
`treasury-3342` 409 This withdrawal waited for earlier transfers from the same wallet to be submitted, but the wait expired. This withdrawal is now in a failed state. You must create a new withdrawal in order to try again. The signed transaction's nonce was ahead of the wallet's submission watermark and the missing lower nonces were not submitted within the wait window. The step is already failed; a new withdrawal is planned against a fresh nonce.
`treasury-3343` 400 Same-account rebalances are only supported for on-ledger FX swaps A rebalance where source and destination are the same account requires a supported provider ledger swap
`treasury-3344` 400 from_network is required when the source account is a wallet Withdrawals from a stablecoin wallet must specify the blockchain network the funds leave from
`treasury-3345` 400 Deposit '{id}' cannot be simulated because it is expired or no longer awaiting funds The deposit expired before its funding receipt was simulated, or another receipt already completed it. A Kraken deposit that simulation already credited is returned unchanged instead of this error
## Transfers :::note Transfers codes never appear as a top-level API error response — they only surface inside a step's `status_reasons` array (see [Status Reasons](#status-reasons) above). The HTTP Status column is a placeholder for consistency with the other domains and carries no meaning for these codes. :::
Error Code HTTP Status Error Message Description
`transfers-9101` 500 Destination asset is missing for the transfer step A Circle-dispatched step could not resolve the destination asset for the transfer.
`transfers-9102` 500 Destination account not found for the transfer step The destination account referenced by a dispatched step could not be found.
`transfers-9103` 500 Required provider fields are missing for the transfer step An OpenFX-dispatched step was missing fields required to initiate the provider transfer or swap.
`transfers-9104` 500 Source account not found for the transfer step The source account referenced by an OpenFX-dispatched step could not be found.
`transfers-9105` 500 Source organization is missing for the transfer step An OpenFX-dispatched step could not resolve the source organization for the transfer.
`transfers-9106` 500 Account not found for the transfer step An account referenced by an OpenFX-dispatched step could not be found.
`transfers-9107` 500 Fiat currency mismatch for the transfer step The fiat currency of an OpenFX-dispatched step did not match the expected currency.
`transfers-9108` 500 Provider data is missing for the staged transfer step A staged (customer-signed) step was missing the provider data required to build the unsigned transaction.
`transfers-9109` 500 Source is missing for the staged transfer step A staged step was missing its source wallet or source address.
`transfers-9110` 500 Currency is not supported for the transfer step The currency of the step is not supported by the resolved provider or execution path.
`transfers-9111` 500 Network is not supported for the transfer step The network of the step is not supported by the resolved provider or execution path.
`transfers-9112` 500 Destination could not be resolved for the transfer step The destination address for the step could not be resolved at execution time.
`transfers-9113` 500 Planning failed for the transfer The planner hit a deterministic rejection while preparing the step graph; the domain error code and detail are appended.
`transfers-9114` 500 The step could not be priced before execution A provider could not price the step before its funds were reserved, so the payment waits in awaiting_funds with nothing reserved. The next balance change on the source retries the pricing; the payment expires as usual if it never succeeds.
`transfers-9201` 500 An upstream step failed, so this step was not executed A prior step in the transfer failed and the failure cascaded to this downstream step.
`transfers-9202` 500 The inbound transaction was reorganized away before finality A confirmed inbound step was dropped by a chain reorganization before reaching finality.
`transfers-9203` 500 The transfer was rejected by risk screening Outbound risk screening rejected the payment (fail-closed); its non-terminal steps were failed.
`transfers-9204` 500 The transfer expired before it could be executed The payment expired (e.g. quote/funding window elapsed) and its pre-broadcast steps were failed.
`transfers-9205` 500 Insufficient funds to execute the transfer step The source did not hold enough balance to execute the on-chain send for the step.
`transfers-9206` 500 The on-chain transaction for this inbound transfer was included in a block but reverted, so no funds arrived. The credit applied when it was detected has been taken back. Inbound funds are credited when the transfer is detected, before finality. A transfer that succeeded when reported can revert when re-executed after a reorg, since it runs against different preceding state. The credit is then reversed. If the recipient already spent it, the reversal recovers only what remains and the shortfall is reported for manual recovery.
`transfers-9207` 500 The on-chain transaction for this inbound transfer succeeded but did not move the expected funds to the expected address. The credit applied when it was detected has been taken back. The transaction did not revert, but the token contract emitted no Transfer event matching the reported sender, recipient and amount. Distinct from a revert: execution succeeded, it simply did not deliver what was reported. Sustained for ten minutes before acting, because a node that is behind produces the same answer as a genuine absence.
`transfers-9208` 500 The quote expired before the provider accepted it The rate lock was accepted within the quote window, but the quote had expired by the time the provider processed it, so the provider refused to create the order. No funds moved. Create a new payment for a fresh quote.
`transfers-9209` 500 The payout is held for manual settlement, and its funds stay in the provider ledger The funds reached the provider ledger, but the payout could not complete automatically: the ledger cannot cover a refreshed quote for the committed amount, the provider refused the payout request, the provider reported the payout failed or returned, or its outcome stayed unconfirmed for a day. The step neither fails nor completes, and nothing is refunded, until an operator settles it; the reason is appended.
`transfers-9210` 500 The step's outcome at the provider could not be verified, so it is held for manual investigation The provider accepted or may have accepted the step, but its result could not be confirmed. The step neither fails nor completes, and its funds stay held, until an operator confirms the outcome; the detail is appended.
`transfers-9211` 500 The transfer is held before it is sent, and its funds stay in the source wallet The step was about to be prepared for signing, but the provider account it pays into is not active, so sending would strand the funds there. Nothing is sent, and the step neither fails nor completes, until an operator retries or releases it; the reason is appended.
`transfers-9215` 500 The transfer failed due to failed Beneficiary name match check Beneficiary name match check at Notabene failed. Travel Rule authorization request was rejected. See participants[] info for more details.
`transfers-9301` 500 The provider rejected the transfer step The provider returned a terminal rejection (e.g. a 4xx) when initiating the step.
`transfers-9302` 500 The provider payout failed A provider payout/transfer webhook reported failure; the provider's raw code is appended when available.
`transfers-9303` 500 The transfer step failed to execute A dispatched provider step failed to execute after exhausting its retry budget.
`transfers-9304` 500 The currency conversion trade failed An OpenFX swap/trade leg failed; the provider's error detail is appended when available.
`transfers-9305` 500 The on-chain transaction failed to broadcast Broadcasting the signed transaction to the network failed; the chain error is appended.
`transfers-9306` 500 The Circle deposit failed A Circle Mint inbound deposit leg reported failure via webhook.
`transfers-9307` 500 The simulated settlement send failed Staging-only: the simulated on-chain settlement send (airdrop) failed.
`transfers-9308` 500 The simulated settlement transaction reverted Staging-only: the simulated on-chain settlement transaction reverted.
`transfers-9309` 500 The withdrawal settlement failed at the provider An OpenFX withdrawal settlement webhook reported a failed/terminal status; the status is appended.
`transfers-9310` 500 The on-chain transaction was rejected because its nonce was already used by another transaction from this wallet. This transfer is now in a failed state. You must create a new transfer in order to try again. Another transaction from the same wallet consumed this nonce before the signed transaction reached the network. The step is terminal and its signed transaction carries the spent nonce, so the transfer cannot be retried — a new one must be created.
`transfers-9311` 500 This transfer waited for earlier transfers from the same wallet to be submitted, but the wait expired. This transfer is now in a failed state. You must create a new transfer in order to try again. The signed transaction's nonce was ahead of the wallet's submission watermark and the missing lower nonces were not submitted within the wait window. The step is terminal; a new transfer is planned against a fresh nonce.
`transfers-9312` 500 An earlier transfer from this wallet failed before being submitted, so this transfer's position in the submission queue became invalid. This transfer is now in a failed state. You must create a new transfer in order to try again. A transfer holding a lower allocated nonce died before broadcast (wait timeout, expiry, risk rejection, or an on-chain nonce conflict), which invalidates every allocated nonce above it. The causing transfer's id is appended when known.
`transfers-9313` 500 The on-chain transaction for this transfer was included in a block but reverted, so no funds moved. The reserved balance has been released. You must create a new transfer in order to try again. The transaction was mined and finalized, but its execution reverted — commonly insufficient token balance at execution time, or a token contract rejecting the transfer. Gas was still spent and is recorded as a fee on the step. The nonce was consumed, so later transfers from the same wallet are unaffected.
`transfers-9314` 500 The on-chain transaction for this transfer succeeded but did not move the expected funds to the expected address. The reserved balance has been released. You must create a new transfer in order to try again. The transaction did not revert, but the token contract emitted no Transfer event matching the expected sender, recipient and amount. Distinct from a revert: execution succeeded, it simply did not deliver. Gas was spent and is recorded as a fee. The nonce was consumed.
## Vault
Error Code HTTP Status Error Message Description
`vault-0001` 500 Failed to store sensitive data in vault The vault provider failed to store the token. This may be due to a temporary outage or configuration issue.
`vault-0002` 500 Vault provider did not return a token ID The vault provider accepted the data but did not return a token ID. This indicates an unexpected API response.
`vault-0003` 400 Unknown vault field The specified field name is not configured for vault storage. Check the VaultFieldConfig for supported fields.
`vault-0004` 400 Invalid field type for operation The operation does not match the field type. For example, trying to store text in a file-only field.
`vault-0005` 409 Vault record already exists for this resource and field A create-only vault write found an existing record for the same (resourceId, fieldName). Rotate or delete the existing record instead.
`vault-0010` 404 Vault token not found The specified token does not exist in the vault. It may have been deleted or never created.
`vault-0011` 500 Failed to retrieve token from vault The vault provider failed to retrieve the token. This may be due to a temporary outage or authentication issue.
`vault-0012` 500 Failed to reveal token value The vault provider failed to reveal the token value. The API key may not have sufficient permissions.
`vault-0013` 503 Vaulted value is temporarily unavailable The vault record exists but its value could not be revealed right now. Retry the request.
`vault-0020` 500 Failed to delete token from vault The vault provider failed to delete the token. This may be due to a temporary outage or authentication issue.
`vault-0030` 502 Vault proxy request failed The vault proxy failed to forward the request to the destination. Check the destination URL and authentication.
`vault-0031` 502 Vault proxy destination returned an error The vault proxy successfully forwarded the request, but the destination returned an error response.
`vault-0040` 503 Vault provider is not configured The vault provider API key is not configured. Contact support to enable vault functionality.
`vault-0041` 400 Liquidation provider not found The specified liquidation provider is not configured. Check the available providers.
`vault-0050` 502 Failed to forward vaulted data to provider The vaulted data could not be forwarded to the liquidation provider. This may be due to a provider outage or misconfiguration.
`vault-0051` 422 Provider rejected the forwarded data The liquidation provider rejected the forwarded data. Check the data format and provider requirements.
## Circle
Error Code HTTP Status Error Message Description
`circle-1000` 404 Circle API key not configured for organization The organization does not have a Circle Mint API key configured in the vault
`circle-1001` 404 Circle credentials are not configured for this organization Basis Theory could not detokenize the organization's Circle Mint API key token.
`circle-2000` 401 Circle API key authentication failed The configured Circle Mint API key failed authentication with Circle API
`circle-4290` 429 Cannot complete request because rate limited by provider Circle Mint The Circle Mint API returned a 429 rate limit response
`circle-5000` 502 Circle Mint service error An error occurred while communicating with the Circle Mint API
`circle-5001` 503 Circle Mint service temporarily unavailable The Circle Mint API is temporarily unavailable
## Capa
Error Code HTTP Status Error Message Description
`capa-0001` 500 CAPA_API_KEY is not configured on this deployment The Capa partner API key (`CAPA_API_KEY`) is unset, so no Capa call can be made. Capa is a single Tesser-wide deployment secret; this indicates a deployment misconfiguration.
`capa-1000` 404 Capa could not find the requested resource Capa returned 404 NOT_FOUND_ERROR — the user, quote, or currency/corridor was not found (e.g. an unsupported currency pair).
`capa-2000` 401 Capa rejected the partner API key Capa returned 401 — the partner API key is invalid, or the partner account is not enabled for this operation ('User is not associated with the partner').
`capa-3000` 400 Capa rejected the request as invalid Capa returned a 4xx validation error (e.g. INVALID_USER_INPUT_ERROR) — a field was missing, disallowed, or malformed for the endpoint's schema.
`capa-3001` 422 Capa could not quote the requested amount Capa's quote failed with the documented 'ExchangeRateProviderV2Spread not found' signature, which surfaces when the requested amount is outside the quotable range (observed near $1M; the exact upper ceiling is unconfirmed). Only this exact signature is mapped here; any other quote failure stays on the generic capa-3000 request error.
`capa-3002` 400 Capa refused the cross-ramp because its quote expired Capa rejected the cross-ramp create with its documented QUOTE_EXPIRED code: the ~10s quote lapsed before Capa processed the order, so no order was created.
`capa-4290` 429 Capa rate limited the request Capa returned a 429 rate-limit response.
`capa-5000` 502 Capa service error Capa returned an unexpected or unparseable response that does not map to a more specific error.
`capa-5030` 502 Capa is temporarily unavailable Capa returned a 5xx or the request failed in transit (network error/timeout). The request may or may not have been processed — reconcile before retrying.
## Idempotency
Error Code HTTP Status Error Message Description
`idempotency-0001` 409 A request with this idempotency key is currently being processed. Please wait and retry. Another request with the same idempotency key is still in progress
`idempotency-0002` 400 Keys for idempotent requests can only be used with the same parameters they were first used with. The idempotency key was previously used with a different request body. Use a different key for different requests.
`idempotency-0003` 503 Idempotency cannot be guaranteed right now. The request was not processed. Please retry with the same key. The idempotency store is unreachable, so the request was rejected rather than executed without protection
`idempotency-3000` 400 An Idempotency-Key header is required for this request. This request must carry a non-blank Idempotency-Key header, so that a retry returns the original result instead of creating a duplicate. Nothing was created.
## Zuba
Error Code HTTP Status Error Message Description
`zuba-0001` 500 Zuba is not configured Tesser's Zuba platform credentials are missing, or the workspace's organization has no Zuba sub-account. Nothing was sent.
`zuba-0004` 409 Zuba onboarding refused for this organization A Tesser-managed Zuba sub-account is refused while the organization still holds a legacy Zuba credential record, or when it conflicts with the organization's recorded sub-account; later onboarding steps are refused when they conflict with the recorded sub-account or KYB application. The reason names which. Nothing was stored.
`zuba-2000` 401 Zuba credentials were rejected Authentication failed before the payout request could be submitted.
`zuba-2001` 500 The Zuba sub-account is not accessible with Tesser's credentials Zuba answered not_account_owner: the stored sub-account id does not belong to Tesser's platform credentials. Nothing was created.
`zuba-3000` 400 Zuba rejected the request as invalid The provider definitively rejected the request validation.
`zuba-3001` 422 Zuba balance is insufficient The provider definitively rejected the payout for insufficient balance; no funding is attempted.
`zuba-3002` 422 Zuba does not quote this corridor Zuba answered UNSUPPORTED_CORRIDOR to a payout-intent quote. No quote exists and nothing was submitted.
`zuba-3003` 422 Zuba refused the payout because its quote expired Zuba refused this quote-bound payout request with QUOTE_EXPIRED. The refusal covers only this request: an earlier attempt under the same clientRef may still have been accepted, so reconcile the clientRef by lookup before concluding no payout exists. The quote cannot be reused; a new quote needs its own acceptance.
`zuba-3004` 400 This field cannot be set when creating a USDC to NGN payment USDC to NGN payments are priced by the NGN amount, and NGN has no network. Where sandbox instant acceptance is available, funding_account_id, desired.from.account_id and desired.to.account_id are accepted together and the quote is accepted at create; otherwise payments are created without accounts, which bind when the quote is accepted. Nothing was created.
`zuba-3005` 409 This payment's quote cannot be acted on in its current state The payment is priced by a Zuba quote stored on the payment. The quote cannot be accepted in its current state, and the payment cannot be reviewed until its quote is accepted, nor at all on a corridor that is not risk-screened. Nothing was changed: no accounts were bound and no review was recorded.
`zuba-3006` 422 No wallet account in this workspace holds the sandbox wallet address Sandbox instant acceptance funds Zuba through the Tesser sandbox wallet, which must be registered in the workspace as an unmanaged wallet account. Nothing was created; register the sandbox wallet, then retry with a new Idempotency-Key.
`zuba-3007` 422 Zuba refused the sandbox deposit that funds this payout Zuba definitively rejected the sandbox deposit before creating it, so the payout it funds was not submitted. Funds already sent to the sandbox wallet are not refunded.
`zuba-3008` 422 The destination bank account cannot be paid on the Zuba sandbox rail The destination must be an NGN bank account owned by a tenant or counterparty, with a 3 to 6 digit Nigerian bank code and one of the Zuba sandbox account numbers (0000000000 pays, 0000000001 and 0000000002 fail). The quote was not accepted and no steps were created.
`zuba-3009` 422 Zuba refused to open a deposit wallet for this workspace Opening the workspace's Tempo deposit wallet needs the organization's Zuba sub-account to be enabled for crypto deposits on Tempo. Nothing was provisioned. Ask Zuba to enable Tempo deposits on the sub-account, then retry.
`zuba-3010` 422 The destination bank account cannot receive a Zuba NGN payout The destination must be an NGN bank account owned by a tenant or counterparty with a name, a 3 to 6 digit Nigerian bank code and a 10-digit account number. The accounts were not attached.
`zuba-3011` 422 Zuba refused the payout's originator Zuba rejected the payout at submission because the originator's details were incomplete or invalid (SENDER_INFO_INCOMPLETE, SENDER_INFO_INVALID), or no originator was on record (ORIGINATOR_NOT_ON_RECORD). No payout was created; Zuba's code is appended.
`zuba-3020` 422 The Zuba sub-account for this workspace is not active The workspace's organization has no Zuba sub-account yet, or it is pending KYB review or closed, so only its KYB application can be changed. Tesser refuses the request before sending it, or Zuba refuses it with ACCOUNT_NOT_ACTIVE. Nothing was created.
`zuba-3021` 422 The Zuba KYB application is missing steps or documents Zuba refused to submit the application; the missing steps, company documents and persons without documents are listed as Zuba returned them.
`zuba-3022` 404 The Zuba sub-account does not exist Zuba does not know the stored sub-account id. Nothing was changed.
`zuba-4000` 409 Zuba payout reference already exists Look up the existing payout using the original clientRef and verify it before adoption.
`zuba-5030` 502 Zuba is temporarily unavailable A transient provider failure occurred. This alone does not establish whether a payout was accepted; reconcile before retrying submission.
`zuba-5031` 502 Zuba payout submission outcome is unknown A timeout, transport failure, or unusable submission response leaves acceptance unknown. Reconcile the original clientRef; do not infer terminal failure or create a new reference.
`zuba-5032` 502 Zuba returned a sub-account that does not match the request Zuba answered a sub-account create with an account whose name, country or type differs from what was sent, typically a replay of an earlier create with the same idempotency key. The account may exist on Zuba; nothing was stored in Tesser.
`zuba-5033` 502 Zuba returned a resource owned by another Zuba account A payout or deposit Zuba returned names a different owner than the Zuba account this workspace acts as. Tesser refused it and did not adopt it; the payment is held for an operator to reconcile.
--- ## Document: Deposits, Withdrawals, and Rebalances First-party funds movement — on-ramping, off-ramping, and moving funds between your own accounts URL: /overviews/deposits-withdrawals-and-rebalances # Deposits, Withdrawals, and Rebalances Deposits, withdrawals, and rebalances are Tesser's **first-party** funds-movement resources: there is no third-party beneficiary. Every account in the flow is one of yours — a bank account, a self-custodial wallet, or a ledger you hold at a provider. That is what separates them from [Payments](/overviews/payments), which move funds to or from an arms-length third party. Deposits and withdrawals reach that result by transacting with a liquidity provider, which executes the on- or off-ramp; rebalances move funds only between your Tesser-managed accounts. | Resource | What it moves | Create endpoint | |---|---|---| | Deposit | Funds into a liquidity provider, on-ramping fiat to stablecoin | [POST /v1/treasury/deposits](/api/treasury#create-deposit) | | Withdrawal | Funds out of a liquidity provider, off-ramping stablecoin to fiat | [POST /v1/treasury/withdrawals](/api/treasury#create-withdrawal) | | Rebalance | Funds between your own Tesser-managed accounts | [POST /v1/treasury/rebalances](/api/treasury#create-rebalance) | All three share the same data shape, lifecycle phases, and statuses as payments. See [Funds Movement Lifecycle and Data Model](/overviews/funds-movement-lifecycle-and-data-model) for the `desired` / `estimated` / `actual` overlays, the step model, and balance and step statuses. ## Deposits and Withdrawals Deposits and Withdrawals involve funds exchange with a liquidity provider to on-ramp funds into your organization’s wallets or off-ramp funds back into external bank accounts. - **On-ramping:** Exchange fiat for stablecoin(s) via a stablecoin liquidity provider. Fiat funds leave your organization’s, or your customers’, fiat bank account(s) and stablecoins are deposited to your wallets. - **Off-ramping:** Exchange stablecoin(s) for fiat via a liquidity provider. Stablecoins leave your organization’s, or your customers’, wallets and fiat is deposited to fiat bank account(s). To create either one, see [Deposit Funds via a Liquidity Provider](/how-tos/manage-your-treasury/deposit-funds-via-a-liquidity-provider) and [Withdraw Funds via a Liquidity Provider](/how-tos/manage-your-treasury/withdraw-funds-via-a-liquidity-provider). ## Rebalances A rebalance moves funds between two of your Tesser-managed accounts — either a provider ledger (for example, a Circle ledger or an OpenFX ledger) or a self-custodial wallet you have registered with Tesser. Nothing enters or leaves your control: a rebalance only changes *where* your funds sit. Rebalances exist because a balance of the right total size is not necessarily in the right place. You may need to: - Fund a wallet on a specific network before sending payouts on that network - Consolidate balances that have accumulated across several wallets or ledgers - Convert an idle currency balance into the stablecoin your upcoming payouts require - Move funds between providers to match where your payment volume is going A rebalance can be same-currency or cross-currency, and Tesser plans the route for you: - **Same-currency rebalances** execute as a single `transfer` step — for example, moving USDC between two Circle ledgers, or USDT on POLYGON from one self-custodial wallet to another. `estimated.*` matches `desired.*` 1:1, and there is no swap. - **Cross-currency rebalances** include a `swap` step at a provider ledger, but the order depends on where the funds start. A ledger-to-wallet rebalance swaps first and then transfers the resulting stablecoin to the wallet. A wallet-to-OpenFX rebalance transfers the stablecoin to the OpenFX ledger first and then swaps it there. Tesser reports an indicative exchange rate in `estimated.*`; the realized fill lands in `actual.*` and may differ slightly. :::note Rebalances are first-party only — they never involve a third party. To send funds to a beneficiary outside your organization, create a payment instead. See [Send a Stablecoin Payout](/how-tos/send-a-stablecoin-payout/create-a-stablecoin-payout). ::: Because a rebalance debits one of your accounts, Tesser runs a balance check before executing. Where the source is a self-custodial wallet, you also sign the outbound transaction yourself. For the full walkthrough — including request and response examples, webhooks, and the signing flow — see [Rebalance Funds](/how-tos/manage-your-treasury/rebalance-funds). --- ## Document: Currencies Supported fiat and crypto currencies URL: /overviews/currencies # Currencies import { Badge } from "zudoku/ui/Badge"; import { Callout } from "zudoku/ui/Callout"; ## Currencies The Currencies resource lists all fiat and digital assets supported by the platform. ### Overview Use `GET /v1/currencies` to retrieve supported currency definitions, including their decimal precision, blockchain network, and custody provider. Identify a currency definition by `(key, network, provider)`. The same currency can have different precision at a bank, on a network, or with a custody provider. ### Currency Types - Fiat Traditional currencies like USD. - Stablecoins Digital assets pegged to fiat currencies (e.g., USDC, USDT). ### Data Model Each currency object includes: - `key`: The currency symbol (e.g., `USD`, `USDC`). - `name`: Display name (e.g., `USD`, `Circle USD`). - `decimals`: Number of decimal places for this key, network, and provider combination. - `network`: The blockchain network, or `null` for balances held off-chain, including stablecoins held with a custody provider. - `provider`: For example, `circle_mint` or `openfx`; `null` for a network currency or default fiat definition. For example: | `key` | `network` | `provider` | `decimals` | | --- | --- | --- | --- | | `USD` | `null` | `null` | 2 | | `USD` | `null` | `openfx` | 2 | | `USDC` | `null` | `circle_mint` | 6 | | `USDC` | `BASE` | `null` | 6 | | `USDC` | `STELLAR` | `null` | 7 | For a wallet asset, match its currency and network with `provider: null`. For a ledger asset, match its currency and account provider with `network: null`. Account responses use uppercase providers such as `OPENFX`; match them to the lowercase currency provider `openfx`. A bank uses the default fiat definition, where both `network` and `provider` are `null`. ### Amounts in API Responses Account balances, treasury balances, transfer amounts, and webhook amounts are decimal strings in currency units. For example, `"1.25"` USD represents that many US dollars. Amounts are already formatted using the relevant precision; trailing zeroes may be omitted, so one unit can be returned as `"1"`. Aggregated treasury balances preserve the precision of the balances being combined. Use decimal arithmetic when processing these strings to retain all returned digits. See the [API Reference](/api#tag/Currencies) for the full list. --- ## Document: Counterparties Manage external individuals and businesses URL: /overviews/counterparties # Counterparties A counterparty represents an originator (ultimate sender) or beneficiary (ultimate receiver) of a payment. Counterparties can be individuals or businesses. Counterparties are associated to Accounts and belong to a Workspace. Registering counterparties in advance of submitting payments enables OFAC (sanctions) screening and helps meet regulatory requirements. To comply with Travel Rule obligations, counterparty information will be recorded for stablecoin payouts and be included in payment instructions sent to fiat off-ramps. \*Note: Tesser does not conduct KYC or KYB verification on counterparties. Organizations must have completed KYC/KYB verification of originator counterparties prior to registering them with the Tesser platform. To meet regulatory obligations and maintain consistency of data requirements, Tesser requires the following fields to be supplied about counterparties for registration: **Required Fields** - Legal name (first name and last name) - Physical address (legal street address, no P.O. Boxes or virtual addresses) **Optional Fields** - Date of birth - National identification number (Passport, ID card number, Tax Identification number, Social Security Number) **Required Fields** - Legal entity name - Physical address (legal street address, no P.O. Boxes or virtual addresses) - Legal entity identifier. Legal entity identifier may be a registration number or tax ID. \*Required only for counterparties who will be originators of fiat payouts. **Optional Fields** - Doing business as (DBA) or Trade name - Legal entity identifier (for beneficiaries) > If you did not supply required data during registration that is required in order to send a payment, you will receive an error indicating the missing data. You can update the counterparty record and then retry the payment. --- ## Document: Compliance and Risk Management How Tesser's compliance and risk management controls work URL: /overviews/compliance-and-risk # Compliance and Risk Management Tesser enables two compliance and risk checks for stablecoin payments: **Travel Rule data exchange** and **wallet risk screening**. For the field-level detail, see [Payment Participants](/overviews/payment-participants) and [Wallet Risk Screening Data](/overviews/wallet-risk-screening). At a glance — how the two checks apply to each payment direction: D{Direction?} D -->|Outbound| O1[Required Travel Rule data attached;
authorization may be requested
from the beneficiary VASP] O1 --> O2[Destination wallet screened] O2 --> O3{Approving
risk_status?} O3 -->|Yes| O4[Payment executes] O3 -->|No| O5[Payment does not execute] D -->|Inbound| I0{Originator VASP requests
authorization first?} I0 -->|Yes| I1{Beneficiary name
matches your records?} I1 -->|No| IR[Authorization rejected —
funds are not sent] I1 -->|Yes| I2[Funds land on-chain] I0 -->|No — direct
on-chain transfer| I2 I2 --> I3[Sending wallet screened — advisory] I3 --> I4[Available balance credits at
block finality, regardless of
screening outcome] I4 --> I5[You consume risk_status to decide
whether to credit your end user]`} /> Inbound payments also create records for a sender Tesser has not seen before. A transfer from an unrecognized wallet address produces an unmanaged account representing that address — see [Receive an Inbound Payment to a Wallet](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet#recording-the-sending-wallet-on-the-payment). Separately, where a Travel Rule or wallet screening provider names an entity in the flow that Tesser cannot match to your records, Tesser creates a Counterparty for that entity — see [Payment Participants](/overviews/payment-participants). ## Travel Rule Data Exchange The Travel Rule requires financial institutions to transmit originator and beneficiary identity and account information alongside qualifying transfers. For stablecoin payments, that data does not travel with the blockchain transaction itself. Instead, it must be transmitted between Virtual Asset Service Providers (VASPs) through a Travel Rule data-exchange platform. Tesser is integrated with [Notabene](https://notabene.id/), a leading provider with 1,900+ VASPs in its network. **On outbound payments** - Tesser can request authorization from the beneficiary VASP prior to sending funds on-chain, per your organization's policy and subject to the beneficiary VASP's ability to respond to authorization requests. This allows your organization to confirm that the (economic) owner of the destination wallet address as identified by the Beneficiary VASP is the intended beneficiary. (In other words, do you and the Beneficiary VASP agree on whom you are planning to send funds to?) - Tesser will submit the required originator and beneficiary information for the beneficiary jurisdiction to the Beneficiary VASP. During onboarding, your organization will be informed of required data points for originators (your organization's customers or end-users) and beneficiaries for the planned jurisdictions. When you register originators and beneficiaries via API, Tesser enforces collection of those data points. **On inbound payments** - The originator VASP may request authorization before sending funds on-chain. When this occurs, Tesser will evaluate the Beneficiary identity info sent by the Originator VASP against your beneficiary records. If the name of Beneficiary supplied by the Originator VASP for the destination wallet address does not match the identity info you have for that wallet, Tesser will reject the authorization. This prevents funds being sent erroneously to the wrong beneficiary, which can create an exception flow. - For payments above your jurisdiction's threshold (e.g. USD 3000 in the United States), Tesser will enforce policies with Notabene to require originator VASPs to transmit all required Travel Data. **On all payments** Identity data received through a Travel Rule provider-facilitated flow is recorded on the payment: the reconciliation outcome for each party appears in [`participants[]`](/overviews/payment-participants), and the raw transfer record is passed through in [`provider_metadata[]`](/overviews/payment-participants#raw-travel-rule-data). **Testing your inbound handling** In sandbox and staging, [Simulate a Travel Rule Inbound Payment](/how-tos/receive-an-inbound-payment/simulate-a-travel-rule-inbound-payment) lets you rehearse an inbound Travel Rule authorization and rejection against a real Notabene counterparty, without a second VASP of your own. ## Wallet Risk Screening Every payment's external wallet is screened via a blockchain analytics provider to identify potential risks about the receiving wallet (outbound payment) or sending wallet (inbound payment). The screen evaluates the wallet's activity and exposure aggregated across **all chains the wallet exists on,** not only the network for this particular payment, to ensure the full scope of the wallet's risk is identified. Which wallet is screened, and what the outcome gates, depends on direction: | Direction | Screened wallet | What the outcome gates | | --- | --- | --- | | **Inbound** | The **sending** wallet | **Advisory.** Funds are credited to your wallet's available balance at block finality regardless of the screening outcome. The result is a signal you consume to decide whether to credit your end user, quarantine, or return funds. | | **Outbound** | The **destination** wallet, or the destination VASP wallet when the beneficiary is VASP-hosted | **Gating.** The payment does not execute unless screening resolves to an approving outcome. | ### What screening evaluates The screened wallet is analyzed across several kinds of signals: - **Ownership** — the wallet's own risky activity. - **Counterparty** — the wallet has directly transacted with a risky wallet. - **Indirect** — the wallet has indirect (multi-hop) exposure to a risky wallet. - **Behavioral** — the wallet exhibits transaction patterns commonly associated with obfuscation or laundering techniques. - **Entity attribution** — the wallet is attributed to a known entity (e.g. an exchange, an issuer, a custody service, a DeFi protocol/contract/developer, a sanctioned actor), with an accompanying confidence level. See the full list of [wallet screening risk categories](/overviews/wallet-screening-risk-categories). ### How the decision is made Screening produces one `risk_status` per payment. Multiple inputs are used to determine the `risk_status`. - **Severe findings about the wallet and its activity automatically reject, ahead of every other check.** A configurable set of severe categories (for example, sanctions or terrorist financing) rejects the payment when found in the wallet's own on-chain activity, its **attributed entity**, or its behavior pattern — regardless of volume, and regardless of whether the counterparty is allowlisted. The entity path does not require the attribution to exceed your confidence threshold. - **Allowlist/Blocklist.** Your organization can maintain an **allowlist** of trusted counterparties and **wallet addresses**, and a **blocklist** of prohibited counterparties. A blocklisted counterparty is rejected; an allowlisted counterparty — or a wallet whose address is itself allowlisted — is approved, bypassing the remaining checks. Counterparties are matched on the entity the wallet screening provider attributes to the wallet, subject to the confidence threshold below; addresses are matched exactly, on any network, with no confidence involved (EVM addresses regardless of checksum casing; other formats byte-for-byte). If a counterparty appears on both lists, the blocklist wins. Neither list is consulted before the severe-findings check above — a counterparty cannot be listed out of a hard-block category. - **The screened wallet is classified as VASP-hosted or self-hosted.** The classification selects which materiality thresholds apply in the next step. A wallet is treated as VASP-hosted when the flow can be facilitated through a Travel Rule data provider, or if the entity the wallet screening provider attributes to the wallet carries a custodial entity category (for example, Exchange or Financial Services); a wallet with no entity attribution is treated as self-hosted. - **Remaining indicators have their materiality thresholds applied.** Other findings count only when they are *material* — when the exposure exceeds configurable thresholds relative to the wallet's total volume or in absolute dollar terms. This is what lets a legitimate, high-volume custodial wallet pass despite incidental contact with flagged addresses. **Material findings resolve by severity: low and medium severity approve automatically, high severity routes the payment to manual review, and severe rejects.** - **A failed screen is automatically rejected by default.** If screening cannot complete, Tesser applies your configured screening-failure action (default: automatically rejected). A screening failure is distinguishable on the payment record from a risk-based rejection. Every outcome is reconstructable from the payment record: the signals that determined it — with configured thresholds and observed values — appear in `risk_status_reasons[]`, and the raw screening response is passed through in `provider_metadata[]`. See [Wallet Risk Screening Data](/overviews/wallet-risk-screening). ### Risk statuses | `risk_status` | Meaning | | --- | --- | | `unchecked` | Initial state; screening has not yet resolved. | | `automatically_approved` | No material risk found. | | `awaiting_decision` | Material exposure warranting review by your organization. | | `automatically_rejected` | A severe finding, a blocklisted counterparty, or a screening failure (per your configured failure action). | | `manually_approved` | A reviewer in your organization approved the payment. | | `manually_rejected` | A reviewer in your organization rejected the payment. | Every `risk_status` change fires a `payment.risk_updated` webhook. ## Manual Review Process Payments with `risk_status` of `awaiting_decision` can be resolved through the risk review endpoint: ```bash curl -X POST https://api.tesser.xyz/v1/payments/{paymentId}/review \ -H "Authorization: Bearer your-access-token" \ -H "Content-Type: application/json" \ -d '{ "is_approved": true }' ``` After a decision is recorded, `risk_status` transitions to `manually_approved` or `manually_rejected`, and `risk_reviewed_by` and `risk_reviewed_at` are populated. Payments can also be reviewed in the Tesser dashboard. ## Testing Risk Checks To build and test your compliance handling against every outcome (`automatically_approved`, `awaiting_decision`, and `automatically_rejected`), Tesser provides testing mechanisms in sandbox for both directions: known test beneficiary addresses for outbound payouts and a simulate endpoint for inbound payments. See [Test Compliance Risk Checks](/how-tos/test-compliance-risk-checks). ## What Your Organization Can Configure Each of the following ships with a Tesser default and can be tuned for your organization: - **Allowlist and blocklist** — the counterparties on each list, and the wallet addresses on the allowlist. - **Severe categories** — the set of categories that reject automatically when found in the wallet's own ownership or behavior. - **Custodial entity categories** — the entity categories treated as an indication the wallet is VASP-hosted. - **Materiality thresholds** — the relative (% of wallet volume) and absolute (dollar) thresholds, per payment direction and wallet custody type. - **Screening-failure action** — how a failed screen resolves, per direction. - **Entity attribution confidence threshold** — how confident an entity attribution must be before Tesser relies on it. Contact us to review or change your organization's configuration. ## Travel Rule Information Transmitted Account information is collected and transmitted as part of Travel Rule compliance for stablecoin payouts or fiat payouts: | **Information transmitted** | **Required for Originator** | **Required for Beneficiary** | **Notes** | | --- | --- | --- | --- | | **Legal Name** | Individual's legal first and last name, or Business's legal entity name | Individual's legal first and last name, or Business's legal entity name | | | **Physical Address** | Legal address of the individual or business. (Street address, City, State/District/Region, Postal Code, Country) | Legal address of the individual or business. (Street address, City, State/District/Region, Postal Code, Country) | No P.O. Boxes or virtual addresses. | | **Legal Entity Identifier** | Required for business originators of fiat payouts. Not required for individuals. | Not required for beneficiaries | Legal entity Identifier may be a registration number or tax ID. | | **Account Identifier*** | Account Number or IBAN. | Account Number, IBAN, or Wallet Address. | For cash-funded remittance transaction, will be a unique identifier for the originator or transaction | | **Financial Institution (FI) info** | Name and Identifier of the FI | Name and Identifier of the FI (For fiat payouts only) | | > *For Travel Rule purposes, information about the originator's fiat account is typically transmitted to beneficiary financial institutions, unless wallets are provisioned for *and* self-custodied by the originator. When your organization is the custodian of the wallets, the fiat bank account that ultimately funds the transaction is identified in the payment instruction via the `funding_account_id`. ## Wallet Risk Categories Wallets are screened against the screening provider's full set of risk categories and behavioral patterns. See [Wallet Screening Risk Categories](/overviews/wallet-screening-risk-categories) for the complete list and how each value maps to the `risk_status_reason_category` field on the payment record. --- ## Document: Authentication How to authenticate with the Tesser API URL: /overviews/authentication # Authentication All requests to the Tesser API must include a valid access token. Tokens are generated using your API credentials, which are provisioned in the [Tesser Dashboard](https://app.tesser.xyz). ## Credentials Each workspace has a **Client ID** and **Client Secret** pair. You can find these in the Tesser Dashboard under **Settings > API Keys**. Keep your client secret secure and never expose it in client-side code or public repositories. ## Generate an API Token To obtain an access token, send a `POST` request to the Tesser token endpoint with your credentials: ```bash curl --request POST \ --url https://auth.tesser.xyz/oauth/token \ --header 'Content-Type: application/json' \ --data '{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "audience": "https://api.tesser.xyz", "grant_type": "client_credentials" }' ``` ### Token Response A successful request returns a JSON response containing your access token: ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp...", "token_type": "Bearer", "expires_in": 86400 } ``` | Field | Description | | --- | --- | | `access_token` | The token to include in API requests | | `token_type` | Always `Bearer` | | `expires_in` | Token lifetime in seconds (default: 86400 = 24 hours) | ## Using the Token Include the access token in the `Authorization` header of every API request: ```bash curl --request GET \ --url https://api.tesser.xyz/counterparties \ --header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp...' ``` ## Token Expiration Tokens expire after the duration specified in `expires_in` (default 24 hours). When a token expires, the API returns a `401 Unauthorized` response. To maintain uninterrupted access: - Request a new token before the current one expires. - Do not cache tokens indefinitely. - There are no refresh tokens. Simply request a new token using the same credentials. --- ## Document: Accounts Manage fiat bank accounts and crypto wallets URL: /overviews/accounts # Accounts An account represents a store of value for fiat currencies and/or stablecoins. - **Bank account**: Account that exists at a regulated depository financial institution (i.e. a bank). Stores fiat. - **Wallet**: An on-chain wallet. Stores stablecoins. - **Ledger**: An account at a liquidity provider/custodian. May store fiat and/or stablecoins. Accounts may be managed by Tesser or unmanaged. "Unmanaged" accounts are accounts that were not provisioned by Tesser, and instead were provisioned by a third party. "Managed" accounts are accounts that are provisioned by Tesser. The distinction is about who provisioned the underlying account, not who created the record at Tesser: Tesser creates unmanaged account records on your behalf to represent external accounts it encounters. The sending wallet on an inbound payment is the common case — the first time funds arrive from a wallet address Tesser has not seen before, Tesser creates an unmanaged wallet account for it. See [Receive an Inbound Payment to a Wallet](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet#recording-the-sending-wallet-on-the-payment). Accounts will always belong to the workspace they are created in. They can optionally also belong to one of a counterparty or tenant.
*Belongs to:* **Type: Bank account** **Type: Wallet** **Type: Ledger**
**Workspace** n/a

An omnibus wallet an organization uses to manage its treasury operations in an aggregated manner.

(Managed)

An omnibus wallet an organization uses to manage its treasury operations in an aggregated manner.

(Managed)

**Tenant** n/a

A wallet designated for funds belonging to a tenant.

(Managed)

A wallet designated for funds belonging to a tenant.

(Managed)

**Counterparty**

A bank account that belongs to an originator or a beneficiary.

(Unmanaged)

For fiat payouts, bank account info as transmitted as part of Travel Rule compliance.

  • Bank accounts of originators are the ultimate source of funds.
  • Bank accounts of beneficiaries are the ultimate destination of funds.

A wallet designated for an originator or beneficiary provisioned by Tesser. (Managed)

A wallet provisioned for the originator or beneficiary by a non-Tesser provider. (Unmanaged)

A wallet designated for an originator or beneficiary.

(Managed)

## Wallets A wallet's `type` identifies the **blockchain family** it belongs to, not a single network: - `stablecoin_ethereum` — the **EVM (Ethereum Virtual Machine) family**, which includes Ethereum, Base, Polygon, and other EVM-compatible chains. - `stablecoin_solana` — the Solana network. - `stablecoin_stellar` — the Stellar network. :::info `stablecoin_ethereum` covers every EVM chain, not just Ethereum mainnet. Because EVM wallets share a single address across all EVM chains, one `stablecoin_ethereum` wallet has the same `crypto_wallet_address` on Ethereum, Base, Polygon, and any other EVM network. We use `stablecoin_ethereum` rather than `stablecoin_evm` for readability. ::: ### Assets A wallet holds an `assets` array — one entry per `currency` and `network` combination the wallet is enabled for. A single EVM wallet therefore commonly returns balances across **multiple networks**: ```json { "type": "stablecoin_ethereum", "crypto_wallet_address": "0x9A7c3B2e1F4d5C6b8A0e2D1c4B5a6F7e8D9c0B1a", "assets": [ { "currency": "USDC", "network": "ETHEREUM", "available_balance": "0" }, { "currency": "USDT", "network": "ETHEREUM", "available_balance": "0" }, { "currency": "USDC", "network": "BASE", "available_balance": "0" }, { "currency": "USDC", "network": "POLYGON", "available_balance": "0" }, { "currency": "USDT", "network": "POLYGON", "available_balance": "0" } ] } ``` Although an EVM wallet is theoretically compatible with any EVM chain and token, Tesser only enables a curated set of `currency` and `network` combinations. See [Supported Tokens and Networks](./tokens-and-networks) for the current list and each combination's status. ### Sandbox Networks In the sandbox, `network` values are testnet identifiers that map to their mainnet equivalents in production: | Sandbox (testnet) | Production (mainnet) | | --- | --- | | `ETHEREUM_SEPOLIA` | `ETHEREUM` | | `BASE_SEPOLIA` | `BASE` | | `POLYGON_AMOY` | `POLYGON` | :::note A wallet's `assets` are generally fixed once it is created. Tesser may enable additional networks or tokens on an existing wallet — coordinated with you in advance — and a new asset can also appear if a wallet organically receives funds on a network it hasn't held before (for example, a rebalance that lands USDC on a newly supported EVM chain). ::: ### Wallet Registration with Liquidity Providers Before a liquidity provider can transfer funds to a wallet, the wallet's address must be registered with that provider: - **Circle**: Tesser submits registrations automatically. New wallets are registered as recipients on every supported network in their family — an EVM wallet on Ethereum, Base, and Polygon; a Stellar wallet on Stellar. Each registration starts as `pending` and must be approved in your Circle account console before it becomes active. - **OpenFX**: In production, you must manually register the wallet address as a withdrawal address in your OpenFX dashboard for the relevant asset. Tesser picks up verified addresses automatically the next time the wallet is used — no API call needed. (Not required in the sandbox.) Registration is **per blockchain network**: a wallet can receive withdrawals from a provider on a network as soon as that network's registration is active. Other networks' registrations do not block it. :::warning{title="Circle approval required"} Wallets registered with Circle require approval via email: Circle emails your account administrators when approvals are pending. Approve each recipient address within **48 hours** (per Circle's notification email); unapproved registrations may expire. Until a network's registration is approved, deposits and transfers to the wallet on that network are rejected. If you didn't approve in time, contact Tesser support. ::: --- ## Document: Test Compliance Risk Checks Exercise every risk outcome (approved, awaiting review, and rejected) for outbound and inbound payments in your sandbox environment. URL: /how-tos/test-compliance-risk-checks # Test Compliance Risk Checks Exercising risk screening is hard to arrange on demand. For inbound payments you would normally need access to a wallet that already carries a risk score. In both directions it is not obvious which addresses correspond to which risk categories. To let you build and test your compliance handling against every outcome, Tesser provides two testing mechanisms in **sandbox**: - **Outbound**: send a payout to one of the public [test destination addresses](#outbound-test-destination-addresses) below. Each address corresponds to a different risk severity. - **Inbound**: call [`POST /v1/payments/simulate-inbound`](#test-inbound-risk-checks) and choose the `mocked_risk_status` you want the screening verdict forced to. With both mechanisms you get the same [`payment.risk_updated`](/webhooks/payment-updates) webhook, `risk_status` transitions, and [manual-review flow](#review-a-payment-awaiting-a-decision) as in production. See [Compliance and Risk Management](/overviews/compliance-and-risk) for how screening works and the [Funds Movement Lifecycle and Data Model](/overviews/funds-movement-lifecycle-and-data-model#risk-statuses) for the full `risk_status` taxonomy. :::note{title="Sandbox only"} These are testing tools for your sandbox integration. `POST /v1/payments/simulate-inbound` is blocked in production (returns [`payments-3053`](/overviews/errors)). The outbound test addresses are public on-chain addresses that Tesser does not control. Use them as payout beneficiaries in sandbox and never send meaningful value to them. ::: ## Test outbound risk checks Outbound screening targets the **beneficiary wallet**, the on-chain address behind `desired.to.account_id`. To trigger a given outcome, create a payout whose destination account's `crypto_wallet_address` is one of the test addresses. A payout does not execute until risk is approved (see [Payout risk review](/how-tos/send-a-stablecoin-payout/create-a-stablecoin-payout#payout-risk-review)). ### Outbound test destination addresses These are EVM addresses and can be used on **Ethereum Sepolia, Base Sepolia, and Polygon Amoy**. In sandbox, use one as the beneficiary of a stablecoin payout.
Severity → risk_status Destination address
Medium → automatically_approved 0xd3f72b3e91c8124dfc1b4273e93594bb00c9be6f
High → awaiting_decision 0x0a5b2bf3ccfb44c1d22f07eed9553ecba752d4ad
Severe → automatically_rejected 0xac4cc4b68ea24bbfaac8fd127b67ed445accce22
### Steps 1. **Create a beneficiary account at a test address.** Create a [counterparty](/how-tos/create-a-counterparty) and a stablecoin [account](/how-tos/create-an-account) whose `crypto_wallet_address` is the test address for the severity you want to exercise. Payouts to this account screen to the corresponding outcome. 2. **Create a payout to that account.** Follow [Create a Payout](/how-tos/send-a-stablecoin-payout/create-a-stablecoin-payout) and set `desired.to.account_id` to the beneficiary account. A minimal request: ```json { "funding_account_id": "2113b166-5873-42fd-85fe-5b1a02940d43", "desired": { "from": { "account_id": "6de8a7e9-be79-4885-9b65-b25b11d38078", "amount": "1", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "53b7aabd-97bc-4a4f-9f9c-1c1c69474889", "currency": "USDC", "network": "ETHEREUM" } } } ``` 3. **Observe the verdict.** Once `desired.to.account_id` is set, Tesser screens the beneficiary and emits a [`payment.risk_updated`](/webhooks/payment-updates) webhook carrying the mapped `risk_status`: ```json { "risk_status": "automatically_rejected", "risk_status_reasons": [ { "risk_status_reason_type": "OWNERSHIP", "risk_status_reason_category": "Sanctions", "risk_status_reason_message": "Address has OWNERSHIP activity for Sanctions", "risk_status_reason_severity": "Severe", "risk_status_reason_participant_id": null, "risk_status_reason_significance": "DECISIVE", "volume_usd_percent": "100.00", "volume_usd_percent_threshold": "1", "volume_usd_amount": "56051.10", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ] } ``` ## Test inbound risk checks [`POST /v1/payments/simulate-inbound`](/api/payments#simulate-an-inbound-payment-with-a-chosen-risk-status-non-production-only) sends a real, tiny test-USDC transfer from a Tesser-controlled faucet wallet into one of your managed wallets. It then forces the screening verdict to the `mocked_risk_status` you choose. The inbound itself is genuine. It is detected on-chain through the normal path, and balances, lifecycle, and webhooks all behave exactly as they would for a real inbound. Only the screening verdict is fabricated. It carries risk indicators that match your `mocked_risk_status`, so `risk_status_reasons` is populated the same way as in production. See [Receive an Inbound Payment to a Wallet](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet) for the flow this exercises. ### Prerequisites - A **managed `stablecoin_ethereum` wallet** with an on-chain address to receive the inbound (`to_account`). Provision one via [Create an Account](/how-tos/create-an-account). An account that exists but is not an eligible managed wallet returns [`payments-3055`](/overviews/errors). ### Request | Field | Required | Description | | --- | --- | --- | | `to_account` | Yes | Managed account ID that should receive the simulated inbound. | | `mocked_risk_status` | Yes | The `risk_status` the screening verdict is forced to: `automatically_approved`, `awaiting_decision`, or `automatically_rejected`. | | `network` | No | EVM testnet to send on: `BASE_SEPOLIA` (default), `ETHEREUM_SEPOLIA`, `POLYGON_AMOY`, or `TEMPO_MODERATO`. Any other network returns [`payments-3054`](/overviews/errors). | The transfer is a fixed `1` USDC per call. The API returns the actual value. ```bash curl -X POST https://sandbox.tesserx.co/v1/payments/simulate-inbound \ -H "Authorization: Bearer your-access-token" \ -H "Content-Type: application/json" \ -d '{ "to_account": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "mocked_risk_status": "awaiting_decision", "network": "BASE_SEPOLIA" }' ``` The `mocked_risk_status` you send is exactly the `risk_status` the resulting inbound screens to. If you choose `awaiting_decision`, record a decision afterward (see [Review a payment awaiting a decision](#review-a-payment-awaiting-a-decision)). ### Response Once the inbound payment is detected and screened, the endpoint returns the full payment with `risk_status` set to your `mocked_risk_status`: ```json { "data": { "id": "9f3b7e1c-4a2d-4f69-b5a8-0c6e2d9f1a3b", "direction": "inbound", "risk_status": "awaiting_decision", "risk_status_reasons": [ { "risk_status_reason_type": "OWNERSHIP", "risk_status_reason_category": "High-Risk Exchange", "risk_status_reason_message": "Address has OWNERSHIP activity for High-Risk Exchange", "risk_status_reason_severity": "High", "risk_status_reason_participant_id": null, "risk_status_reason_significance": "DECISIVE", "volume_usd_percent": "100.00", "volume_usd_percent_threshold": "1", "volume_usd_amount": "56051.10", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ], "balance_status": null } } ``` If the on-chain transfer is broadcast but not yet detected and screened within the request's wait budget, the endpoint returns `202 Accepted` instead, with the transaction hash and (once known) the `payment_id`. Poll [`GET /v1/payments/{paymentId}`](/api/payments#get-a-payment) for the screened payment: ```json { "data": { "tx_hash": "0xabcdef0123", "network": "BASE_SEPOLIA", "status": "pending", "payment_id": null } } ``` Inbound risk is advisory and does not gate the credit (see [Spending the Inbound Funds](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet#spending-the-inbound-funds)). ## Review a payment awaiting a decision Whenever a payment lands in `awaiting_decision`, record a decision through the [risk review decision API](/api/payments#submit-risk-review) or in the Tesser dashboard: ```bash curl -X POST https://sandbox.tesserx.co/v1/payments/{paymentId}/review \ -H "Authorization: Bearer your-access-token" \ -H "Content-Type: application/json" \ -d '{ "is_approved": true }' ``` After the decision is recorded, `risk_reviewed_by` and `risk_reviewed_at` are populated. A `payment.risk_updated` webhook fires with `risk_status` transitioned to `manually_approved` (when `is_approved` is `true`) or `manually_rejected` (when `false`). --- ## Document: Sign a Wallet Step Submit signatures for self-custodial wallet-originated steps across payments, rebalances, and withdrawals. URL: /how-tos/sign-a-wallet-step # Sign a Wallet Step This guide covers how to sign a step that originates funds movement out of a self-custodial wallet. Applies to payments, rebalances, and withdrawals. Tesser orchestrates the funds-movement lifecycle and prepares the unsigned transaction, but cannot submit the on-chain transaction itself — you must sign client-side with your wallet's signing key, then return the signature to Tesser via the resource-appropriate sign endpoint. Deposits via a liquidity provider — even those that land in a self-custodial wallet — don't require customer-signed steps, as the wallet is a destination for the deposit, not a source of funds. The wallet-step signing flow covered here is for resources where the customer's wallet is the **source** of funds. :::note Gas for on-chain transfers initiated through this flow is sponsored by Tesser. You do not need to fund the source wallet with native gas tokens — only the asset being transferred needs to be present on the source wallet. ::: The shape of the signing flow — the SDK call, the request body, the validation error codes, and the on-chain follow-up — is the same across all three resource types. Only the URL of the sign endpoint differs by resource. ## Step Status Flow A wallet-source step transitions through the following states:
Status Webhook event Meaning
`created` (none) The step has been planned and inserted into `step_sequence` but is not yet ready to sign.
`signature_requested` `step.signature_requested` Tesser has prepared the unsigned transaction; the step DTO now carries `unsigned_transaction`. You sign client-side and submit the signature to the sign endpoint.
`signed` `step.signed` The sign endpoint accepted your signature and the transaction is ready to broadcast. It may rest here while earlier nonces on the same wallet clear; `submitted` marks the actual broadcast attempt. `transaction_hash` is already populated at this point — it is derived from the signed payload, so it is known before broadcast. Treat it as the identifier to correlate against, not as proof that the transaction has reached the network.
`submitted` `step.submitted` Tesser has broadcast the signed transaction to the network. For Turnkey-managed wallets (the self-custodial wallet provider in scope for this flow), `step.submitted` is an event-only marker — the persisted step row transitions directly from `signed` to `confirmed` once Tesser observes the broadcast result (mempool acceptance), while the webhook payload and GET response both surface `status: "submitted"` and a populated `submitted_at` at the event boundary.
`confirmed` `step.confirmed` The transaction has been broadcast and accepted into the network's mempool — it is not yet included in a block. `transaction_hash` is confirmed against the value the node returned.
`completed` `step.completed` Funds have settled at the destination. The step is terminal.
## The `unsigned_transaction` Field When a wallet-source step reaches `signature_requested`, Tesser publishes the unsigned transaction on the step DTO as the `unsigned_transaction` field. The value is the serialized transaction call, ready to sign with the source wallet's key. The same field is retrievable at any time via a `GET` request on the parent resource: `` `GET /v1/payments/{paymentId}` ``, `` `GET /v1/treasury/rebalances/{rebalanceId}` ``, or `` `GET /v1/treasury/withdrawals/{withdrawalId}` ``. Example field shape (truncated for display — the actual value is much longer): ```json { "unsigned_transaction": "0x02ed81893a85 ... 942e8f4c6b3a1d48e79b ... 3a764000080c0" } ``` ## Sign the Step with the LocalSigner SDK Tesser publishes a signer-only SDK in three languages: [TypeScript](https://github.com/tesser-payments/sdk-ts) (`@tesser-payments/sdk-ts`), [Kotlin](https://github.com/tesser-payments/sdk-kotlin) (`xyz.tesser:sdk`), and [Java](https://github.com/tesser-payments/sdk-java) (`xyz.tesser:sdk-java`). All three expose a `LocalSigner` that stamps the step locally with your signing key and returns a `signature` string. The SDK does **not** make HTTP calls. Obtaining a token, looking up the wallet address, and submitting the signature are yours to do (any HTTP client works). ### Install ```bash bun add @tesser-payments/sdk-ts @tesser-payments/types ``` `@tesser-payments/types` is a peer dependency; install it alongside the SDK. ```kotlin title="build.gradle.kts" dependencies { implementation("xyz.tesser:sdk:0.0.3") } ``` Targets JVM 17+ and Kotlin 2.0+. `signStep` is a `suspend` function, so call it from a coroutine. ```xml title="pom.xml" xyz.tesser sdk-java 0.0.3 ``` For Gradle, use `implementation("xyz.tesser:sdk-java:0.0.3")`. Targets Java 17+. `signStep` returns a `CompletableFuture` that is already complete when it returns; the signing runs on the calling thread. ### Sign To sign, construct a `StepForSigning` from the webhook step and call `signStep`. You supply three values: the `unsigned_transaction` from the step, the source wallet's on-chain address, and the network. Resolve the on-chain address with `GET /v1/accounts/{account_id}` using the step's `estimated.from.account_id` (the public, GET-resolvable account ID); its `crypto_wallet_address` is the `signWith` value. ```typescript import { LocalSigner, type StepForSigning } from "@tesser-payments/sdk-ts"; // Initialize the signer once at application startup. 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 { data: account } = await fetch( `https://api.tesser.xyz/v1/accounts/${step.estimated.from.account_id}`, { headers: { Authorization: `Bearer ${token}` } } ).then((res) => res.json()); // 3. Build the StepForSigning payload and sign locally. const toSign: StepForSigning = { unsignedTransaction: step.unsigned_transaction, signWith: account.crypto_wallet_address, network: step.estimated.from.network, // BASE | BASE_SEPOLIA | ETHEREUM | POLYGON | POLYGON_AMOY | SOLANA }; const { signature } = await signer.signStep(toSign); // 4. Submit the signature to the resource-appropriate sign endpoint // (replace per the table in the next section). await fetch( `https://api.tesser.xyz/v1//${step.payment_id ?? step.rebalance_id ?? step.withdrawal_id}/steps/${step.id}/sign`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ signature }), } ); ``` `signStep` returns `{ signature, metadata }`. Send `signature` to the sign endpoint to execute the step; `metadata` (`stampHeaderName`, `stampHeaderValue`, `body`) stays client-side for debugging and is never sent to Tesser. ```kotlin import kotlinx.coroutines.runBlocking import xyz.tesser.sdk.LocalSigner import xyz.tesser.sdk.SigningConfig import xyz.tesser.sdk.StepForSigning // Initialize the signer once at application startup. val signer = LocalSigner( SigningConfig( publicKey = System.getenv("SIGNING_PUBLIC_KEY"), privateKey = System.getenv("SIGNING_PRIVATE_KEY"), enclaveId = System.getenv("SIGNING_ENCLAVE_ID"), ), ) runBlocking { // `step` is the step.signature_requested webhook's data.object (parsed). // Resolve the source wallet's on-chain address via GET /v1/accounts/{id}; // `fetchAccount` / `submitSignature` wrap your own HTTP client. val signWith = fetchAccount(step.estimated.from.accountId).cryptoWalletAddress val toSign = StepForSigning( id = step.id, transferId = step.rebalanceId, // or paymentId / withdrawalId per resource unsignedTransaction = step.unsignedTransaction, signWith = signWith, network = step.estimated.from.network, // BASE | BASE_SEPOLIA | ETHEREUM | POLYGON | POLYGON_AMOY | SOLANA ) val signed = signer.signStep(toSign) // POST /v1//{transferId}/steps/{stepId}/sign submitSignature(toSign.transferId, toSign.id, signed.signature) } ``` `signStep` returns a `SignedStepResult` with `signature` (send this to the sign endpoint), `unsignedTransaction` (an echo of the input, for audit trails), and `metadata` (`stampHeaderName`, `stampHeaderValue`, `body`) for debugging. Only `signature` is sent to Tesser. ```java import xyz.tesser.sdk.java.LocalSigner; import xyz.tesser.sdk.java.SignedStepResult; import xyz.tesser.sdk.java.SigningConfig; import xyz.tesser.sdk.java.StepForSigning; // Initialize the signer once at application startup. The constructor // fails fast if the public and private keys are not a pair. LocalSigner signer = new LocalSigner(new SigningConfig( System.getenv("SIGNING_PUBLIC_KEY"), System.getenv("SIGNING_PRIVATE_KEY"), System.getenv("SIGNING_ENCLAVE_ID"))); // `step` is the step.signature_requested webhook's data.object (parsed). // Resolve the source wallet's on-chain address via GET /v1/accounts/{id}; // `fetchAccount` / `submitSignature` wrap your own HTTP client. String signWith = fetchAccount(step.estimated().from().accountId()).cryptoWalletAddress(); StepForSigning toSign = new StepForSigning( step.id(), step.rebalanceId(), // or paymentId / withdrawalId per resource step.unsignedTransaction(), signWith, step.estimated().from().network()); // BASE | BASE_SEPOLIA | ETHEREUM | POLYGON | POLYGON_AMOY | SOLANA SignedStepResult signed = signer.signStep(toSign).join(); // POST /v1//{transferId}/steps/{stepId}/sign submitSignature(toSign.transferId(), toSign.id(), signed.signature()); ``` `signStep` returns a `CompletableFuture` with `signature` (send this to the sign endpoint), `unsignedTransaction` (an echo of the input, for audit trails), and `metadata` for debugging. Only `signature` is sent to Tesser. A signing failure arrives as a failed future: `join()` throws a `CompletionException` whose `getCause()` is the SDK's `TesserError`. For runnable end-to-end scripts (webhook-driven and polling-based), see the `examples/` directory in the [TypeScript](https://github.com/tesser-payments/sdk-ts/tree/main/examples), [Kotlin](https://github.com/tesser-payments/sdk-kotlin/tree/main/examples), and [Java](https://github.com/tesser-payments/sdk-java/tree/main/examples) repos. ## Sign-Endpoint Contract The sign endpoint takes a single JSON body with one field — the `signature` returned by `LocalSigner.signStep`: ```json { "signature": "" } ``` The body shape is identical across all three resources. Only the endpoint URL differs: | Resource | Endpoint | |---|---| | Payment | [`POST /v1/payments/{paymentId}/steps/{stepId}/sign`](/api/payments#sign-payment-step) | | Rebalance | `POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign` | | Withdrawal | `POST /v1/treasury/withdrawals/{withdrawalId}/steps/{stepId}/sign` | Use the URL that matches the parent resource — i.e., the resource that emitted the `step.signature_requested` webhook. The per-resource how-tos ([Send a Payout (from a wallet)](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet), [Rebalance Funds](/how-tos/manage-your-treasury/rebalance-funds), [Withdraw Funds via a Liquidity Provider](/how-tos/manage-your-treasury/withdraw-funds-via-a-liquidity-provider)) carry the surrounding scenario context for when wallet-step signing applies in each flow. ## Signing Several Transfers from One Wallet On-chain transactions from a wallet must reach the network in nonce order, and each transfer's nonce is fixed when Tesser prepares its unsigned transaction. Tesser queues them for you: a signed transfer whose predecessors have not been broadcast yet is held, then sent automatically as soon as they are. What you see when a transfer is queued: - The sign call returns **200** and the step stays `signed` — the signature was accepted, funds are reserved. `transaction_hash` is already populated (it is derived from the signed payload), but `submitted_at` stays `null` until the transfer's turn comes: the hash identifies the transaction, it does not mean the transaction was broadcast. - When its turn comes, `step.submitted` and `step.confirmed` fire as usual, and `submitted_at` is stamped. No retry or second call is needed. Two signing patterns are safe: **sign the batch in parallel** (arrival order does not matter — the queue sorts them), or **sign sequentially in nonce order**. What is not safe is signing a later transfer first and then taking your time over its predecessors. A queued transfer waits roughly 12 seconds; if the transfers ahead of it have not been broadcast by then — because they were signed too late, or never signed at all — Tesser fails the whole outstanding group for that wallet **on that network** (the same address on another network has its own independent queue). The waiting transfer fails with `transfers-9311` and the rest with `transfers-9312`, each naming the transfer that caused it. Nothing reaches the chain, reservations are released, and the wallet is immediately reusable. Create new transfers to retry. ## Sign-Endpoint Validation Error Codes The sign endpoint validates the submitted signature before accepting it. Validation failures are surfaced as 4XX responses with an `error_code` identifying the cause. The rebalance and withdrawal sign endpoints share the `treasury-30xx` namespace; the payment sign endpoint uses `payments-30xx`. | Semantic | Rebalance + Withdrawal | Payment | |---|---|---| | Invalid signature | `treasury-3015` | `payments-3013` | | Signed transaction does not match step/payment | `treasury-3016` | `payments-3014` | | Step not signable (wrong state) | `treasury-3013` | — (no direct equivalent — see [Errors overview](/overviews/errors)) | | Unsigned transaction missing | `treasury-3014` | — (no direct equivalent — see [Errors overview](/overviews/errors)) | Two of the four sign-validation semantics have direct counterparts across both code families; the other two — "step not signable" and "unsigned transaction missing" — are only surfaced as distinct codes by the treasury endpoints today. For the payment sign endpoint, those conditions are surfaced by adjacent codes in the broader `payments-30xx` range (for example, `payments-3026` "Transfer step is already in status ``. Cannot execute."). See the full [Errors overview](/overviews/errors) for the per-domain reference. ## Sign-Endpoint Failure Response A validation failure returns a 4XX response in the standard Tesser error envelope — a top-level `errors` array of error objects, each carrying an `error_code` from the table above. Example using `treasury-3015` (invalid signature — the most commonly reproducible failure during integration testing): ```json { "errors": [ { "error_code": "treasury-3015", "error_message": "Signature is invalid or could not be verified" } ] } ``` The equivalent response from the payment sign endpoint uses `error_code: "payments-3013"` (`"signature is malformed or signed with incorrect key"`). The envelope shape is the same; only the resource-domain prefix on the code differs. After a validation failure, the step's status remains `signature_requested` and the step-level `actual.*` overlay remains all-null — the failure is at the sign endpoint, not at the step. Retry the signing flow with a fresh signature. For `treasury-3013` ("step not signable"), the step may have advanced beyond `signature_requested` while you were preparing the signature — fetch the latest step state via `GET` on the parent resource before retrying. ## Wallet-Source Insufficient Funds at Sign Time The sign endpoint also validates that the source wallet has sufficient funds to cover `desired.from.amount` before accepting the signature. The balance check is synchronous: it runs inside the same request that submits the signature. The full sequence: 1. **Sufficient funds.** The sign endpoint accepts the signature, emits `step.signed`, and emits `.balance_updated` with `balance_status: "reserved"` from the sign-API path. 2. **Insufficient funds.** The sign endpoint returns a 4XX response and emits `.balance_updated` with `balance_status: "awaiting_funds"`. The signature is not consumed; the step stays at `signature_requested`. 3. **Wallet balance updates.** When both the source wallet's live on-chain balance and internal available balance after existing reservations cover the waiting step, Tesser republishes `step.signature_requested` for the same step ID with a refreshed `signature_requested_at`. Partial funding leaves the step waiting. If the live balance lookup fails, Tesser waits for another balance update before checking again. Repeat the signing flow with the refreshed step; signing still checks both the live on-chain balance and internal available balance. 4. **Expiration.** The retry loop continues until the wallet is funded or the parent resource hits `expires_at`. If the resource expires, the funds-movement is terminal — create a new resource to retry. Several waiting transfers can each receive a refreshed request if they individually fit the available funds. A request does not reserve funds: the first successful sign reserves them, and later signs can fail with insufficient funds. Such a failure returns the step to waiting for another funding update. Duplicate funding events do not refresh the same waiting request again. Signature-request webhooks use the existing delivery mechanism, without durable delivery retries. If a notification is missed, you can fetch the resource and retry signing before `expires_at`. For per-resource example payloads of the `balance_updated` webhook and the surrounding scenario context, see each guide's Balance Check section: - [Payout Balance Check (wallet payout)](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet#payout-balance-check-paymentbalance_updated) - [Rebalance Balance Check](/how-tos/manage-your-treasury/rebalance-funds#rebalance-balance-check-rebalancebalance_updated) - [Withdrawal Balance Check](/how-tos/manage-your-treasury/withdraw-funds-via-a-liquidity-provider#withdrawal-balance-check-withdrawalbalance_updated) ## See Also For the scenario-specific context around wallet-step signing in each resource type: - [Send a Payout (from a wallet)](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet) — wallet-source payouts to a counterparty wallet (stablecoin) or fiat off-ramp. - [Rebalance Funds — Scenario 3](/how-tos/manage-your-treasury/rebalance-funds#scenario-3-on-chain-transfer-between-self-custodial-wallets) — on-chain transfer between two self-custodial wallets. - [Withdraw Funds via a Liquidity Provider — Scenario 3](/how-tos/manage-your-treasury/withdraw-funds-via-a-liquidity-provider#scenario-3-on-chain-transfer-from-a-self-custodial-wallet-then-off-ramp-at-openfx) — on-chain transfer from a self-custodial wallet, then off-ramp at OpenFX. For the funds-movement lifecycle, status taxonomies, and overlay model that contextualize wallet-step signing within a resource's broader flow: - [Funds Movement Lifecycle and Data Model](/overviews/funds-movement-lifecycle-and-data-model) --- ## Document: Create an Account Pre-register accounts for payouts on the Tesser platform URL: /how-tos/create-an-account # Create an Account Accounts must be pre-registered to transact on the Tesser platform. Registering accounts in advance of transacting enables wallet risk assessment and helps meet regulatory requirements. **Account creation** To create an account, submit a request to create a bank, wallet, ledger, or [generic](#generic-account) account and populate the appropriate fields. Accounts will always belong to the workspace they are created in and can also optionally belong to a tenant or counterparty. See the [Accounts overview](/overviews/accounts) for more details. Bank account creation request ```json { "counterparty_id": "2a7e1c9f-4b3d-4a82-b6e0-8f5c2d1a9b3e", "name": "Operating Account - EU", "bank_name": "Chase Bank", "bank_code_type": "SWIFT", "bank_identifier_code": "CHASUS33", "bank_account_number": "123456789" } ``` The response will provide a unique identifier for the account that can be used when requesting payments or transfers: ```json { "data": { "id": "8f3b1e7c-9a2d-4f58-b6c0-4e7d2a9f1b3c", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "tenant_id": null, "counterparty_id": "2a7e1c9f-4b3d-4a82-b6e0-8f5c2d1a9b3e", "type": "fiat_bank", "name": "Operating Account - EU", "bank_name": "Chase Bank", "bank_code_type": "SWIFT", "bank_identifier_code": "CHASUS33", "crypto_wallet_address": null, "created_at": "2024-03-02T14:30:00.000Z", "updated_at": "2024-03-02T14:30:00.000Z" } } ``` Wallet account creation request ```json { "workspace_id": "b53f6690-3242-4942-9907-885779632832", "name": "Treasury Wallet (Omnibus)", "type": "stablecoin_ethereum", "signature": "eyJwdWJsaWNLZXkiOiJwdWJfZXhhbXBsZSIsInNpZ25hdHVyZSI6InNpZ19leGFtcGxlIiwic2NoZW1lIjoiRUQyNTUxOSJ9" } ``` The response will provide a unique identifier for the account that can be used when requesting payments or transfers: ```json { "data": { "id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "tenant_id": null, "counterparty_id": null, "type": "stablecoin_ethereum", "name": "Treasury Wallet (Omnibus)", "bank_name": null, "bank_code_type": null, "bank_identifier_code": null, "crypto_wallet_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE0B", "created_at": "2024-03-01T10:00:00.000Z", "updated_at": "2024-03-01T10:00:00.000Z" } } ``` Ledger account creation request ```json { "workspace_id": "b53f6690-3242-4942-9907-885779632832", "name": "Treasury Ledger", "provider": "CIRCLE_MINT" } ``` The response will provide a unique identifier for the account that can be used when requesting payments or transfers: ```json { "data": { "id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "tenant_id": null, "counterparty_id": null, "name": "Treasury Ledger", "type": "ledger", "provider": "CIRCLE_MINT" } } ``` ## Generic Account A generic account records an account identifier that Tesser stores but does not interpret, such as a customer id at an exchange or an account number at another institution. It has no network, currency, or balance, so it never holds assets. Create one with [`POST /v1/accounts/generic`](/api/accounts#create-a-new-generic-account): ```json { "counterparty_id": "6f1c4a8e-2d7b-4930-b5e2-9a3f0c7d1b6e", "name": "Acme at Coinbase", "generic_account_number": "ACC-778" } ``` ```json { "data": { "id": "3a6f9c2e-8d1b-4e57-a2c4-7b0e5d9f1c3a", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "tenant_id": null, "counterparty_id": "6f1c4a8e-2d7b-4930-b5e2-9a3f0c7d1b6e", "type": "generic", "name": "Acme at Coinbase", "generic_account_number": "ACC-778", "created_by": "CUSTOMER", "created_at": "2024-03-01T10:00:00.000Z", "updated_at": "2024-03-01T10:00:00.000Z" } } ``` - **Owner:** exactly one of `tenant_id` or `counterparty_id` is required. Sending neither (`accounts-3027`) or both (`accounts-3000`) returns `400`. - **Number:** `generic_account_number` is required, up to 256 characters. Tesser trims surrounding whitespace and keeps the case, so `ACC-778` and `acc-778` are different numbers. - **One account per number:** an owner has at most one generic account per number. A duplicate returns `409` (`accounts-3028`), and the error message carries the existing account's id. The existing account may be one Tesser created from an inbound payment's travel-rule data (`created_by: "SYSTEM"`); use that account instead of creating another. - **Immutable number:** [`PATCH /v1/accounts/:id`](/api/accounts#update-an-account) changes only the `name`. Sending `generic_account_number` returns `400`. To record a different number, create a new generic account. - **Funding only:** a generic account can be a payment's `funding_account_id`, but never the source or destination of a funds movement. Using it as a payment's `from_account_id` or `to_account_id`, or as the account of a deposit, withdrawal, or rebalance, returns `400` (`accounts-3029`). See [Funding Account vs. From Account](/overviews/funds-movement-lifecycle-and-data-model#funding-account-vs-from-account). Generic accounts appear in [`GET /v1/accounts`](/api/accounts#list-all-accounts) (filter with `type=generic`) and [`GET /v1/accounts/:id`](/api/accounts#get-an-account-by-id), with `generic_account_number` and no `assets`. **Associating wallets and ledgers with a tenant or counterparty** A wallet or ledger can optionally belong to either a counterparty or tenant in addition to the workspace. - Do not specify a tenant or counterparty if you want the wallet or ledger to be used in a fully omnibus manner, where funds are commingled across all of your customers. Upon onboarding, your organization will be provisioned with a wallet or ledger that only belongs to your workspace. You may create additional wallets or ledgers that only belong to the workspace as needed. - Create a wallet or ledger that belongs to a tenant to manage proprietary funds belonging to that tenant, or to commingle funds belonging to that tenant's customers (e.g. originators and/or beneficiaries). - Create a wallet or ledger that belongs to a counterparty to fully segregate funds only for that counterparty. For more information on the relationship between accounts and counterparties, see the [Accounts overview](/overviews/accounts). --- ## Document: Create a Tenant How to create and manage tenants for platform customers URL: /how-tos/create-a-tenant # Create a Tenant A tenant represents an organization’s customer when that customer is itself a platform with its own users who need to be represented in Tesser’s systems. When an organization’s customers are tenants, originators and/or beneficiaries are customers of the tenant and thus end-customers to the organization. To create a tenant, submit a request to the [tenant creation API](/api/tenants#create-a-new-tenant). ```json { "business_legal_name": "Acme Corporation", "business_dba": "Acme Co", "business_address_country": "US", "business_street_address1": "123 Corp Blvd", "business_street_address2": "Suite 100", "business_city": "New York", "business_state": "NY", "business_legal_entity_identifier": "123456789", "webhook_url": "https://acme.com/webhooks/tesser" } ``` Example response: ```json { "data": { "id": "29c580bd-43d2-4dbf-bc9e-a3b1ccb8e7ee", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "business_legal_name": "Acme Corporation", "business_dba": "Acme Co", "business_address_country": "US", "business_street_address1": "123 Corp Blvd", "business_street_address2": "Suite 100", "business_city": "New York", "business_state": "NY", "business_legal_entity_identifier": "9876543210", "country": "US", "webhook_url": "https://acme.com/webhooks/tesser", "created_at": "2024-01-15T10:30:00.000Z", "updated_at": "2024-01-15T10:30:00.000Z" } } ``` **Creating resources for a tenant** To initiate counterparty, account, payment, or transfer creation on behalf of the tenant, you will supply the `id` of the tenant returned in the creation response in API calls using your own API keys. --- ## Document: Create a Counterparty Pre-register counterparties for payouts on the Tesser platform URL: /how-tos/create-a-counterparty # Create a Counterparty Counterparties must be pre-registered to transact on the Tesser platform. Registering counterparties in advance of submitting payouts enables OFAC (sanctions) screening and helps meet regulatory requirements. **Counterparty creation** To create a counterparty, submit a request to the [counterparty creation API](/api/counterparties#create-a-new-counterparty) and populate the appropriate fields for the individual or business. *Note: Individuals are natural persons; businesses are registered legal entities. Select a type of customer you want to create: Example: Individual counterparty creation request ```json { "classification": "individual", "individual_first_name": "John", "individual_last_name": "Doe", "individual_address_country": "US", "individual_date_of_birth": "1980-01-01", "individual_national_identification_number": "123456789", "individual_street_address1": "123 Main St", "individual_street_address2": "Apt 4B", "individual_city": "New York", "individual_state": "NY", "individual_postal_code": "10001" } ``` In the successful response you receive a unique id for the counterparty. ```json { "data": { "id": "c7d8e9f0-1a2b-3c4d-5e6f-789012345678", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "classification": "individual", "tenant_id": null, "individual_first_name": "John", "individual_last_name": "Doe", "individual_address_country": "US", "individual_date_of_birth": "1980-01-01", "individual_national_identification_number": "123456789", "individual_street_address1": "123 Main St", "individual_street_address2": "Apt 4B", "individual_city": "New York", "individual_state": "NY", "individual_postal_code": "10001", "business_legal_name": null, "business_dba": null, "business_address_country": null, "business_street_address1": null, "business_street_address2": null, "business_city": null, "business_state": null, "business_legal_entity_identifier": null, "created_at": "2024-03-01T10:00:00.000Z", "updated_at": "2024-03-01T10:00:00.000Z" } } ``` Example: Business counterparty creation request ```json { "classification": "business", "business_legal_name": "Acme Corporation", "business_dba": "Acme Co", "business_address_country": "US", "business_street_address1": "456 Corp Blvd", "business_street_address2": "Suite 200", "business_city": "San Francisco", "business_state": "CA", "business_legal_entity_identifier": "1234567890" } ``` In the successful response you receive a unique id for the counterparty. ```json { "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "classification": "business", "tenant_id": "00000000-0000-0000-0000-000000000000", "individual_first_name": null, "individual_last_name": null, "individual_street_address1": null, "individual_street_address2": null, "individual_city": null, "individual_state": null, "individual_postal_code": null, "individual_address_country": null, "individual_date_of_birth": null, "individual_national_identification_number": null, "business_legal_name": "Acme Corporation", "business_dba": "Acme Co", "business_address_country": "US", "business_street_address1": "456 Corp Blvd", "business_street_address2": "Suite 200", "business_city": "San Francisco", "business_state": "CA", "business_legal_entity_identifier": "1234567890", "created_at": "2024-01-15T10:30:00.000Z", "updated_at": "2024-01-15T10:30:00.000Z" } } ``` --- ## Document: Combine a Deposit and a Payout (Managed Wallet) Combine a deposit and an outbound payment into a single orchestrated API call that leverages a "stablecoin sandwich" (fiat --> stablecoin --> fiat) settled through your managed wallet. URL: /how-tos/combine-a-deposit-and-a-payout-via-managed-wallet # Combine a Deposit and a Payout (Managed Wallet) 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. :::note This guide covers the **managed-wallet** variant, where the corridor is bridged through a stablecoin held in your organization's managed wallet (on-ramp partner → managed wallet → off-ramp partner), and your organization signs the on-chain leg. If your corridor is served by a single **cross-ramp provider** that converts and pays out entirely in fiat — no stablecoin, no managed wallet, no signing — see [Combine a Deposit and a Payout](/how-tos/send-a-stablecoin-payout/create-a-cross-ramp-payment) instead. ::: Before walking the flow, review the [Funds Movement Lifecycle and Data Model](/overviews/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](#buffer-funds). - **The counterparties and their bank accounts are registered at Tesser.** Register the originator and the beneficiary as counterparties (see [Create a Counterparty](/how-tos/create-a-counterparty)), along with the originator's fiat bank account and the beneficiary's fiat bank account (see [Create an Account](/how-tos/create-an-account)). This setup happens once per counterparty and is re-used across subsequent payments. :::note 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: 1. **Create the payment and request an FX rate quote** — `POST /v1/payments` with the currency pair. Receive a payment ID synchronously. Tesser then plans the transfer steps asynchronously and fires a single `payment.quote_created` webhook carrying the end-to-end FX rate (on-ramp cost + off-ramp cost + Tesser's fee) and the planned `steps[]`. 2. **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 a `payment.updated` webhook. 3. **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. 4. **Reach terminal state** — the payment's `actual` overlay populates and a final `payment.updated` webhook fires alongside the last `step.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](/how-tos/manage-your-treasury/deposit-funds-via-a-liquidity-provider) (Scenario 3); the last two mirror the fiat-payout pattern in [Create a payout (from a wallet)](/how-tos/send-a-stablecoin-payout/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
:::note 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_id`s on the two sides. See [Steps](/overviews/funds-movement-lifecycle-and-data-model#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. :::note 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. ```json title="POST /v1/payments — request body" { "desired": { "from": { "currency": "MXN", "network": null }, "to": { "amount": "10000", "currency": "USD", "network": null } }, "organization_reference_id": "pmt_2026_05_18_001" } ``` 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. ```json title="POST /v1/payments — synchronous response" { "data": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "pmt_2026_05_18_001", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": null, "currency": "MXN", "network": null }, "to": { "account_id": null, "amount": "10000", "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [], "created_at": "2026-05-18T15:00:00.000Z", "updated_at": "2026-05-18T15:00:00.000Z", "expires_at": "2026-05-19T15:00:00.000Z" } } ``` 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. ```json title="Webhook — payment.quote_created" { "id": "evt_01HGZ8R7T3K6P9X2N5W4Y3M2C1", "type": "payment.quote_created", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "pmt_2026_05_18_001", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": null, "currency": "MXN", "network": null }, "to": { "account_id": null, "amount": "10000", "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": null, "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": null, "amount": "10000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440101", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "190095.0475", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440102", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "190095.0475", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440103", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 3, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "10015.0125", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440104", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "10015.0125", "currency": "USDC", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "10015.0125", "currency": "USDC", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440105", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 5, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "10010.005", "currency": "USDC", "network": "POLYGON" }, "to": { "account_id": null, "amount": "10010.005", "currency": "USDC", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [ { "fee_amount": "5.0075", "fee_currency": "USDC", "fee_type": "tesser_fee", "fee_metadata": { "fee_amount_mxn": "95.0475", "fx_rate": "18.98109" } }, { "fee_amount": "0.002027", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440106", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 6, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "10010.005", "currency": "USDC", "network": "POLYGON" }, "to": { "account_id": null, "amount": "10000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [ { "fee_amount": "10.005", "fee_currency": "USDC", "fee_type": "bfi_transaction_fee", "fee_metadata": {} } ], "provider_key": "circle_payments_network", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2026-05-18T15:00:00.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "expires_at": "2026-05-19T15:00:00.000Z" } } } ``` 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). :::note 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`. ::: ```json title="PATCH /v1/payments/{paymentId} — request body" { "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047" } }, "payment_info": { "payment_description_wire": "Invoice 2026-0518" } } ``` - `desired.from.account_id` is **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. :::warning 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_id` is the **originator's MX bank account**, registered in advance (see [Prerequisites](#prerequisites)). This top-level field identifies the ultimate originating entity of the payout for Travel Rule purposes — see [Travel Rule](/overviews/compliance-and-risk#travel-rule). - `desired.to.account_id` is 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. ```json title="Webhook — payment.updated (accounts attached)" { "id": "evt_01HGZ8RJX9Y4V5K3M7N2C1P8B6", "type": "payment.updated", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "pmt_2026_05_18_001", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "payment_info": { "payment_description_wire": "Invoice 2026-0518" }, "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": null, "currency": "MXN", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "10000", "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "10000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440101", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "190095.0475", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440102", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "190095.0475", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440103", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 3, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "10015.0125", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440104", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "10015.0125", "currency": "USDC", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "10015.0125", "currency": "USDC", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440105", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 5, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "10010.005", "currency": "USDC", "network": "POLYGON" }, "to": { "account_id": null, "amount": "10010.005", "currency": "USDC", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [ { "fee_amount": "5.0075", "fee_currency": "USDC", "fee_type": "tesser_fee", "fee_metadata": { "fee_amount_mxn": "95.0475", "fx_rate": "18.98109" } }, { "fee_amount": "0.002027", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "550e8400-e29b-41d4-a716-446655440106", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 6, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "10010.005", "currency": "USDC", "network": "POLYGON" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "10000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [ { "fee_amount": "10.005", "fee_currency": "USDC", "fee_type": "bfi_transaction_fee", "fee_metadata": {} } ], "provider_key": "circle_payments_network", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2026-05-18T15:00:00.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "expires_at": "2026-05-19T15:00:00.000Z" } } } ``` ## 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](#step-2-transfer-of-mxn-from-the-openfx-bank-account-to-your-mxn-ledger-at-openfx)). 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](/how-tos/manage-your-treasury/deposit-funds-via-a-liquidity-provider) 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](/api/accounts#get-an-account-by-id) with that account ID to retrieve the bank account information needed for the push. :::warning{title="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. ```text title="Lifecycle" created → completed ↘ failed ``` 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). ```json title="Webhook — step.completed (step 1)" { "id": "evt_01HGZ8S2N1K3W7Y8B5C2D9F4E0", "type": "step.completed", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440101", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "190095.0475", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "190095.0475", "currency": "MXN", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "190095.0475", "currency": "MXN", "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:30:00.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": "2026-05-18T15:29:50.000Z", "completed_at": "2026-05-18T15:30:00.000Z", "failed_at": null } } } ``` ### 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. ```text title="Lifecycle" created → completed ↘ failed ``` 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. :::note 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`. ```text title="Lifecycle" created → submitted → confirmed → completed ↘ failed ``` 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`). ```text title="Lifecycle" created → submitted → confirmed → completed ↘ failed ``` ```json title="Webhook — step.completed (step 4)" { "id": "evt_01HGZ8SDR4F2H8M3Y6V7W1X9N5", "type": "step.completed", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440104", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "10015.0125", "currency": "USDC", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "10015.0125", "currency": "USDC", "network": "POLYGON" } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "10015.0125", "currency": "USDC", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "10015.0125", "currency": "USDC", "network": "POLYGON" } }, "transaction_hash": "0x1a2b3c4d5e6f ... 9c0d1e2f", "fees": [], "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:32:15.000Z", "signature_requested_at": null, "signed_at": null, "submitted_at": "2026-05-18T15:31:50.000Z", "confirmed_at": "2026-05-18T15:32:00.000Z", "completed_at": "2026-05-18T15:32:15.000Z", "failed_at": null } } } ``` ## 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](/overviews/funds-movement-lifecycle-and-data-model#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: "automatically_approved"` (or `awaiting_decision` if your organization's compliance policy requires manual review). See [Failure Modes](#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](#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's `estimated.from.account_id`). When the buffer is applied, step 5's `actual.from.amount` will exceed its `estimated.from.amount` by the buffer amount drawn. :::warning{title="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. ```json title="Webhook — step.signature_requested (step 5)" { "id": "evt_01HGZ8SQK7P4R2N6T9M3D5C8B7", "type": "step.signature_requested", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440105", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 5, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "10010.005", "currency": "USDC", "network": "POLYGON" }, "to": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "10010.005", "currency": "USDC", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": "0x02ed81893a85...3a764000080c0", "fees": [ { "fee_amount": "5.0075", "fee_currency": "USDC", "fee_type": "tesser_fee", "fee_metadata": { "fee_amount_mxn": "95.0475", "fx_rate": "18.98109" } }, { "fee_amount": "0.002027", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:32:20.000Z", "signature_requested_at": "2026-05-18T15:32:20.000Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } } } ``` Your organization constructs the signed transaction and submits it. See [Sign a Wallet Step](/how-tos/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[]`: ```json title="Tesser fee — step 5 fees[] entry" { "fee_amount": "5.0075", "fee_currency": "USDC", "fee_type": "tesser_fee" } ``` 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. ```text title="Lifecycle" created → submitted → confirmed → completed ↘ failed ``` 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](/overviews/funds-movement-lifecycle-and-data-model#example-outbound-payment) 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 — the `transfers-9201` code (`An upstream step failed, so this step was not executed`) — 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](/overviews/funds-movement-lifecycle-and-data-model) 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: every step that had not yet completed (steps 1–6) transitions to `failed` carrying the `transfers-9204` reason (`The transfer expired before it could be executed`) — expiry fails all pending steps uniformly rather than cascading from a single failed step. 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 `failed` carrying the `transfers-9204` reason (`The transfer expired before it could be executed`). - Step 6 also transitions to `failed` carrying the same `transfers-9204` reason (expiry fails both pending steps uniformly). - 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.expired` event; the expired state is reflected in the payment's terminal `actual` overlay. ### 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: ```json title="Step 6 status_reasons on CPN rejection" [ { "error_code": "transfers-9302", "error_message": "The provider payout failed (provider_code: cpn_beneficiary_rejected)" } ] ``` 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
--- ## Document: Resources AI-friendly resources and authentication for the Tesser API URL: /agentic/resources # Resources The Tesser API is designed for both human developers and AI agents. All endpoints are accessible via standard REST calls and via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), allowing AI coding assistants to discover and invoke Tesser operations as tools. ## AI-Friendly Resources | Resource | URL | | --- | --- | | **llms.txt** | [https://docs.tesser.xyz/llms.txt](https://docs.tesser.xyz/llms.txt) | | **Full docs for LLMs** | [https://docs.tesser.xyz/llms-full.txt](https://docs.tesser.xyz/llms-full.txt) | | **OpenAPI Schema** | [https://docs.tesser.xyz/api/v1/schema.json](https://docs.tesser.xyz/api/v1/schema.json) | | **MCP Endpoint** | `https://sandbox.tesserx.co/v1/mcp` | | **OAuth Resource Metadata** | `https://sandbox.tesserx.co/.well-known/oauth-protected-resource` | ## Authentication Both REST and MCP use the same Auth0 OAuth 2.0 credentials. You need a `TESSER_API_KEY` and `TESSER_API_SECRET` (issued as Auth0 client credentials). MCP clients that support OAuth discovery can read the resource-metadata endpoint above to locate our Auth0 authorization server and authenticate automatically — see [MCP Integration](/agentic/mcp-integration#oauth-discovery). Otherwise, obtain a bearer token manually as below. ### Obtain a Token ```bash curl --request POST \ --url https://dev-awqy75wdabpsnsvu.us.auth0.com/oauth/token \ --header 'content-type: application/json' \ --data '{ "client_id": "'$TESSER_API_KEY'", "client_secret": "'$TESSER_API_SECRET'", "audience": "https://sandbox.tesserx.co", "grant_type": "client_credentials" }' ``` The response contains an `access_token` to use as a Bearer token in all subsequent requests. ### Environment Setup Create a `.env` file in your project root: ```bash TESSER_API_KEY=your-api-key TESSER_API_SECRET=your-api-secret ``` --- ## Document: Provider Onboarding Connect a liquidity provider to Tesser with the open-source Tesser agent skill. URL: /agentic/provider-onboarding # Provider Onboarding Connecting a liquidity provider to Tesser is a semi-automatic flow: you complete a few steps in the provider's own dashboard, and a sequence of Tesser API calls registers your credentials and accounts. Tesser ships this onboarding as an **open-source agent skill** rather than a static how-to, so an AI coding agent can walk you through the manual and API steps interactively. The skill lives at **[github.com/tesser-payments/skills](https://github.com/tesser-payments/skills)** and works in both **Claude Code** and **Codex** (it's harness-agnostic; any agent that can run a shell and read files can use it). ## OpenFX The first available skill, `setup-openfx`, connects your **OpenFX** liquidity-provider account to Tesser end-to-end: - Authenticates and locates your workspace. - Stores your OpenFX API credential bundle (a generate-only call in production, so your private key never leaves your hands). - Registers your funding bank account and destination wallet(s) with both Tesser and OpenFX. - Coordinates the deposit VAN (Tesser-assisted, seeded by Tesser staff). - Verifies a deposit (fiat→fiat, then fiat→USDC on-ramp). It covers both **sandbox/staging** and **production**, asks which environment you're targeting up front, and confirms before any production write. ### Install and Run Paste this into Claude Code or Codex: ```text Install the Tesser setup-openfx skill: clone https://github.com/tesser-payments/skills into ~/.tesser-skills, then follow the "Install" section of ~/.tesser-skills/AGENTS.md for whichever agent you are. Tell me to restart when it's done. ``` In Claude Code you can alternatively use the native plugin marketplace: ```text /plugin marketplace add tesser-payments/skills /plugin install tesser-skills@tesser-skills ``` After restarting, ask your agent to **"set up OpenFX on Tesser"** (in Codex you can also use `/setup-openfx`). You'll need your Tesser workspace API key and secret available first; see the repo's [Prerequisites](https://github.com/tesser-payments/skills#prerequisites). Once onboarding is complete, see [Deposit Funds via a Liquidity Provider](/how-tos/manage-your-treasury/deposit-funds-via-a-liquidity-provider). :::note Everything operational in the skill is plain `curl` + `jq`. Credentials are parsed as data (never executed), endpoints are pinned to a fixed allowlist, and downloaded credential files are gitignored. See the repo's [Security](https://github.com/tesser-payments/skills#security) section for details. ::: ## Coming Soon Skill support for additional providers is in progress: - **Circle Mint** - **Circle Payments Network** - **Notabene** - **Kraken** Until these land, use the [Liquidity Providers](/overviews/liquidity-providers) overview for provider concepts and reach out to Tesser for onboarding help with providers not yet covered by the skill. --- ## Document: MCP Integration Connect AI coding assistants to Tesser via Model Context Protocol URL: /agentic/mcp-integration # MCP Integration The Tesser MCP server exposes every public API endpoint as a tool that AI agents can discover and invoke. It uses **Streamable HTTP** transport at: ``` https://sandbox.tesserx.co/v1/mcp ``` Authentication uses the same Auth0 credentials as the REST API, in one of two ways: - **OAuth discovery (recommended)** — the server advertises OAuth metadata, so clients that support the [MCP authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) (e.g. Cursor) run the OAuth flow and refresh tokens for you. See [OAuth discovery](#oauth-discovery). - **Manual bearer token** — a fallback for clients without OAuth discovery (e.g. Claude Code today). You fetch a token and set it as a header. See [Manual token](#manual-token). ## OAuth discovery The MCP endpoint serves [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) metadata at `/.well-known/oauth-protected-resource`, pointing clients at our Auth0 server. A `401` also returns a `WWW-Authenticate: resource_metadata` hint, so a compliant client discovers everything with no extra config. ### Cursor — `.cursor/mcp.json` ```jsonc { "mcpServers": { "tesser-payments": { "url": "https://sandbox.tesserx.co/v1/mcp", "auth": { "CLIENT_ID": "${env:TESSER_API_KEY}", "CLIENT_SECRET": "${env:TESSER_API_SECRET}" } } } } ``` Cursor runs the OAuth flow on first use and refreshes tokens automatically. ## Manual token For clients without OAuth discovery, fetch a token and pass it as a header. Set it in the shell before launching your IDE: ```bash export MCP_TOKEN=$(bash scripts/mcp-token.sh) ``` ### Claude Code — `.claude/settings.local.json` ```jsonc { "mcpServers": { "tesser-payments": { "type": "url", "url": "https://sandbox.tesserx.co/v1/mcp", "headers": { "Authorization": "Bearer ${MCP_TOKEN}" } } } } ``` Run `/mcp` in Claude Code to reload. ### Cursor — `.cursor/mcp.json` ```jsonc { "mcpServers": { "tesser-payments": { "type": "url", "url": "https://sandbox.tesserx.co/v1/mcp", "headers": { "Authorization": "Bearer ${MCP_TOKEN}" } } } } ``` Restart Cursor. (Prefer [OAuth discovery](#oauth-discovery), which refreshes tokens for you.) ### Windsurf — `.windsurf/mcp.json` ```jsonc { "mcpServers": { "tesser-payments": { "type": "url", "url": "https://sandbox.tesserx.co/v1/mcp", "headers": { "Authorization": "Bearer ${MCP_TOKEN}" } } } } ``` Restart Windsurf. ### Token refresh Manual tokens expire after ~24h; calls then fail with `401`. Re-run the `export` above and restart your IDE (or `/mcp` in Claude Code). See [Manual MCP Setup](/agentic/manual-mcp) for details, or use [OAuth discovery](#oauth-discovery) to avoid refreshing. --- ## Document: Manual MCP Setup Connect to the Tesser MCP server using a manually fetched token URL: /agentic/manual-mcp # Manual MCP Setup Use this approach when `mcp-remote` is not available — for example in headless environments, CI pipelines, or custom agent frameworks. ## Fetch a Token ```bash export MCP_TOKEN=$(curl -s --request POST \ --url https://dev-awqy75wdabpsnsvu.us.auth0.com/oauth/token \ --header 'content-type: application/json' \ --data '{ "client_id": "'$TESSER_API_KEY'", "client_secret": "'$TESSER_API_SECRET'", "audience": "https://sandbox.tesserx.co", "grant_type": "client_credentials" }' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") ``` This requires `TESSER_API_KEY` and `TESSER_API_SECRET` set in your environment or `.env` file. ## IDE Configuration Configure your IDE with a static token. This works with any IDE that supports MCP URL-type servers: ```jsonc { "mcpServers": { "tesser-payments": { "type": "url", "url": "https://sandbox.tesserx.co/v1/mcp", "headers": { "Authorization": "Bearer $MCP_TOKEN" } } } } ``` Set `MCP_TOKEN` in your shell **before** launching the IDE. ## Token Helper Script The Tesser repo includes a helper script that reads credentials from `.env` and outputs a fresh token: ```bash export MCP_TOKEN=$(bash scripts/mcp-token.sh) ``` ## Token Expiry Tokens expire after approximately 24 hours. When a token expires, MCP tool calls will fail with a 401 error. You will need to re-run the token fetch command and restart your IDE session. For automatic token refresh, use [mcp-remote](/agentic/mcp-integration) instead. --- ## Document: AI Context File A ready-to-paste context file that gives AI assistants Tesser API knowledge URL: /agentic/ai-context # AI Context File Copy the markdown below into a `CLAUDE.md`, `.cursorrules`, or similar AI context file in your project. This gives your AI coding assistant the knowledge it needs to work with the Tesser API without re-reading the full documentation each session. ## Usage 1. Copy the content below 2. Save it as `CLAUDE.md` (for Claude Code), `.cursorrules` (for Cursor), or any context file your tool supports 3. Place it in your project root ## Context File ````markdown # Tesser Payments API ## Overview Tesser is a payments platform for stablecoin and fiat transfers. The API handles payment creation, wallet management, counterparty onboarding, compliance screening, and treasury operations. ## API Access - **Base URL**: `https://sandbox.tesserx.co/v1` - **Auth**: OAuth 2.0 client credentials → Bearer token - **Docs**: https://docs.tesser.xyz - **LLM docs**: https://docs.tesser.xyz/llms-full.txt - **OpenAPI Schema**: https://docs.tesser.xyz/api/v1/schema.json ### Authentication ```bash curl --request POST \ --url https://dev-awqy75wdabpsnsvu.us.auth0.com/oauth/token \ --header 'content-type: application/json' \ --data '{ "client_id": "'$TESSER_API_KEY'", "client_secret": "'$TESSER_API_SECRET'", "audience": "https://sandbox.tesserx.co", "grant_type": "client_credentials" }' ``` ### Environment Variables ``` TESSER_API_KEY=your-api-key TESSER_API_SECRET=your-api-secret ``` ## MCP Server All API operations are available as MCP tools at `https://sandbox.tesserx.co/v1/mcp`. ## Key Endpoints | Method | Path | Description | | --- | --- | --- | | POST | `/v1/payments` | Create a payment (payout, deposit) | | GET | `/v1/payments/{id}` | Get payment by ID | | PATCH | `/v1/payments/{id}` | Update payment with account info | | POST | `/v1/payments/{id}/review` | Submit risk review decision | | GET | `/v1/accounts` | List accounts (wallets, ledgers, bank accounts) | | POST | `/v1/accounts/wallets` | Create a wallet account | | POST | `/v1/accounts/ledgers` | Create a ledger account | | POST | `/v1/accounts/banks` | Create a bank account | | GET | `/v1/entities/counterparties` | List counterparties | | POST | `/v1/entities/counterparties` | Create a counterparty | | GET | `/v1/currencies` | List supported currencies | | GET | `/v1/networks` | List supported blockchain networks | ## API Conventions - All request/response fields use **snake_case** - Amounts are strings (e.g. `"1000.50"`) - IDs are UUIDs - Timestamps are ISO 8601 (e.g. `"2025-12-01T09:00:00.000Z"`) - Webhook events follow the pattern `resource.action` (e.g. `payment.balance_updated`, `step.confirmed`) ## Domain Glossary Language that is easy to confuse. This is a glossary, not a spec. ### Funds Movement - **Payment** — A funds-movement resource with a `direction` (`inbound`, `outbound`, `rebalance`) and a `payment_type` (`deposit`, `withdrawal`, `transfer`). Carries `desired` / `estimated` / `actual` overlays and one or more steps. - **Inbound Payment** — A payment with `direction = "inbound"` recording funds sent on-chain from an external wallet to one of your **Managed Wallets**. Detected by monitoring the chain, not initiated through the API. Distinct from a **Deposit**. - **Deposit** — A customer-initiated inbound funds movement (`POST /v1/treasury/deposits`) that routes fiat or stablecoin through a provider to a destination account. Not the same as an **Inbound Payment**. - **Step** — One leg of a payment's execution (`transfer`, `swap`). Has a lifecycle status and `estimated` / `actual` overlays. ### Inbound Lifecycle - **Confirmed** — Step state when the on-chain transaction is included in a block. Fires `payment.created`. Funds are visible but **not yet final** — the block can still be reorganized. - **Completed** — Step state when the block has finalized. Fires `step.completed` + `payment.updated`. Funds are safe to spend. Avoid using "confirmed" and "completed" interchangeably — for inbound they are two distinct moments. ### Outbound Lifecycle (wallet source) - **Signature Requested** — The unsigned transaction is prepared and Tesser is waiting for the customer to sign. Fires `step.signature_requested`. - **Signed** — The signature was accepted and the source balance is reserved. The step already carries `transaction_hash`: it is derived from the signed payload, so it is known before broadcast. A hash at this stage is a correlation key, not evidence that the transaction reached the network. A nonce-queued step stays here until its turn to broadcast. Fires `step.signed` followed by `payment.balance_updated` (`reserved`). - **Submitted** — Event-only marker that broadcast is beginning. - **Confirmed (outbound)** — The broadcast was accepted by the network (mempool). Carries the transaction hash. **Not final.** Unlike inbound, this means broadcast acceptance, not block inclusion. - **Completed (outbound)** — The transfer reached finality. `step.completed` fires, followed by the terminal `payment.updated`. That ordering is contractual: detect terminal state from the pair. ### Balances - **Total Balance** — All funds credited to an account asset, including inbound funds that have arrived but not yet finalized. For an inbound payment, credited at **Confirmed**. - **Available Balance** — Funds free to spend (total minus reserved). For an inbound payment, credited once the step is **Completed** (block finality). Inbound risk screening does not gate it. Invariant: available ≤ total. ### Risk & Screening - **Risk Screening** — Evaluation of a payment's external counterparty wallet against the workspace's risk policy. For an inbound payment the screened party is the **sender** (the Unmanaged Account); for an outbound payment it is the recipient. The outcome is a `risk_status` and drives the `payment.risk_updated` webhook. **Advisory for inbound** (does not gate Available Balance — funds are already custodied), **gating for outbound** (fail-closed). - **Risk Decision** — A manual approval/rejection submitted when screening returns `awaiting_decision`. Recorded as `manually_approved` / `manually_rejected` with reviewer and timestamp. ### Accounts & Counterparties - **Managed Wallet** (a.k.a. Managed Account, `is_managed: true`) — A wallet Tesser custodies and can sign for. Inbound funds are credited to one. - **Unmanaged Account** (`is_managed: false`) — An external wallet Tesser does not control. Auto-created to represent the **sender** of an inbound payment, keyed by its on-chain address. - **Placeholder Counterparty** — The single shared counterparty that all inbound senders are attached to, because the owner of a sending wallet cannot be identified. One per tenant. ### Relationships - An inbound payment has exactly one step (type `transfer`), born **Confirmed**, transitioning to **Completed** at finality. - An inbound payment's sender is an **Unmanaged Account** attached to the **Placeholder Counterparty**; its recipient is a **Managed Wallet**. - **Confirmed** credits **Total Balance**; **Completed** (finality) credits **Available Balance**. Inbound risk screening is advisory and does not gate this credit. ```` --- ## Document: Request Supported Currencies and Networks Learn which currencies and payment networks are available URL: /how-tos/send-a-stablecoin-payout/supported-currencies-and-networks # Request Supported Currencies and Networks You can use the API to learn which currencies and payment networks are available. ### **Request available blockchain networks** Call the [available networks](/api/networks#get-available-networks) endpoint to retrieve list of all supported blockchain networks. Returns an array with network key and display name. **Shell** ```bash curl -H "Authorization: Bearer ${YOUR_API_KEY}" \ -X GET https://api.tesser.xyz/v1/networks ``` **Response excerpt (production)** **JSON** ```json { "data": [ { "key": "ETHEREUM", "name": "Ethereum" }, { "key": "POLYGON", "name": "Polygon" }, { "key": "STELLAR", "name": "Stellar" }, { "key": "BASE", "name": "Base" } ] } ``` ### **Request available currencies and networks** Call the [**available currencies**](/api/currencies#get-available-currencies) endpoint to receive supported currency definitions. Each entry includes `name`, `key`, `decimals`, `network`, and `provider`. Match the full `(key, network, provider)` combination when looking up precision. Provider balances have `network: null`, including stablecoins held off-chain. Network currencies and default fiat definitions have `provider: null`. ```bash curl -H "Authorization: Bearer ${YOUR_API_KEY}" \ -X GET https://api.tesser.xyz/v1/currencies ``` **Response excerpt (production)** ```json { "data": [ { "name": "USD", "key": "USD", "decimals": 2, "network": null, "provider": null }, { "name": "USD", "key": "USD", "decimals": 2, "network": null, "provider": "openfx" }, { "name": "Circle USD", "key": "USDC", "decimals": 6, "network": null, "provider": "circle_mint" }, { "name": "Circle USD", "key": "USDC", "decimals": 6, "network": "ETHEREUM", "provider": null }, { "name": "Circle USD", "key": "USDC", "decimals": 7, "network": "STELLAR", "provider": null }, { "name": "Circle USD", "key": "USDC", "decimals": 6, "network": "BASE", "provider": null } ] } ``` The network definitions depend on the environment. Ledger account responses use uppercase providers such as `OPENFX`; currency definitions use lowercase values such as `openfx`. See [Currencies](/overviews/currencies) for matching rules and how to handle decimal amount strings in balances, transfers, and webhooks. --- ## Document: Create a Payout (via Custodian) Create and execute payouts using third-party custodian accounts URL: /how-tos/send-a-stablecoin-payout/create-a-stablecoin-payout # Create a Payout (via Custodian) 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](/overviews/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](/api/payments#create-payment). :::note 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](/api/payments#update-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](#update-payout-with-account-information). ::: :::warning{title="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](/overviews/funds-movement-lifecycle-and-data-model#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](#update-payout-with-account-information). Example request for stablecoin payout creation: ```json { "desired": { "from": { "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "currency": "USDC", "network": "ETHEREUM" } }, "organization_reference_id": "ref_123" } ``` Example request for fiat payout creation: ```json { "desired": { "from": { "currency": "USDC", "network": "ETHEREUM" }, "to": { "amount": "1000", "currency": "MXN", "network": null } }, "organization_reference_id": "ref_123" } ``` :::note 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. Example response for stablecoin payout creation: ```json { "data": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } ``` Example response for fiat payout creation: ```json { "data": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } ``` ## Quote and planning 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](/overviews/funds-movement-lifecycle-and-data-model#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. :::note 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): ```json { "id": "8472fb87-73b3-45ee-8020-a3496b4fc7a1", "type": "payment.quote_created", "created_at": "2025-12-01T09:00:00.045Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.040Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.040Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` Example `payment.quote_created` webhook for a fiat payout (cross-currency, two steps; step 1 is on-chain Circle → OpenFX USDC ledger, step 2 is the OpenFX fiat off-ramp): ```json { "id": "8472fb87-73b3-45ee-8020-a3496b4fc7a1", "type": "payment.quote_created", "created_at": "2025-12-01T09:00:00.045Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": null, "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.040Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": null, "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.040Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.040Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` ## Update payout with account information If you did not supply account information at creation, submit a PATCH request to the [Payments API](/api/payments#update-payment) 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, owned by a counterparty or tenant. :::note 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. It must reference an account owned by a counterparty or tenant: Tesser rejects a create or update request whose `funding_account_id` belongs to the workspace with a `400` error, [`payments-3099`](/overviews/errors#payments). If your organization originates its own payouts, [register your organization as a counterparty](/how-tos/create-a-counterparty) and use that counterparty's bank account as the `funding_account_id`. See [Travel Rule](/overviews/compliance-and-risk#travel-rule). ::: Example PATCH request for stablecoin payout updated with accounts: ```json { "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012" } } } ``` Example PATCH request for fiat payout updated with accounts: ```json { "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047" } } } ``` 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](#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: ```json { "id": "1c9a4f6e-8b3d-4f23-a056-9d7c4b2e1f08", "type": "payment.updated", "created_at": "2025-12-01T09:00:00.205Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.200Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` Example `payment.updated` webhook after PATCHing accounts on a fiat payout: ```json { "id": "1c9a4f6e-8b3d-4f23-a056-9d7c4b2e1f08", "type": "payment.updated", "created_at": "2025-12-01T09:00:00.205Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.200Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` ## Payout risk review 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](/overviews/funds-movement-lifecycle-and-data-model#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](/api/payments#submit-risk-review) 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`. :::tip{title="Test each risk outcome"} In sandbox you can trigger `automatically_approved`, `awaiting_decision`, or `automatically_rejected` on a payout by sending it to a known test beneficiary address. See [Test Compliance Risk Checks](/how-tos/test-compliance-risk-checks#test-outbound-risk-checks). ::: ## 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](/overviews/funds-movement-lifecycle-and-data-model#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](#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: ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "payment.balance_updated", "created_at": "2025-12-01T09:00:01.002Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:01.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` Example `payment.balance_updated` webhook for a fiat payout after a successful balance check: ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "payment.balance_updated", "created_at": "2025-12-01T09:00:01.002Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:01.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` ## On-chain processing 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](/overviews/funds-movement-lifecycle-and-data-model#execution)). The blockchain `transaction_hash` is populated on the step once submitted, and gas fees appear in the per-step `fees[]` array once confirmed. :::note 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: ```json { "id": "d7e80c94-d7b3-4e9b-8c00-065e7cbcfac8", "type": "step.confirmed", "created_at": "2025-12-01T09:00:01.802Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": "0xd65dc6bf6dcc111237f9acfbfa6003ea4a4d88f2e071f4307d3af81ae877f7be", "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "circle_mint", "status": "confirmed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:01.800Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": null, "failed_at": null } } } ``` 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. Example `step.confirmed` webhook for the on-chain step of a fiat payout (Circle ledger → OpenFX USDC ledger): ```json { "id": "d7e80c94-d7b3-4e9b-8c00-065e7cbcfac8", "type": "step.confirmed", "created_at": "2025-12-01T09:00:01.802Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": "0x2b3c4d5e6f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c", "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "circle_mint", "status": "confirmed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:01.800Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": null, "failed_at": null } } } ``` ## 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: "openfx"`) and progresses through the same `created → submitted → confirmed → completed` path. See [Step Statuses](/overviews/funds-movement-lifecycle-and-data-model#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](/overviews/funds-movement-lifecycle-and-data-model#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 **second** `payment.updated` event you receive — the first fired after the PATCH supplied account ids (see [Update payout with account information](#update-payout-with-account-information)). Example terminal-state `payment.updated` webhook for a successful stablecoin payout: ```json { "id": "f4c8e1a3-9b76-4d2e-a058-3c5f9e1d4b27", "type": "payment.updated", "created_at": "2025-12-01T09:00:02.502Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "transaction_hash": "0xd65dc6bf6dcc111237f9acfbfa6003ea4a4d88f2e071f4307d3af81ae877f7be", "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:02.500Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": "2025-12-01T09:00:02.500Z", "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:02.500Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` Example terminal-state `payment.updated` webhook for a successful fiat payout: ```json { "id": "f4c8e1a3-9b76-4d2e-a058-3c5f9e1d4b27", "type": "payment.updated", "created_at": "2025-12-01T09:00:02.502Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "transaction_hash": "0x2b3c4d5e6f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c", "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:02.000Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": "2025-12-01T09:00:02.000Z", "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "transaction_hash": null, "fees": [ { "fee_amount": "1.50", "fee_currency": "USDC", "fee_type": "provider", "fee_metadata": {} } ], "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:02.500Z", "submitted_at": "2025-12-01T09:00:02.100Z", "confirmed_at": "2025-12-01T09:00:02.300Z", "completed_at": "2025-12-01T09:00:02.500Z", "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:02.500Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` ## Failure Modes for Custodian Payouts 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](/overviews/funds-movement-lifecycle-and-data-model#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.** - If the payout `expires_at` passes before funds arrive, see [Expiration before completion](#expiration-before-completion). Example `payment.balance_updated` webhook with `balance_status: "awaiting_funds"`: ```json { "id": "5c1f3e9b-4a6d-47c2-9f08-8d2c7e1b5a04", "type": "payment.balance_updated", "created_at": "2025-12-01T09:00:01.002Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "awaiting_funds", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:01.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` ### Risk rejection If the risk policy auto-rejects the payout (or an operator manually rejects during review), execution halts before the balance check. What you will observe: - A `payment.risk_updated` webhook fires with `risk_status: "automatically_rejected"` or `risk_status: "manually_rejected"`. - `risk_status_reasons` carries the type, category, and severity that drove the decision. - No `payment.balance_updated` event fires; no steps are submitted. - The payout is terminal in this state. To proceed, resolve the underlying risk factors and create a new payout. Example `payment.risk_updated` webhook with `risk_status: "automatically_rejected"`: ```json { "id": "9d3a7e21-b4c8-4f15-a02d-6e8b4c1f9a72", "type": "payment.risk_updated", "created_at": "2025-12-01T09:00:00.602Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_rejected", "risk_status_reasons": [ { "risk_status_reason_type": "COUNTERPARTY", "risk_status_reason_category": "Malware", "risk_status_reason_message": "Address has COUNTERPARTY exposure to Malware", "risk_status_reason_severity": "Severe", "risk_status_reason_participant_id": null, "risk_status_reason_significance": "DECISIVE", "volume_usd_percent": "41.83", "volume_usd_percent_threshold": "2", "volume_usd_amount": "4521.00", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [ { "category": "Malware", "categoryId": "57", "categoryRiskScoreLevel": 15, "categoryRiskScoreLevelLabel": "Severe", "incomingInstanceCount": null, "incomingVolumeUsd": null, "outgoingInstanceCount": "3", "outgoingVolumeUsd": "4521.00", "riskType": "COUNTERPARTY", "totalInstanceCount": "3", "totalVolumeUsd": "4521.00", "incomingVolumePercent": null, "outgoingVolumePercent": "41.83", "totalVolumePercent": "41.83" } ] } } ], "risk_reviewed_by": null, "risk_reviewed_at": "2025-12-01T09:00:00.600Z", "balance_status": "unreserved", "balance_reserved_at": null, "steps": [], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.600Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` ### Step failure 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[]`. - Top-level `actual.*` may diverge from `desired.*` / `estimated.*` — see [Terminal State and Divergence](/overviews/funds-movement-lifecycle-and-data-model#terminal-state-and-divergence). - Subsequent steps in the route do not execute. Example `step.failed` webhook for the on-chain step of a custodian payout: ```json { "id": "ab7c2e91-3d4f-46b8-9c05-1f2e8a4b7c93", "type": "step.failed", "created_at": "2025-12-01T09:00:01.802Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "failed", "status_reasons": [ { "error_code": "transfers-9302", "error_message": "The provider payout failed" } ], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:01.800Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T09:00:01.800Z" } } } ``` ### Expiration before completion 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](/overviews/funds-movement-lifecycle-and-data-model#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`: ```json { "id": "e8a4b3c1-7d92-4f06-a1e5-3b8c2f1d4e07", "type": "payment.updated", "created_at": "2025-12-01T23:59:59.999Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:01.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T23:59:59.999Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ``` --- ## Document: Create a Payout (from a Wallet) Create and execute payouts using self-custodial wallets URL: /how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet # Create a Payout (from a Wallet) 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](/overviews/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=` 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](/overviews/funds-movement-lifecycle-and-data-model#execution)); follow the emitted `step.*` events for actual sequencing. >Tesser: POST /v1/payments — desired currencies, amount, networks Tesser-->>You: 200 — payment id, desired.* echoed Tesser-->>You: webhook: type=payment.quote_created — estimated.* populated,
steps[] partially populated opt Note over You,Tesser: Two-step flow: Update the payment with account ids if omitted at creation You->>Tesser: PATCH /v1/payments/{id} — funding_account_id, desired.from/to.account_id Tesser-->>You: webhook: type=payment.updated — all desired.* fields echoed,
steps[] fully populated end Note over Tesser: Screens beneficiary wallet against your risk policy Tesser-->>You: webhook: type=payment.risk_updated, risk_status=automatically_approved Tesser-->>You: webhook: type=step.signature_requested — unsigned transaction payload Note over You: Sign before expires_at. Wallet address can be obtained
from your database or cache, or by calling
GET /v1/accounts/{estimated.from.account_id} You->>Signer: signStep — unsigned_transaction, wallet address, network Signer-->>You: signature You->>Tesser: POST /v1/payments/{id}/steps/{stepId}/sign Note over Tesser: desired.from.account_id balance checked synchronously
as part of signing execution Tesser-->>You: 200 — signature accepted Tesser-->>You: webhook: type=payment.balance_updated, balance_status=reserved Tesser-->>You: webhook: type=step.signed Tesser->>Chain: Broadcast signed transaction — gas sponsored by Tesser Tesser-->>You: webhook: type=step.submitted — indicates timestamp
when Tesser broadcasted transaction on chain Chain-->>Tesser: Accepted into mempool Tesser-->>You: webhook: type=step.confirmed — transaction_hash and fees[] populated Chain-->>Tesser: Transaction reaches finality on-chain Tesser-->>You: webhook: type=step.completed — step actual.* fields populated Tesser-->>You: webhook: type=payment.updated — terminal, top-level actual.* fields populated `} />
>Tesser: POST /v1/payments — from USDC on ETHEREUM, to MXN, to.network null Tesser-->>You: 200 — payment id, desired.* echoed Tesser-->>You: webhook: type=payment.quote_created — two steps planned Note over Tesser: estimated.from 55.85 USDC to estimated.to 1000 MXN
(ratio is the indicative FX rate) opt Note over You,Tesser: Two-step flow: Update the payment with account ids if omitted at creation You->>Tesser: PATCH /v1/payments/{id} — funding_account_id, desired.from/to.account_id Tesser-->>You: webhook: type=payment.updated — complete desired.*, re-planned steps[] end Tesser->>Provider: Request the deposit address for the off-ramp transaction Provider-->>Tesser: Respond with on-chain deposit wallet address
(where to send the USDC) Note over Tesser: Screens that provider deposit address against your risk policy Tesser-->>You: webhook: type=payment.risk_updated, risk_status=automatically_approved Note over You,Provider: Step 1 — the on-chain transfer to that provider deposit address
runs exactly as the Stablecoin flow above, through step.completed for step 1 rect rgba(127,127,127,0.07) Note over You,Provider: Step 2 — provider delivers fiat to the beneficiary Tesser-->>You: webhook: type=step.submitted Provider-->>Tesser: Confirm on-chain deposit received Tesser-->>You: webhook: type=step.confirmed Provider-->>Tesser: Confirmation that payout has completed to beneficiary Tesser-->>You: webhook: type=step.completed — actual.* fields populated end Tesser-->>You: webhook: type=payment.updated — terminal, top-level actual.* fields populated `} />
## Payout Creation To create a payout, send a POST request to the [Payments API](/api/payments#create-payment). 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](#update-the-payout-with-account-information). Example request for stablecoin payout creation: ```json { "desired": { "from": { "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "currency": "USDC", "network": "ETHEREUM" } }, "organization_reference_id": "ref_123" } ``` Example request for fiat payout creation: ```json { "desired": { "from": { "currency": "USDC", "network": "ETHEREUM" }, "to": { "amount": "1000", "currency": "MXN", "network": null } }, "organization_reference_id": "ref_123" } ``` :::note 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.
Example response for stablecoin payout creation ```json { "data": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } ```
Example response for fiat payout creation ```json { "data": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } ```
## Payout Quote Created (`payment.quote_created`) 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](/overviews/funds-movement-lifecycle-and-data-model#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. :::note 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) ```json { "id": "8472fb87-73b3-45ee-8020-a3496b4fc7a1", "type": "payment.quote_created", "created_at": "2025-12-01T09:00:00.045Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.040Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.040Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
Example `payment.quote_created` webhook for a fiat payout (cross-currency, two steps) ```json { "id": "8472fb87-73b3-45ee-8020-a3496b4fc7a1", "type": "payment.quote_created", "created_at": "2025-12-01T09:00:00.045Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": null, "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": null, "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.040Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": null, "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "provider", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.040Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.040Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
## Update the Payout with Account Information If you did not supply account information at creation, submit a PATCH request to the [Payments API](/api/payments#update-payment) 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, owned by a counterparty or tenant. :::note 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. It must reference an account owned by a counterparty or tenant: Tesser rejects a create or update request whose `funding_account_id` belongs to the workspace with a `400` error, [`payments-3099`](/overviews/errors#payments). If your organization originates its own payouts, [register your organization as a counterparty](/how-tos/create-a-counterparty) and use that counterparty's bank account as the `funding_account_id`. See [Travel Rule](/overviews/compliance-and-risk#travel-rule). ::: Example PATCH request for stablecoin payout updated with accounts: ```json { "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012" } } } ``` Example PATCH request for fiat payout updated with accounts: ```json { "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047" } } } ``` 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](#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 ```json { "id": "1c9a4f6e-8b3d-4f23-a056-9d7c4b2e1f08", "type": "payment.updated", "created_at": "2025-12-01T09:00:00.205Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.200Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
Example `payment.updated` webhook after PATCHing accounts on a fiat payout ```json { "id": "1c9a4f6e-8b3d-4f23-a056-9d7c4b2e1f08", "type": "payment.updated", "created_at": "2025-12-01T09:00:00.205Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "provider", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.200Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
## Payout Risk Review (`payment.risk_updated`) 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](/overviews/funds-movement-lifecycle-and-data-model#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](/api/payments#submit-risk-review) 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`. against your risk policy Tesser-->>You: webhook: type=payment.risk_updated, risk_status=automatically_approved Note over You,Tesser: Flow continues — step.signature_requested follows `} /> against your risk policy Tesser-->>You: webhook: type=payment.risk_updated, risk_status=awaiting_decision —
risk_status_reasons[] populated You->>Tesser: Submit decision — risk review via API or Tesser dashboard Tesser-->>You: webhook: type=payment.risk_updated, risk_status=manually_approved —
risk_reviewed_by and risk_reviewed_at set Note over You,Tesser: Flow continues — step.signature_requested follows `} />
against your risk policy Tesser-->>You: webhook: type=payment.risk_updated, risk_status=awaiting_decision —
risk_status_reasons[] populated You->>Tesser: Submit decision — risk review via API or Tesser dashboard Tesser-->>You: webhook: type=payment.risk_updated, risk_status=manually_rejected Tesser-->>You: webhook: type=step.failed — each step goes to failed,
step actual.* fields null Tesser-->>You: webhook: type=payment.updated — terminal,
top-level actual.* fields all null Note over You: Terminal — resolve the risk factors and create a new payout `} />
against your risk policy Tesser-->>You: webhook: type=payment.risk_updated, risk_status=automatically_rejected —
risk_status_reasons[] populated Tesser-->>You: webhook: type=step.failed — each step goes to failed,
step actual.* fields null Tesser-->>You: webhook: type=payment.updated — terminal,
top-level actual.* fields all null Note over You: Terminal — resolve the risk factors and create a new payout `} />
## 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. :::warning{title="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](/overviews/funds-movement-lifecycle-and-data-model#expiration) for details. :::
Example webhook schema for stablecoin payout requesting signature ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "step.signature_requested", "created_at": "2025-12-01T09:00:00.802Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": "0x02ed81893a85 ... 3a764000080c0", "fees": [], "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:00.800Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } } } ```
Example webhook schema for fiat payout requesting signature ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "step.signature_requested", "created_at": "2025-12-01T09:00:00.802Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": "0x02ed81893a85 ... 3a764000080c0", "fees": [], "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:00.800Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } } } ```
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](/api/payments#sign-payment-step) to execute the payment. See [Sign a Wallet Step](/how-tos/sign-a-wallet-step#sign-the-step-with-the-localsigner-sdk) for install and the full SDK reference. ```typescript 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 { data: 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 }), } ); ``` ```kotlin import kotlinx.coroutines.runBlocking import xyz.tesser.sdk.LocalSigner import xyz.tesser.sdk.SigningConfig import xyz.tesser.sdk.StepForSigning val signer = LocalSigner( SigningConfig( publicKey = System.getenv("SIGNING_PUBLIC_KEY"), privateKey = System.getenv("SIGNING_PRIVATE_KEY"), enclaveId = System.getenv("SIGNING_ENCLAVE_ID"), ), ) runBlocking { // `step` is the step.signature_requested webhook's data.object (parsed). // `fetchAccount` / `submitSignature` wrap your own HTTP client. val signWith = fetchAccount(step.estimated.from.accountId).cryptoWalletAddress val toSign = StepForSigning( id = step.id, transferId = step.paymentId, unsignedTransaction = step.unsignedTransaction, signWith = signWith, network = step.estimated.from.network, ) val signed = signer.signStep(toSign) // POST /v1/payments/{paymentId}/steps/{stepId}/sign submitSignature(toSign.transferId, toSign.id, signed.signature) } ``` `signStep` returns a `signature` to send to the [Sign payment step API](/api/payments#sign-payment-step) 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](/api/payments#sign-payment-step) — the sign endpoint validates that the wallet at `desired.from.account_id` has sufficient funds before accepting your signature. See [Balance Statuses](/overviews/funds-movement-lifecycle-and-data-model#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 ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "payment.balance_updated", "created_at": "2025-12-01T09:00:01.002Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": "0x02ed81893a85 ... 3a764000080c0", "fees": [], "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:00.800Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:01.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
Example webhook schema for fiat payout after successful balance check ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "payment.balance_updated", "created_at": "2025-12-01T09:00:01.002Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": "0x02ed81893a85 ... 3a764000080c0", "fees": [], "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:00.800Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "provider", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:01.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
Example webhook schema for stablecoin payout after signing ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "step.signed", "created_at": "2025-12-01T09:00:01.052Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": "0xd65dc6bf6dcc ... e877f7be", "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "signed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:01.050Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } } } ```
Example webhook schema for fiat payout after signing ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "step.signed", "created_at": "2025-12-01T09:00:01.052Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": "0x2b3c4d5e6f0a ... 3f4a5b6c", "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "signed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:01.050Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } } } ```
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. step.signature_requested received, step signed locally You->>Tesser: POST /v1/payments/{id}/steps/{stepId}/sign Note over Tesser: desired.from.account_id balance checked synchronously
as part of signing execution — insufficient funds Tesser-->>You: 4XX error Tesser-->>You: webhook: type=payment.balance_updated,
balance_status=awaiting_funds, balance_reserved_at=null Note over Tesser: Payout queued until the wallet is funded
or expires_at elapses alt Wallet funded before expires_at Tesser-->>You: webhook: type=step.signature_requested —
republished, fresh signature_requested_at You->>Tesser: Repeat the signing flow with the freshly published step Tesser-->>You: webhook: type=payment.balance_updated, balance_status=reserved Tesser-->>You: webhook: type=step.signed Note over You,Tesser: Flow continues as the happy path else expires_at passes first Tesser-->>You: webhook: type=step.failed Tesser-->>You: webhook: type=payment.updated, balance_status=unreserved —
terminal, top-level actual.* fields all null Note over You: Once expired, payments cannot be resurrected.
Create a new payout to retry. end `} />
Example webhook schema for stablecoin payout after failed balance check ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "payment.balance_updated", "created_at": "2025-12-01T09:00:01.002Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "awaiting_funds", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": "0x02ed81893a85 ... 3a764000080c0", "fees": [], "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:00.800Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:01.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
Example webhook schema for fiat payout after failed balance check ```json { "id": "99ea4da4-2178-4169-9e21-01d3cb4ae158", "type": "payment.balance_updated", "created_at": "2025-12-01T09:00:01.002Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "awaiting_funds", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": "0x02ed81893a85 ... 3a764000080c0", "fees": [], "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:00.800Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "provider", "status": "created", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:00.200Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:01.000Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
## Payout On-Chain Processing 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](/overviews/funds-movement-lifecycle-and-data-model#step-statuses) in the lifecycle overview. The blockchain `transaction_hash` is populated on the step from `signed` onward — it is derived from the signed payload — and gas fees land in the per-step `fees[]` array once confirmed. If the same wallet has an earlier payment awaiting broadcast, this one queues behind it — the sign call returns 200 and the step stays `signed` until its turn, with `submitted_at` still `null`; see [signing a wallet step](/how-tos/sign-a-wallet-step). :::note 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 ```json { "id": "d7e80c94-d7b3-4e9b-8c00-065e7cbcfac8", "type": "step.confirmed", "created_at": "2025-12-01T09:00:01.802Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": "0xd65dc6bf6dcc ... e877f7be", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "confirmed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:01.800Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": null, "failed_at": null } } } ```
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.
Example webhook schema for fiat payout after blockchain network confirmation ```json { "id": "d7e80c94-d7b3-4e9b-8c00-065e7cbcfac8", "type": "step.confirmed", "created_at": "2025-12-01T09:00:01.802Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": "0x2b3c4d5e6f0a ... 3f4a5b6c", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "confirmed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:01.800Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": null, "failed_at": null } } } ```
## 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](#payout-risk-review-paymentrisk_updated)). 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](/overviews/funds-movement-lifecycle-and-data-model#step-statuses). :::note 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](/overviews/funds-movement-lifecycle-and-data-model#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 **second** `payment.updated` event you receive — the first fired after the PATCH supplied account ids (see [Update the payout with account information](#update-the-payout-with-account-information)).
Example terminal-state `payment.updated` webhook for a successful stablecoin payout ```json { "id": "f4c8e1a3-9b76-4d2e-a058-3c5f9e1d4b27", "type": "payment.updated", "created_at": "2025-12-01T09:00:02.502Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "transaction_hash": "0xd65dc6bf6dcc ... e877f7be", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:02.500Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": "2025-12-01T09:00:02.500Z", "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:02.500Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
Example terminal-state `payment.updated` webhook for a successful fiat payout ```json { "id": "f4c8e1a3-9b76-4d2e-a058-3c5f9e1d4b27", "type": "payment.updated", "created_at": "2025-12-01T09:00:02.502Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "transaction_hash": "0x2b3c4d5e6f0a ... 3f4a5b6c", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:02.000Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": "2025-12-01T09:00:02.000Z", "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [ { "fee_amount": "1.50", "fee_currency": "USDC", "fee_type": "provider", "fee_metadata": {} } ], "provider_key": "provider", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:02.500Z", "signature_requested_at": null, "signed_at": null, "submitted_at": "2025-12-01T09:00:02.100Z", "confirmed_at": "2025-12-01T09:00:02.300Z", "completed_at": "2025-12-01T09:00:02.500Z", "failed_at": null } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:02.500Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
## Failure Modes for Wallet Payouts 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](#payout-balance-check-paymentbalance_updated) 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 ```json { "id": "a8b3d2e7-4c91-4f5a-9d28-1e7c3f8a5b04", "type": "payment.updated", "created_at": "2025-12-01T09:00:00.500Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_rejected", "risk_status_reasons": [ { "risk_status_reason_type": "COUNTERPARTY", "risk_status_reason_category": "Sanctions", "risk_status_reason_message": "Address has COUNTERPARTY exposure to Sanctions", "risk_status_reason_severity": "Severe", "risk_status_reason_participant_id": null, "risk_status_reason_significance": "DECISIVE", "volume_usd_percent": "27.44", "volume_usd_percent_threshold": "2", "volume_usd_amount": "15578.90", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [ { "category": "Sanctions", "categoryId": "69", "categoryRiskScoreLevel": 15, "categoryRiskScoreLevelLabel": "Severe", "incomingInstanceCount": null, "incomingVolumeUsd": null, "outgoingInstanceCount": "22", "outgoingVolumeUsd": "15578.90", "riskType": "COUNTERPARTY", "totalInstanceCount": "22", "totalVolumeUsd": "15578.90", "incomingVolumePercent": null, "outgoingVolumePercent": "27.44", "totalVolumePercent": "27.44" } ] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:00.500Z", "signature_requested_at": null, "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T09:00:00.500Z" } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:00.500Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
### Step failure 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` and `completed_at` are null because the step never reached `step.confirmed`. `transaction_hash` is retained. `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. balance reserved, step.signed received Tesser->>Chain: Broadcast signed transaction Tesser-->>You: webhook: type=step.submitted — indicates timestamp
when Tesser broadcasted transaction on chain Chain--xTesser: Fails to confirm — reorg, insufficient gas, or broadcast issue Tesser-->>You: webhook: type=step.failed — status_reasons[] carry the error,
transaction_hash retained, step actual.* fields null Tesser-->>You: webhook: type=payment.updated — terminal Note over You,Tesser: Top-level actual.* fields all null. balance_status rolls back
to unreserved, balance_reserved_at clears `} />
Example `step.failed` webhook for an on-chain broadcast failure ```json { "id": "c2f8e1a4-7b5d-4936-a0c8-3e9f1d2b4a7c", "type": "step.failed", "created_at": "2025-12-01T09:00:01.900Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": "0xd65dc6bf6dcc ... e877f7be", "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "failed", "status_reasons": [ { "error_code": "transfers-9305", "error_message": "The on-chain transaction failed to broadcast" } ], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:01.900Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T09:00:01.900Z" } } } ```
Example terminal `payment.updated` webhook for an on-chain broadcast failure ```json { "id": "d4a9e3c1-6b82-4715-9e0d-2f5c8a1b3e64", "type": "payment.updated", "created_at": "2025-12-01T09:00:01.902Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": "0xd65dc6bf6dcc ... e877f7be", "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "failed", "status_reasons": [ { "error_code": "transfers-9305", "error_message": "The on-chain transaction failed to broadcast" } ], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:01.900Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T09:00:01.900Z" } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:01.902Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
**Scenario 2 — Fiat-side 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. step 1 (on-chain transfer to the provider deposit address) completed Tesser-->>You: webhook: type=step.submitted — step 2 Provider--xTesser: Fiat payout fails — bank rejection, OFAC issue Tesser-->>You: webhook: type=step.failed — step 2 actual.* fields null,
status_reasons[] carries the payout error information Tesser-->>You: webhook: type=payment.updated — terminal, top-level actual.* fields populated
indicating that funds reside with the off-ramp provider Note over Provider: Exception handling process to recover funds or rectify issues
so payout to beneficiary can proceed `} />
Example terminal `payment.updated` webhook for a fiat-side failure ```json { "id": "e9d4a7b2-3c6f-4815-b9e2-5d8c1f3a7b04", "type": "payment.updated", "created_at": "2025-12-01T09:00:03.100Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": null, "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "reserved", "balance_reserved_at": "2025-12-01T09:00:01.000Z", "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "55.85", "currency": "USDC", "network": "ETHEREUM" } }, "transaction_hash": "0xd65dc6bf6dcc ... e877f7be", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.01", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T09:00:02.500Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": "2025-12-01T09:00:01.050Z", "submitted_at": "2025-12-01T09:00:01.200Z", "confirmed_at": "2025-12-01T09:00:01.800Z", "completed_at": "2025-12-01T09:00:02.500Z", "failed_at": null }, { "id": "a7b3d912-5f8e-4c23-9d6a-1e4f7b2c8a5d", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f3a8b2c1-7d4e-4f9a-b6e5-2c8d9a1f0e3b", "amount": "55.85", "currency": "USDC", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "provider", "status": "failed", "status_reasons": [ { "error_code": "transfers-9302", "error_message": "The provider payout failed" } ], "created_at": "2025-12-01T09:00:00.040Z", "updated_at": "2025-12-01T09:00:03.100Z", "signature_requested_at": null, "signed_at": null, "submitted_at": "2025-12-01T09:00:02.700Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T09:00:03.100Z" } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T09:00:03.100Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
### Expiration before completion 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](/overviews/funds-movement-lifecycle-and-data-model#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` ```json { "id": "b7e3c1f2-9d48-4a56-8c0f-2e9d4a7b1f30", "type": "payment.updated", "created_at": "2025-12-01T23:59:59.999Z", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "ref_123", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": null, "currency": "USDC", "network": "ETHEREUM" } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { "address": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", "entities": [], "addressRiskIndicators": [] } } ], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440100", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440011", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "550e8400-e29b-41d4-a716-446655440012", "amount": "1000", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T09:00:00.400Z", "updated_at": "2025-12-01T23:59:59.999Z", "signature_requested_at": "2025-12-01T09:00:00.800Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T23:59:59.999Z" } ], "created_at": "2025-12-01T09:00:00.000Z", "updated_at": "2025-12-01T23:59:59.999Z", "expires_at": "2025-12-01T23:59:59.999Z" } } } ```
--- ## Document: Create a Payout from a Pre-Funded Fiat Ledger Create a payment that converts fiat you hold at a liquidity provider into a stablecoin, moves it through your managed wallet, and pays out local fiat to a beneficiary. URL: /how-tos/send-a-stablecoin-payout/create-a-payout-from-a-pre-funded-fiat-ledger # Create a Payout from a Pre-Funded Fiat Ledger For remittance corridors where your organization pre-funds fiat at an exchange, you can create a single payment that converts that fiat into a stablecoin, moves the stablecoin through your managed wallet (a self-custodial wallet Tesser provisions for your workspace and for which you hold the signing keys), and delivers local fiat to the beneficiary's bank account. This guide describes how to create and execute such a payment to fund in CAD and payout in NGN, using **Kraken** as the on-ramp provider and **Zuba** as the off-ramp provider. ## Workflow A payout from a pre-funded fiat ledger progresses through these phases: 1. **Create the payment and request a quote** — `POST /v1/payments` with the CAD amount to send, net of your organization's markup, and the destination currency. 2. **Attach accounts and commit** — once the originator accepts the rate, `PATCH /v1/payments/{paymentId}` with the originator's bank account (`funding_account_id`), your Kraken CAD ledger (`desired.from.account_id`), the beneficiary's bank account (`desired.to.account_id`), and your `organization_reference_id`. This is the commit point: Tesser begins executing toward the committed amount. 3. **Pre-execution check** — Tesser reserves CAD from the Kraken ledger. The payment is not risk-screened: every account the funds pass through is a managed account of your workspace. 4. **Execute the payment** — the four steps progress in order: Kraken swaps CAD for USDC in your ledger; Kraken withdraws the USDC on-chain to your managed wallet; you sign the on-chain transfer from your wallet to your Zuba ledger; Zuba converts the USDC and pays out the local fiat to the beneficiary's bank. 5. **Reach terminal state** — the payment's `actual` overlay populates and a final `payment.updated` webhook fires alongside the last `step.completed`. ## The Four-Step Model A payout from a pre-funded fiat ledger is one Payment resource whose `steps[]` array contains four steps. 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 Swap of CAD for USDC inside your ledger at Kraken swap Tesser (dispatched to Kraken) No
2 On-chain withdrawal of USDC from your Kraken ledger to your managed wallet transfer Tesser (dispatched to Kraken) No
3 On-chain transfer of USDC from your managed wallet to your Zuba ledger transfer You (signed with the managed wallet's keys) Yes
4 Withdrawal of the USDC from your Zuba ledger, converted by Zuba into the destination currency and paid out to the beneficiary's bank account transfer Tesser (dispatched to Zuba) 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_id`s on the two sides. See [Steps](/overviews/funds-movement-lifecycle-and-data-model#steps). :::note Zuba converts the USDC to the destination currency **inside step 4**: the step draws USDC from your Zuba ledger and delivers NGN to the beneficiary's bank, so its `from` side is in USDC and its `to` side is in NGN. The conversion is not surfaced as a `swap` step; it is a cross-currency `transfer` out of a managed ledger. Step 3's `to.account_id` and step 4's `from.account_id` are the same account: your Zuba ledger. ::: ## Successful API and Webhook Sequence The diagram below shows the end-to-end sequence for a successful payment. Solid arrows are API calls you make; dashed arrows are synchronous responses, provider responses, and webhook events. Webhook arrows are labelled `type=` followed by any fields the event changes. The strict ordering shown is illustrative; follow the emitted `step.*` events for actual sequencing (see [Execution](/overviews/funds-movement-lifecycle-and-data-model#execution)). >Tesser: POST /v1/payments — CAD amount, destination currency Tesser-->>You: 200 — payment id, desired.* echoed Tesser->>Kraken: Quote CAD to USDC Kraken-->>Tesser: Rate Tesser->>Zuba: Quote USDC to NGN Zuba-->>Tesser: Rate, quote window Tesser-->>You: webhook: type=payment.quote_created — estimated.* populated,
steps[] planned (4 steps, status=created) Note over You: Show the initial rate to the originator.
Collect payment details, lock funds, run your checks Note over You,Tesser: Attach accounts and commit You->>Tesser: PATCH /v1/payments/{id} — funding, from, to account ids,
organization_reference_id Tesser->>Zuba: Quote USDC needed for the committed NGN Zuba-->>Tesser: USDC amount Tesser-->>You: 200 — accounts attached Tesser-->>You: webhook: type=payment.updated — account ids on desired.*,
estimated.* and steps[] Note over You,Tesser: Pre-execution check Tesser-->>You: webhook: type=payment.balance_updated, balance_status=reserved Note over You,Kraken: Step 1 — swap CAD to USDC Tesser->>Kraken: Market order for the USDC needed Kraken-->>Tesser: Filled Tesser-->>You: webhook: type=step.submitted → step.confirmed → step.completed (step 1) Note over You,Chain: Step 2 — withdraw USDC to your managed wallet Tesser->>Kraken: Withdraw USDC to the registered wallet address Kraken-->>Tesser: Withdrawal accepted, withdrawal id Tesser-->>You: webhook: type=step.submitted → step.confirmed (step 2) — transaction_hash null Kraken->>Chain: Broadcast Kraken-->>Tesser: Withdrawal success, on-chain transaction Tesser-->>You: webhook: type=step.completed (step 2) — transaction_hash set Note over You,Zuba: Step 3 — sign and send USDC to your Zuba ledger Tesser-->>You: webhook: type=step.signature_requested (step 3) — unsigned_transaction set You->>Signer: signStep(unsigned_transaction, wallet address, network) Signer-->>You: signature You->>Tesser: POST /v1/payments/{id}/steps/{stepId}/sign — signature Tesser-->>You: 200 Tesser-->>You: webhook: type=step.signed (step 3) Tesser->>Chain: Broadcast Tesser-->>You: webhook: type=step.submitted → step.confirmed → step.completed (step 3) Note over You,Zuba: Step 4 — Zuba pays out NGN Zuba-->>Tesser: USDC credited to your Zuba ledger Tesser->>Zuba: Create payout against the quote Zuba-->>Tesser: Payout created, then processing, then paid Tesser-->>You: webhook: type=step.submitted → step.confirmed → step.completed (step 4) Tesser-->>You: webhook: type=payment.updated — actual.* populated (terminal) `} /> ## Phase 1: Request a Quote When the originator initiates a remittance, your organization creates a Payment at Tesser to request an end-to-end quote. There is no separate quote API endpoint. In this example the originator specifies the **CAD amount to send**, so the POST body sets `desired.from.amount` and leaves `desired.to.amount` null. Tesser works forward from that CAD amount to NGN: CAD to USDC at Kraken's current rate, then that USDC to NGN at Zuba's current rate. **Apply your organization's markup before creating the payment.** Submit as `desired.from.amount` the CAD net of your margin, not the amount the originator pays you. Tesser prices the payment on the amount you submit, so `estimated.to.amount` is exactly the NGN you display to the originator and commit to delivering. Tesser does not need to know your markup, and the amount the originator pays you is outside Tesser. In this example the originator sends 100.00 CAD, your markup is 50 bps (0.50 CAD), and the payment is created for 99.50 CAD. ```json title="POST /v1/payments — request body" { "desired": { "from": { "amount": "99.50", "currency": "CAD", "network": null }, "to": { "currency": "NGN", "network": null } } } ``` Tesser responds synchronously with the new Payment record. At this point the `desired` overlay echoes what you submitted; `estimated.*`, `actual.*`, and `steps[]` are all empty or null because Tesser has not yet planned the route. ```json title="POST /v1/payments — synchronous response (abridged)" { "data": { "id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": null, "amount": null, "currency": "NGN", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": null, "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": "unreserved", "balance_reserved_at": null, "expires_at": "2026-09-17T14:00:00.000Z", "steps": [], "created_at": "2026-09-14T14:00:00.000Z", "updated_at": "2026-09-14T14:00:00.000Z" } } ``` `expires_at` is set at creation to 72 hours after `created_at`. It is Tesser's expiration length for the payment, not the window in which the originator must accept the rate: attach accounts before it passes, or the PATCH in Phase 2 is refused. If you want a shorter window for the originator to accept the rate, for example 30 minutes, enforce it on your side; Tesser's `expires_at` is not intended to match it. Tesser then plans the route and fires a single `payment.quote_created` webhook carrying the NGN the beneficiary is expected to receive (`estimated.to.amount`) and the four planned steps, each with its own `estimated` overlay. This is the initial rate; see [Fees and FX rates](#fees-and-fx-rates). ```json title="Webhook — payment.quote_created (abridged)" { "id": "7c9e6a1b-2d3f-4a5b-8c9d-0e1f2a3b4c5d", "type": "payment.quote_created", "created_at": "2026-09-14T14:00:02.000Z", "data": { "object": { "id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "organization_reference_id": null, "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": null, "amount": null, "currency": "NGN", "network": null } }, "estimated": { "from": { "account_id": null, "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": null, "amount": "95373.71", "currency": "NGN", "network": null } }, "risk_status": null, "balance_status": "unreserved", "expires_at": "2026-09-17T14:00:00.000Z", "steps": [ { "id": "4f7c1a9e-2b3d-4e85-b0a6-8d5c2e1f3b9a", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": null, "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": null, "amount": "72.26097064", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "kraken", "status": "created", "status_reasons": [] // ...timestamp fields omitted for brevity }, { "id": "9b3e7d1c-5a2f-4e84-b6c0-1d7a3f9e2c5b", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "72.26097064", "currency": "USDC", "network": null }, "to": { "account_id": null, "amount": "72.160970", "currency": "USDC", "network": "TEMPO" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "kraken", "status": "created", "status_reasons": [] // ...timestamp fields omitted for brevity }, { "id": "6d1a9f3e-2c7b-4f45-a8e0-5b2c9d1f7a3e", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "72.160970", "currency": "USDC", "network": "TEMPO" }, "to": { "account_id": null, "amount": "72.160970", "currency": "USDC", "network": "TEMPO" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "turnkey", "status": "created", "status_reasons": [] // ...timestamp fields omitted for brevity }, { "id": "1f8c4e2a-7d3b-4a96-b0e5-9c6d2a1f8b4e", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "72.160970", "currency": "USDC", "network": null }, "to": { "account_id": null, "amount": "95373.71", "currency": "NGN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "zuba", "status": "created", "status_reasons": [] // ...timestamp fields omitted for brevity } ], "created_at": "2026-09-14T14:00:00.000Z", "updated_at": "2026-09-14T14:00:02.000Z" } } } ``` ### Fees and FX rates There are a few things to keep in mind about fees and FX rates in this flow: - `estimated.to.amount / estimated.from.amount` is the end-to-end FX rate for the payout, written CAD/NGN (the NGN delivered per CAD sent). This is the rate the originator should view in your UI and accept or reject. If the originator accepts, continue to Phase 2; if they decline, do not attach accounts and the payment will cease processing. - **Rates in this guide are written base/quote**, following FX convention with the crypto asset first: USDC/CAD 1.3764 means 1.3764 CAD per USDC, and USDC/NGN 1,321.68 means 1,321.68 NGN per USDC. - **Initial rate and final rate may differ.** The FX rate on `payment.quote_created` is the **initial rate**. The NGN it produces, `estimated.to.amount`, is the **committed amount** that will be delivered to the beneficiary, whether or not the rate is later refreshed. The rate the payment executes at is the **final rate**. The final rate will be the initial rate when all pre-execution steps are completed and funds transfer to Zuba has occurred while Zuba's rate quote is still valid. If Zuba's rate has elapsed before funds have been delivered on-chain to your Zuba ledger, Tesser will obtain a new quote for the same committed destination amount, and the final rate reflects the cost in CAD to obtain that destination amount. The table below follows the example payment through three cases. Because Zuba's rate changes infrequently, a refreshed quote often returns the same rate; the second and third columns show a typical change when it does not. The payloads in this guide follow the third column; see [Terminal State](#terminal-state) for the completed payment.
| | Where you see it | All actions inside rate quote window | Rate refresh: Rate moves in your favour| Rate refresh: Rate moves against you | | --- | --- | --- | --- | --- | | Amount the originator wants to send | Not sent to Tesser | 100.00 CAD | 100.00 CAD | 100.00 CAD | | Your markup (example: 50 bps) | Not sent to Tesser | 0.50 CAD | 0.50 CAD | 0.50 CAD | | Send amount submitted to Tesser, net of your markup | `desired.from.amount`, `estimated.from.amount` | 99.50 CAD | 99.50 CAD | 99.50 CAD | | Committed amount of local fiat to deliver | `estimated.to.amount`, and `actual.to.amount` on completion | 95,373.71 NGN | 95,373.71 NGN | 95,373.71 NGN | | Initial FX rate (E2E) | `estimated.to.amount` / `estimated.from.amount`; each provider's rate follows from the two amounts on that step's `estimated` overlay, after deducting Kraken's trade fee from step 1's CAD | CAD/NGN 958.53 (USDC/CAD 1.3764 at Kraken, USDC/NGN 1,321.68 at Zuba) | CAD/NGN 958.53 (USDC/CAD 1.3764 at Kraken, USDC/NGN 1,321.68 at Zuba) | CAD/NGN 958.53 (USDC/CAD 1.3764 at Kraken, USDC/NGN 1,321.68 at Zuba) | | Final FX rate (E2E) | `actual.to.amount` / `actual.from.amount`; each provider's rate follows from the two amounts on that step's `actual` overlay, after deducting Kraken's trade fee from step 1's CAD | Same as the initial rate | CAD/NGN 959.30 (USDC/CAD 1.3763 at Kraken, USDC/NGN 1,322.68 at Zuba) | CAD/NGN 957.95 (USDC/CAD 1.3762 at Kraken, USDC/NGN 1,320.68 at Zuba) | | CAD debited from your ledger at Kraken | `actual.from.amount` on the payment and on step 1 | 99.50 CAD | 99.42 CAD | 99.56 CAD | | Kraken trade fee (4 bps) | Step 1 `fees[]`, `fee_type: provider` | 0.04 CAD | 0.04 CAD | 0.04 CAD | | Tesser's fee (flat, not deducted from any amount) | Step 3 `fees[]`, `fee_type: tesser_fee` | 0.60 USDC | 0.60 USDC | 0.60 USDC | | USDC bought at Kraken | Step 1 `actual.to.amount` and step 2 `actual.from.amount` | 72.260970 USDC | 72.206412 USDC | 72.315608 USDC | | USDC delivered to your Zuba ledger (less Kraken's withdrawal fee) | Step 2 `actual.to.amount`, step 3 `actual.from.amount` and `actual.to.amount`, step 4 `actual.from.amount` | 72.160970 USDC | 72.106412 USDC | 72.215608 USDC | | Your gain or loss | Not reported; `desired.from.amount` less `actual.from.amount` | None | 0.08 CAD gain | 0.06 CAD loss |
- **The `estimated` overlays always carry the initial rate.** They are not revised after the quote; the amounts that actually move appear on each step's `actual` overlay when it completes, and on the payment's `actual` overlay at the end. - **Fee line items are recorded when a step completes.** Every step's `fees[]` is empty on `payment.quote_created` and stays empty until that step completes, with one exception: step 3's `gas` line item can appear when Tesser observes the transaction receipt, before the step completes. The `estimated` amounts are already net of the provider fees. Kraken's trade fee is recorded as a `provider` line item in CAD on step 1; it is taken from the CAD before the swap, so step 1 converts the CAD drawn less that fee. Kraken's per-withdrawal fee is a `provider` line item in USDC on step 2 and is deducted from the USDC that reaches your wallet. Zuba's fee is netted into its exchange rate and carries no line item; step 4's `to.amount` divided by its `from.amount` is Zuba's all-in USDC/NGN rate. Tesser's fee and the sponsored network gas are recorded on step 3 and are not deducted from any amount in the flow (see [Tesser's fee](#tessers-fee)). ## Phase 2: Attach Account IDs and Commit Before you attach accounts, complete your own pre-payout work: collect the beneficiary's payment details, confirm the originator accepted the rate within the window you enforce, lock the originator's funds so the remittance is paid for, and run your compliance checks on the originator. Then attach the accounts via a PATCH call. This finalizes the payment's `desired` overlay **and commits the payment**: payout processing begins. Nothing is committed at Kraken or Zuba before this PATCH call. Tesser executes toward the committed amount. If Zuba's rate quote is still valid when the USDC reaches your Zuba ledger, the payment settles at the initial rate; if it has elapsed by then, Tesser obtains a new quote for the same committed amount and the payment settles at the final rate (see [Fees and FX rates](#fees-and-fx-rates)). Tesser tracks how much of Zuba's quote window remains and is conservative: if there is not enough time left to complete every execution step and deliver the USDC to Zuba before the quote lapses, Tesser obtains the new quote before it begins executing, so the USDC it buys at Kraken is sized to the rate the payment will settle at. Submit all of the following fields in one PATCH request: - `funding_account_id` is the **originator's bank account**, the ultimate source of funds for Travel Rule purposes. - `desired.from.account_id` is **your CAD ledger at Kraken**, the account Tesser draws from. Tesser supplies this ID during onboarding; it is the same on every payment. - `desired.to.account_id` is the **beneficiary's NGN bank account**, where Zuba delivers the payout. - `organization_reference_id` is your own reference for the remittance. **It is accepted on the initial POST or on this PATCH, and refused once execution has started**, so include it here if you did not have it at creation. - `payment_info.payment_description` is optional free text that Zuba passes to the beneficiary's bank for the account statement. **Keep it to 50 characters or fewer**: Zuba accepts longer text, but the receiving bank may not carry all of it onto the statement. ```json title="PATCH /v1/payments/{paymentId} — request body" { "funding_account_id": "8f3b1e7c-9a2d-4f58-b6c0-4e7d2a9f1b3c", "desired": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f" }, "to": { "account_id": "8e2c6a9f-1b4d-4f83-a5e7-3d0c9b1f2a6e" } }, "organization_reference_id": "remit_2026_09_14_0042", "payment_info": { "payment_description": "Family Remit transfer 0042" } } ``` The PATCH succeeds synchronously and Tesser publishes a `payment.updated` webhook. It reflects the populated account IDs on the `desired` and `estimated` overlays and on the steps: your Kraken ledger on step 1 (both sides) and step 2's `from`; your managed wallet on step 2's `to` and step 3's `from`; your Zuba ledger on step 3's `to` and step 4's `from`; and the beneficiary's bank on step 4's `to`. The amounts on the `estimated` overlays are unchanged by the PATCH. In this example Zuba's initial quote had elapsed, so the payment settles at the final rate: Zuba needs `72.215608` USDC for the committed amount rather than the `72.160970` at the initial rate. The final amounts appear on each step's `actual` overlay as the step completes (see [Phase 4](#phase-4-convert-and-withdraw-from-the-ledger-steps-12)). ```json title="Webhook — payment.updated (accounts attached, abridged)" { "id": "8d0f7b2c-3e4a-4b6c-9d0e-1f2a3b4c5d6e", "type": "payment.updated", "created_at": "2026-09-14T14:12:01.000Z", "data": { "object": { "id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "organization_reference_id": "remit_2026_09_14_0042", "payment_info": { "payment_description": "Family Remit transfer 0042" }, "direction": "outbound", "funding_account_id": "8f3b1e7c-9a2d-4f58-b6c0-4e7d2a9f1b3c", "desired": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": "8e2c6a9f-1b4d-4f83-a5e7-3d0c9b1f2a6e", "amount": null, "currency": "NGN", "network": null } }, "estimated": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": "8e2c6a9f-1b4d-4f83-a5e7-3d0c9b1f2a6e", "amount": "95373.71", "currency": "NGN", "network": null } }, "risk_status": null, "balance_status": "unreserved", "expires_at": "2026-09-17T14:00:00.000Z", "steps": [ { "id": "4f7c1a9e-2b3d-4e85-b0a6-8d5c2e1f3b9a", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "72.26097064", "currency": "USDC", "network": null } }, "provider_key": "kraken", "status": "created" // ...remaining fields as in payment.quote_created }, { "id": "9b3e7d1c-5a2f-4e84-b6c0-1d7a3f9e2c5b", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "72.26097064", "currency": "USDC", "network": null }, "to": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "72.160970", "currency": "USDC", "network": "TEMPO" } }, "provider_key": "kraken", "status": "created" // ...remaining fields as in payment.quote_created }, { "id": "6d1a9f3e-2c7b-4f45-a8e0-5b2c9d1f7a3e", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "72.160970", "currency": "USDC", "network": "TEMPO" }, "to": { "account_id": "c4e7a1f9-3b8d-4d62-9a5e-7f1c2b8d4e6a", "amount": "72.160970", "currency": "USDC", "network": "TEMPO" } }, "provider_key": "turnkey", "status": "created" // ...remaining fields as in payment.quote_created }, { "id": "1f8c4e2a-7d3b-4a96-b0e5-9c6d2a1f8b4e", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "c4e7a1f9-3b8d-4d62-9a5e-7f1c2b8d4e6a", "amount": "72.160970", "currency": "USDC", "network": null }, "to": { "account_id": "8e2c6a9f-1b4d-4f83-a5e7-3d0c9b1f2a6e", "amount": "95373.71", "currency": "NGN", "network": null } }, "provider_key": "zuba", "status": "created" // ...remaining fields as in payment.quote_created } ], "updated_at": "2026-09-14T14:12:01.000Z" } } } ``` :::note Your Zuba ledger is a managed ledger account that Tesser provisioned for your workspace at Zuba during onboarding (see [Accounts](/overviews/accounts)). It has its own on-chain deposit address, which is where step 3 delivers the USDC, and Tesser tracks the USDC balance it holds. Its account ID is the same on every payment, so step 3's `to.account_id` and step 4's `from.account_id` are stable across payments, just as your Kraken ledger's ID is. ::: ## Phase 3: Tesser Pre-Execution Check Once accounts are attached, one check gates execution: the balance check. It follows the conventions in [Pre-Execution Checks](/overviews/funds-movement-lifecycle-and-data-model#pre-execution-checks) on the lifecycle overview. **This payment is not risk-screened.** Every account the funds pass through is a managed account of your workspace (your Kraken ledger, your managed wallet, your Zuba ledger), so there is no external destination to screen. `risk_status` is `null` on every payload and no `payment.risk_updated` webhook fires. ### Balance check Because the payment's first step draws CAD from your Kraken ledger, the balance check runs against that ledger when execution begins. Tesser sizes the CAD needed to buy the USDC from Kraken's market price at that moment, verifies the ledger holds that much, reserves it, and emits `payment.balance_updated` with `balance_status: "reserved"` and `balance_reserved_at` populated. The reserved amount is an estimate: step 1 is a market order, so the CAD it actually costs can land above or below the reservation, and the difference shows on step 1's `actual.from.amount`. If the ledger does not hold enough CAD, the event carries `balance_status: "awaiting_funds"` and execution waits; Tesser re-runs the check once the ledger is topped up (see [Insufficient CAD in the Kraken ledger](#insufficient-cad-in-the-kraken-ledger)). See [Balance Statuses](/overviews/funds-movement-lifecycle-and-data-model#balance-statuses). ```json title="Webhook — payment.balance_updated (abridged)" { "id": "5c8e1f3a-7b2d-4a96-c0e4-2d9f6b1a8e3c", "type": "payment.balance_updated", "created_at": "2026-09-14T14:12:03.500Z", "data": { "object": { "id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "risk_status": null, "balance_status": "reserved", "balance_reserved_at": "2026-09-14T14:12:03.400Z" // ...remaining fields as in payment.updated } } } ``` ## Phase 4: Convert and Withdraw from the Ledger (Steps 1–2) The first two money movement steps of the payout swap CAD for USDC at the on-ramp and then withdraw to your managed wallet. Both steps are executed by Tesser and fire the full `step.submitted` → `step.confirmed` → `step.completed` lifecycle. Your organization does not sign either step. ### Step 1: Swap of CAD for USDC in your Kraken ledger Tesser obtains the required amount of USDC from Kraken to deliver the committed amount of NGN to Zuba. 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`. Kraken's trade fee is taken from the CAD side: the step's `from.amount` is the total CAD used, its `fees[]` carries the trade fee in CAD once the step completes, and the USDC received is the remainder converted at Kraken's rate. In this example `99.50` CAD less a `0.04` CAD fee converts at USDC/CAD 1.3764 to `72.26097064` USDC. ```text title="Lifecycle" created → submitted → confirmed → completed ↘ failed ``` The events fire as Tesser places the order (`step.submitted`), Kraken accepts it (`step.confirmed`), and the fill settles in your ledger (`step.completed`). `actual.to.amount` is the amount of USDC Tesser bought, which equals `estimated.to.amount`, to six decimals, when the payment settles at the initial rate and differs when it settles at the final rate. `actual.from.amount` is the CAD amount the fill actually cost. The step's `estimated` overlay keeps its initial-rate values. ```json title="Webhook — step.completed (step 1, abridged)" { "id": "9e1a8c3d-4f5b-4c7d-a0e1-2f3b4c5d6e7f", "type": "step.completed", "created_at": "2026-09-14T14:12:08.000Z", "data": { "object": { "id": "4f7c1a9e-2b3d-4e85-b0a6-8d5c2e1f3b9a", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "72.26097064", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "99.56", "currency": "CAD", "network": null }, "to": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "72.315608", "currency": "USDC", "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [ { "fee_amount": "0.04", "fee_currency": "CAD", "fee_type": "provider", "fee_metadata": {} } ], "provider_key": "kraken", "status": "completed", "status_reasons": [], "signature_requested_at": null, "signed_at": null, "submitted_at": "2026-09-14T14:12:05.000Z", "confirmed_at": "2026-09-14T14:12:07.000Z", "completed_at": "2026-09-14T14:12:08.000Z", "failed_at": null } } } ``` ### Step 2: On-chain withdrawal of USDC to your managed wallet Tesser instructs Kraken to withdraw the USDC to your managed wallet. The events fire as Tesser requests the withdrawal (`step.submitted`), Kraken accepts the request and returns a withdrawal ID (`step.confirmed`), and Kraken reports the withdrawal successful (`step.completed`). `step.confirmed` means Kraken has accepted the withdrawal, not that it has broadcast the transaction; `transaction_hash` is still `null` on that event. Tesser completes the step only once Kraken reports the withdrawal as successful, the withdrawal matches a debit in your Kraken ledger, and Tesser has read the transfer's receipt on Tempo. `transaction_hash` populates on `step.completed`. There is no further chain-finality wait beyond that receipt. Kraken can report success some time after the USDC is visible in your wallet, so treat `step.completed`, not a change in the wallet's balance, as the signal that step 3 is about to be requested. Kraken's per-withdrawal fee is deducted from the amount that lands in your wallet and recorded as a `provider` line item in step 2's `fees[]` when the step completes, which is why step 2's `to.amount` is lower than its `from.amount`. ```text title="Lifecycle" created → submitted → confirmed → completed ↘ failed ``` ```json title="Webhook — step.completed (step 2, abridged)" { "id": "b0f2c9d4-5a6c-4d8e-b1f2-3a4c5d6e7f80", "type": "step.completed", "created_at": "2026-09-14T14:12:40.000Z", "data": { "object": { "id": "9b3e7d1c-5a2f-4e84-b6c0-1d7a3f9e2c5b", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "72.26097064", "currency": "USDC", "network": null }, "to": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "72.160970", "currency": "USDC", "network": "TEMPO" } }, "actual": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "72.315608", "currency": "USDC", "network": null }, "to": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "72.215608", "currency": "USDC", "network": "TEMPO" } }, "transaction_hash": "0x4d1e8a2f7c3b ... 9e0b6c5a", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.10", "fee_currency": "USDC", "fee_type": "provider", "fee_metadata": {} } ], "provider_key": "kraken", "status": "completed", "status_reasons": [], "signature_requested_at": null, "signed_at": null, "submitted_at": "2026-09-14T14:12:10.000Z", "confirmed_at": "2026-09-14T14:12:25.000Z", "completed_at": "2026-09-14T14:12:40.000Z", "failed_at": null } } } ``` Step 2's `step.completed` event signals that Tesser has verified the USDC in your managed wallet; `step.signature_requested` for step 3 follows. ## Phase 5: Sign and Send to Zuba (Step 3) Step 3 is the only step your organization signs. Once step 2 completes, Tesser builds the on-chain transfer from your managed wallet to your Zuba ledger's deposit address and emits `step.signature_requested` carrying the `unsigned_transaction`. **The amount encoded in the `unsigned_transaction` is the USDC Zuba quoted as needed to deliver the committed NGN**, which is exactly the USDC that arrived in your wallet in step 2. In this example that is `72.215608` USDC. It can differ from the step's `estimated.from.amount`, which still carries the initial-rate figure of `72.160970`. To know the amount before you sign, read step 2's `actual.to.amount`: it is the USDC that landed in your wallet (`72.215608` here), and the transaction moves exactly that amount. You do not compute this amount; you sign what Tesser presents. ```json title="Webhook — step.signature_requested (step 3, abridged)" { "id": "e3a7c1f9-2b8d-4d65-a9e0-4f1c6b3d8a2e", "type": "step.signature_requested", "created_at": "2026-09-14T14:12:41.000Z", "data": { "object": { "id": "6d1a9f3e-2c7b-4f45-a8e0-5b2c9d1f7a3e", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "72.160970", "currency": "USDC", "network": "TEMPO" }, "to": { "account_id": "c4e7a1f9-3b8d-4d62-9a5e-7f1c2b8d4e6a", "amount": "72.160970", "currency": "USDC", "network": "TEMPO" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "unsigned_transaction": "0x76ed81893a85 ... 3a764000080c0", "fees": [], "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "signature_requested_at": "2026-09-14T14:12:41.000Z", "signed_at": null, "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } } } ``` To sign the step, build a `StepForSigning` from the webhook step and pass it to the signing SDK. It needs three values: the `unsigned_transaction`, the managed 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](/api/payments#sign-payment-step). Tesser sponsors the Tempo network fee, so you do not need to fund gas separately or configure a sponsor. Pass the `unsigned_transaction` unchanged to `signStep` and submit the returned `signature`; the SDK signs an authorization request with your signing keys, and Tesser obtains the wallet signature through the wallet infrastructure provider, adds its sponsor signature, and broadcasts the transaction. See [Sign a Wallet Step](/how-tos/sign-a-wallet-step#sign-the-step-with-the-localsigner-sdk) for install, the full SDK reference, and the SDKs available for other languages. ```typescript 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 the step.signature_requested webhook event. const step = webhookPayload.data.object; // 2. Resolve the managed wallet's on-chain address. const { data: account } = await fetch( `https://api.tesser.xyz/v1/accounts/${step.estimated.from.account_id}`, { headers: { Authorization: `Bearer ${token}` } } ).then((res) => res.json()); // 3. Authorize wallet signing with the SDK. 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 step. 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 }), } ); ``` When the sign call succeeds, Tesser fires `step.signed`, then broadcasts the transaction on Tempo. `step.submitted` fires when Tesser begins submitting the signed transaction, `step.confirmed` fires when the network accepts it (with `transaction_hash` populated), and `step.completed` fires when the transfer reaches finality and Zuba credits the USDC to your Zuba ledger. When the step completes, Tesser's fee is recorded as a `tesser_fee` line item in step 3's `fees[]`; the `gas` line item for the sponsored network fee can appear earlier, when Tesser observes the transaction receipt. Neither is deducted from any amount in the flow (see [Tesser's fee](#tessers-fee)). ```text title="Lifecycle" created → signature_requested → signed → submitted → confirmed → completed ↘ failed ``` ```json title="Webhook — step.completed (step 3, abridged)" { "id": "a8d2f4c6-9e1b-4b73-c5a0-7f3e1d9b2c4a", "type": "step.completed", "created_at": "2026-09-14T14:13:05.000Z", "data": { "object": { "id": "6d1a9f3e-2c7b-4f45-a8e0-5b2c9d1f7a3e", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 3, "step_type": "transfer", "actual": { "from": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "72.215608", "currency": "USDC", "network": "TEMPO" }, "to": { "account_id": "c4e7a1f9-3b8d-4d62-9a5e-7f1c2b8d4e6a", "amount": "72.215608", "currency": "USDC", "network": "TEMPO" } }, "transaction_hash": "0x7b2c9e4f1a6d ... 3e8f0a5c", "unsigned_transaction": null, "fees": [ { "fee_amount": "0.60", "fee_currency": "USDC", "fee_type": "tesser_fee", "fee_metadata": {} }, { "fee_amount": "0.001", "fee_currency": "USDC", "fee_type": "gas", "fee_metadata": {} } ], "provider_key": "turnkey", "status": "completed", "status_reasons": [], "signature_requested_at": "2026-09-14T14:12:41.000Z", "signed_at": "2026-09-14T14:12:50.000Z", "submitted_at": "2026-09-14T14:12:51.000Z", "confirmed_at": "2026-09-14T14:12:56.000Z", "completed_at": "2026-09-14T14:13:05.000Z", "failed_at": null // ...estimated overlay as in step.signature_requested } } } ``` ### Tesser's fee Tesser's fee is a flat fee per payment, denominated in USDC and recorded as a `tesser_fee` line item in step 3's `fees[]` when the step completes. The `gas` line item for the network fee Tesser sponsors is recorded on the same step, and can appear as soon as Tesser observes the transaction receipt; it records Tesser's cost. In this example the fee is `0.60` USDC. Neither line item is deducted from the amounts that move in this flow: step 3 sends Zuba the full USDC that arrived in your wallet in step 2, and neither the initial rate nor the final rate includes Tesser's fee. Both line items are informational records for reconciliation. ## Phase 6: Zuba Payout (Step 4) After step 3 completes, the USDC sits in your Zuba ledger. Step 4 withdraws it: Tesser creates the payout at Zuba, and Zuba converts the USDC to NGN at the quoted rate and pays the beneficiary's bank account. The step's `from` side is your Zuba ledger in USDC and its `to` side is the beneficiary's bank in NGN. Tesser initiates the step; Zuba executes it. Tesser supplies the beneficiary's account details and the originator's details, drawn from the counterparties you registered, so the payout carries the sender information Zuba requires. ```text title="Lifecycle" created → submitted → confirmed → completed ↘ failed ``` Once Zuba credits the USDC from step 3, Tesser creates the payout at Zuba against the quote. `submitted_at` populates when Zuba accepts that request; `confirmed_at` when Zuba reports the payout is processing; `completed_at` when Zuba reports the NGN paid to the beneficiary's bank. Local bank payouts typically settle in minutes, and within one business day at most. Step 4's `from.amount` is the USDC Zuba draws from your ledger and its `to.amount` is the committed NGN; the conversion happens inside the step and is not a separate `swap` of its own. The step's `transaction_hash` is always `null` because the payout happens within Zuba's books. Zuba's fee is netted into its exchange rate, so step 4 carries no fee line item. Step 4's `to.amount` divided by its `from.amount` is Zuba's all-in USDC/NGN rate. ```json title="Webhook — step.completed (step 4, abridged)" { "id": "c1e5a9f3-8d2b-4f67-b0c4-6a3e9d1f5b8c", "type": "step.completed", "created_at": "2026-09-14T14:14:30.000Z", "data": { "object": { "id": "1f8c4e2a-7d3b-4a96-b0e5-9c6d2a1f8b4e", "payment_id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "step_sequence": 4, "step_type": "transfer", "actual": { "from": { "account_id": "c4e7a1f9-3b8d-4d62-9a5e-7f1c2b8d4e6a", "amount": "72.215608", "currency": "USDC", "network": null }, "to": { "account_id": "8e2c6a9f-1b4d-4f83-a5e7-3d0c9b1f2a6e", "amount": "95373.71", "currency": "NGN", "network": null } }, "transaction_hash": null, "unsigned_transaction": null, "fees": [], "provider_key": "zuba", "status": "completed", "status_reasons": [], "signature_requested_at": null, "signed_at": null, "submitted_at": "2026-09-14T14:13:06.000Z", "confirmed_at": "2026-09-14T14:13:20.000Z", "completed_at": "2026-09-14T14:14:30.000Z", "failed_at": null // ...estimated overlay as in payment.updated } } } ``` ## Terminal State When step 4 completes, Tesser populates the payment's top-level `actual` overlay and fires a final `payment.updated` webhook. This event, together with step 4's `step.completed`, is the signal that funds have been credited to the beneficiary. On success: - **`actual.to.amount` equals `estimated.to.amount`.** The beneficiary received exactly the committed amount. - **`actual.from.amount` is the CAD actually drawn from your Kraken ledger.** It can be more or less than `estimated.from.amount` and `desired.from.amount`, which both still read `99.50`, the CAD you submitted net of your markup, priced at the initial rate. In this example the fill cost `99.56` CAD. That difference is your organization's cost or gain; Tesser does not reconcile it with the originator. - **The `estimated` overlays still show the initial rate.** Compare `actual` against `estimated` on the payment and on each step to see where execution diverged. ```json title="Webhook — payment.updated (terminal, abridged)" { "id": "f4c8e1a3-9b76-4d2e-a058-3c5f9e1d4b27", "type": "payment.updated", "created_at": "2026-09-14T14:14:31.000Z", "data": { "object": { "id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "organization_reference_id": "remit_2026_09_14_0042", "direction": "outbound", "funding_account_id": "8f3b1e7c-9a2d-4f58-b6c0-4e7d2a9f1b3c", "desired": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": "8e2c6a9f-1b4d-4f83-a5e7-3d0c9b1f2a6e", "amount": null, "currency": "NGN", "network": null } }, "estimated": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "99.50", "currency": "CAD", "network": null }, "to": { "account_id": "8e2c6a9f-1b4d-4f83-a5e7-3d0c9b1f2a6e", "amount": "95373.71", "currency": "NGN", "network": null } }, "actual": { "from": { "account_id": "5d2e8f1a-7c3b-4a69-9e0f-1b4c6d8a2e5f", "amount": "99.56", "currency": "CAD", "network": null }, "to": { "account_id": "8e2c6a9f-1b4d-4f83-a5e7-3d0c9b1f2a6e", "amount": "95373.71", "currency": "NGN", "network": null } }, "risk_status": null, "balance_status": "reserved", "balance_reserved_at": "2026-09-14T14:12:03.400Z", "expires_at": "2026-09-17T14:00:00.000Z", "steps": [ // ...all four steps with status "completed", as shown in the per-step sections ], "updated_at": "2026-09-14T14:14:31.000Z" } } } ``` ## Failure Modes When a payment reaches a terminal outcome, all four 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). Failed steps always have all-null `actual.*`. See [Terminal State and Divergence](/overviews/funds-movement-lifecycle-and-data-model#terminal-state-and-divergence). ### Downstream-step cascade behavior When any step transitions to `failed`, Tesser cascades the failure to **all downstream steps** still in `created` or `signature_requested`. Each cascaded step transitions to `failed` with a `status_reasons` entry carrying the `transfers-9201` code (`An upstream step failed, so this step was not executed`), distinct from the error on the step that actually failed. Tesser fires `step.failed` for each failed step, followed by the terminal `payment.updated`. Steps that already completed stay `completed`. Each failure mode below shows which steps cascade. ### Your pre-payout checks fail before commit If your own checks on the originator fail after you created the payment, do not attach accounts. A payment without accounts never executes and nothing moves at any provider. Create a new payment for the next attempt. ### Insufficient CAD in the Kraken ledger Two checks can put the payment into `awaiting_funds`. Before step 1, if the ledger does not hold the CAD Tesser sized from Kraken's market price, `payment.balance_updated` carries `balance_status: "awaiting_funds"` and no step is dispatched. At step 1, if the market moves between that check and the order so that the fill would cost more CAD than the ledger holds, Kraken refuses the order; the step does not fail, and `balance_status` moves from `reserved` back to `awaiting_funds`. In both cases, top up the ledger. Each time the ledger is credited, Tesser re-runs the balance check for every waiting payment in your workspace in the order the payments were created, skipping any the new balance still cannot cover, and execution resumes from where it paused. Because the CAD drawn can exceed the CAD submitted, keep the ledger funded with headroom above the sum of in-flight payments. ### Swap fails at Kraken (step 1) A market order at Kraken either fills in full or does not fill; there is no partial fill. If Kraken rejects the order or it does not fill, step 1 transitions to `failed` with the `transfers-9303` reason (`The transfer step failed to execute`) and Kraken's detail appended. Steps 2–4 cascade with `transfers-9201`. The CAD remains in your ledger. Create a new payment to retry. If Kraken accepts the order but its response does not arrive, Tesser polls Kraken for the order's outcome with exponential backoff and the step stays `submitted` until the outcome is known. If the outcome still cannot be determined, Tesser holds the step for manual investigation; it neither fails nor completes until the result is confirmed. ### On-chain transfer fails (step 3) If the signed transaction fails to broadcast (`transfers-9305`) or reverts on-chain (`transfers-9313`), step 3 transitions to `failed` and step 4 cascades. The USDC remains in your managed wallet. Treat this as a customer-service event and contact Tesser support; the payment cannot be resumed and a new payment is needed to deliver the remittance. ### Zuba's quote expires after execution begins Zuba's quote for the committed amount is valid for a limited window (see [Fees and FX rates](#fees-and-fx-rates)). Tesser obtains a new quote before it starts executing if the remaining window cannot cover the run, so an expiry during execution is unusual. It can still happen when step 3 waits a long time for your signature, or when Kraken is slow to process the withdrawal in step 2. If the quote expires after the USDC has been sent, Zuba still credits the USDC to your Zuba ledger, and Tesser obtains a new quote for the same committed amount and creates the payout from that ledger. If the new quote needs more USDC than the ledger holds, Tesser settles the shortfall with your organization manually, outside the payment's steps; Tesser support will contact you. No step fails. Signing step 3 as soon as `step.signature_requested` arrives keeps this window short. {/* D11: held pending Zuba's answer on how POST /v1/payouts/{id}/cancel and the awaiting_funding payout status fit the "delivery is acceptance" model. Draft below; do not publish until confirmed. ### Zuba payout fails (step 4) If Zuba cannot complete the payout, for example an invalid bank code or account number, or a compliance rejection on its side, step 4 transitions to `failed` with the `transfers-9302` reason (`The provider payout failed`) and Zuba's raw code appended in `status_reasons`. No cascade applies because step 4 is the terminal step. The USDC sits in your Zuba ledger pending manual reconciliation between Tesser and Zuba. Treat this as a customer-service event and contact Tesser support; the payment cannot be redirected to a different beneficiary. */} ## Webhook Events by Phase The following table summarizes the webhook events fired through the lifecycle of a successful payout from a pre-funded fiat ledger.
Phase Webhook event Carries
Quote payment.quote_created Initial end-to-end rate, with the committed amount in estimated.to.amount, plus the four planned steps[] (all status: "created", fees[] empty); customer-side account IDs null
Commit payment.updated Account IDs populated on desired and attached to the steps; your Zuba ledger on step 3 to and step 4 from; estimated amounts unchanged from the initial rate
Pre-execution check payment.balance_updated balance_status: "reserved" on the Kraken CAD ledger (or awaiting_funds); risk_status stays null and no payment.risk_updated fires
Step 1 step.submitted → step.confirmed → step.completed Kraken converted CAD to USDC; actual.from.amount is the total CAD used, trade fee in CAD in fees[]
Step 2 step.submitted → step.confirmed → step.completed Kraken accepted the withdrawal (confirmed, transaction_hash still null), then reported it delivered to your managed wallet on Tempo (completed, transaction_hash set); withdrawal fee in fees[]
Step 3 step.signature_requested → step.signed → step.submitted → step.confirmed → step.completed You signed the transfer to your Zuba ledger; gas and tesser_fee reported in fees[], not deducted
Step 4 step.submitted → step.confirmed → step.completed Zuba drew the USDC from your Zuba ledger (actual.from), converted it at its all-in rate and paid the NGN to the beneficiary's bank (actual.to); no fee line item
Terminal payment.updated Top-level actual overlay populated; actual.to matches estimated.to, actual.from is the CAD actually drawn and may differ from estimated.from
--- ## Document: Create a Cross-Ramp Payment Create a payment that starts in one fiat currency and delivers another currency to a beneficiary. URL: /how-tos/send-a-stablecoin-payout/create-a-cross-ramp-payment # Create a Cross-Ramp Payment For certain payment corridors, you can deliver a fiat currency to a provider and have that provider pay out directly to a beneficiary in a different local currency. This guide describes how to create and execute a cross-ramp payment. ## How the cross-ramp differs The provider handles conversion from the "from" fiat currency to the "to" fiat currency. As a result: - **No step is signed** — there is no on-chain transfer, so your organization never signs a wallet step. - **No risk screening**. Cross-ramp payments are not screened: `risk_status` is `null` and no `payment.risk_updated` webhook fires. - **No balance reservation.** Likewise, because no Tesser-held balance is reserved, `balance_status` stays `null` and no `payment.balance_updated` webhook fires. :::note The originator funds the cross-ramp from the source-currency virtual account they hold with you. How that account is itself funded is outside Tesser. ::: ## Workflow Once the originator is onboarded (see [Prerequisites](#prerequisites)), a cross-ramp payment progresses through these phases: 1. **Create the payment and request an FX rate quote** — [`POST /v1/payments`](/api/payments#create-payment) with the currency pair. Receive a payment ID synchronously. Tesser requests a cross-ramp quote from the provider and fires a single `payment.quote_created` webhook carrying the end-to-end FX rate and the two planned `steps[]`. 2. **Submit accounts and lock the rate** — [`PATCH /v1/payments/{paymentId}`](/api/payments#update-payment) with the originator's source-currency account (`desired.from.account_id` and `funding_account_id`) and the beneficiary's destination-currency account (`desired.to.account_id`). This attaches the accounts and instructs Tesser to create the cross-ramp with the provider, **locking the quoted rate**. Tesser fires a `payment.updated` webhook. This PATCH must happen before the quote expires. 3. **Fund the cross-ramp** — the originator pushes the **exact** source-fiat amount into the provider's source-currency virtual account (step 1). Tesser observes receipt and step 1 completes. 4. **Provider converts and pays out** — the provider converts the source fiat to the destination fiat and pays it out to the beneficiary's bank (step 2). 5. **Reach terminal state** — the payment's `actual` overlay populates and a final `payment.updated` webhook fires alongside the last `step.completed`. ## The Two-Step Model A cross-ramp payment is one Payment resource whose `steps[]` array contains two funds-transfer steps. 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 of the source currency from the originator's virtual account to the cross-ramp provider's source-currency virtual account transfer Originator (pushes source fiat via the local push-payment rail) No
2 Fiat payout of the destination currency from the cross-ramp provider's destination-currency virtual account to the beneficiary's bank account transfer Cross-ramp provider No
:::note The provider converts the source currency to the destination currency **between** these two steps — inside the provider, moving funds from its source-currency virtual account to its destination-currency virtual account. That conversion is not surfaced as a `swap` step, which is why step 1 is denominated entirely in the source currency and step 2 entirely in the destination currency. The end-to-end rate is carried on the payment-level `estimated` overlay. See [Steps](/overviews/funds-movement-lifecycle-and-data-model#steps). ::: ## Example Scenario The remainder of this guide walks through a concrete example: a cross-ramp payment **from Mexico to the United States**. ## Prerequisites Originators must be onboarded with the cross-ramp provider before you can lock a payment they fund. Onboarding includes the provider's business verification (KYB). Tesser performs the registration, so you do not call the provider yourself. Onboarding happens once per originator, outside the API. If you attempt [Phase 2](#phase-2-submit-payment-details-and-lock-the-rate) for an originator that has not been onboarded, the request is refused with `payments-3089`. If the originator is onboarded but verification has not completed, it is refused with `payments-3090`. ### Send Tesser your originator list Send Tesser a CSV with one row per originator. All five columns are required and the header row must match exactly. | Column | Description | | --- | --- | | `business_legal_name` | The originator's registered legal name. Tesser matches resubmissions on this value, so keep it stable across files. | | `email` | Operational contact for the originator. Verification correspondence goes to this address. | | `country` | ISO 3166-1 alpha-2 country of the originator's business address, e.g., `MX`. | | `tos_ip` | The IP address the originator accepted your terms of service from. | | `tos_accepted_at` | When the originator accepted your terms, as an ISO 8601 instant with a timezone. Both `2026-08-14T09:21:00Z` and `2026-08-19T17:04:32-06:00` are accepted. | Quote any field that contains a comma. ```csv title="originators.csv" business_legal_name,email,country,tos_ip,tos_accepted_at "Bodegas del Norte, S.A. de C.V.",ops@bodegas-del-norte.example.com,MX,203.0.113.10,2026-08-14T09:21:00Z Textiles Monterrey S. de R.L. de C.V.,ops@textiles-monterrey.example.com,MX,203.0.113.77,2026-08-27T11:48:05Z ``` :::warning{title="Terms of service acceptance is a compliance record"} `tos_ip` and `tos_accepted_at` cannot be reconstructed after the fact. Capture them when the originator accepts your terms, and send the values you captured rather than the time you assembled the file. ::: ### What Tesser configures Tesser registers each row with the cross-ramp provider, requests business verification, and creates a counterparty in your workspace for each originator. Do not create counterparties for your own originators. A counterparty you create yourself carries no provider registration, so payments funded from its accounts are refused in Phase 2. ### Results Tesser returns Tesser returns a CSV with one row per originator you submitted. Rows are processed independently, so one failure does not affect the others. | Column | Description | | --- | --- | | `status` | `ok` or `error`. Rows with `error` carry the reason in the `error` column and are the only ones to resubmit. | | `counterparty_id` | The originator's Tesser counterparty ID. Use this ID for the rest of this guide. | | `verification_status`, `verified` | The provider's business verification state. Payments can be locked for the originator once `verified` is `true`. | | `kyb_link` | The originator's hosted verification link. Forward it to the originator while verification is pending. The link is stable, so a resubmission returns the same one. | | `deposit_instructions_pending` | `true` when the originator is verified but the provider has not issued a deposit account yet. | In sandbox, verification completes in seconds. In production it can take days. Those rows come back with `verified` set to `false` and a `kyb_link` to forward, and Tesser records the verification when the provider reports it. Contact Tesser if an originator stays unverified longer than you expect. :::note `deposit_instructions_pending` set to `true` on a newly verified originator does not block anything. The provider issues the originator's deposit account when you lock their first payment, and [Phase 3](#phase-3-fund-the-cross-ramp-step-1) reads the deposit instructions from the payment. ::: ### Register the accounts Using the `counterparty_id` Tesser returned, register the accounts this payment will reference. - The originator's source-currency account, which they push MXN from in step 1. - The beneficiary counterparty. For more information, see [Create a Counterparty](/how-tos/create-a-counterparty). - The beneficiary's destination-currency bank account, owned by that beneficiary counterparty. For more information, see [Create an Account](/how-tos/create-an-account). Beneficiaries need no provider onboarding, because nothing is funded from their accounts. ```json title="POST /v1/accounts/banks: the originator's MXN account" { "counterparty_id": "6f1c4a8e-2d7b-4930-b5e2-9a3f0c7d1b6e", "name": "Bodegas del Norte MXN operating", "bank_name": "STP", "bank_code_type": "CLABE", "bank_identifier_code": "646180111812345678", "bank_account_number": "646180111812345678" } ``` Keep the account IDs these calls return. The originator's MXN account is both the `funding_account_id` and the `desired.from.account_id`, and the beneficiary's USD account is the `desired.to.account_id`. You submit all three in [Phase 2](#phase-2-submit-payment-details-and-lock-the-rate). ### Sandbox test amounts In sandbox the provider simulates settlement from the amount the originator funds, so a payment funded with one of the amounts below follows a scripted path. This lets you exercise each outcome end to end without moving real money, including the funding step, which the provider simulates rather than waiting for a real deposit. | Funded amount (MXN) | What the payment does | | --- | --- | | `2000.01` | Settles. Step 1 completes, step 2 moves through `submitted`, `confirmed`, and `completed`, and the payment reaches its terminal state with the `actual` overlay populated. | | `2000.02` | The payout is rejected because the beneficiary is not a verified account holder. Step 2 fails carrying the reason. Step 1 fails as well, unless it had already completed, in which case it stays completed. | | `2000.03` | Stops once the payout is confirmed. Step 1 completes and step 2 reaches `confirmed` and stays there, which leaves the payout leg open to test against. | Any other amount matches no rule, so nothing simulates the funding. Those payments lock normally and then sit with step 1 in `created` until `expires_at` passes, at which point they expire (see [Originator funds never arrive](#originator-funds-never-arrive)). :::warning{title="These values are the funded amount, which you cannot set directly yet"} The rules match on `desired.from.amount`, the MXN the originator funds. A cross-ramp is requested from `desired.to.amount` instead, and the provider derives the funded amount from the rate, so today you cannot land on one of these values deliberately. The provider is adding matching on `desired.to.amount`, after which setting that field to one of the amounts above selects the same outcome. Ask Tesser to confirm the current rule list before you plan a sandbox test. ::: ## 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. The optional `payment_info.payment_description_wire` — free text (maximum 140 characters) that appears on the beneficiary's bank account statement — can be supplied here or on the PATCH in [Phase 2](#phase-2-submit-payment-details-and-lock-the-rate). If both carry it, the PATCH value is used. ```json title="POST /v1/payments — request body" { "desired": { "from": { "currency": "MXN", "network": null }, "to": { "amount": "1000", "currency": "USD", "network": null } }, "organization_reference_id": "pmt_2026_05_18_001", "payment_info": { "payment_description_wire": "Invoice 2026-0518" } } ``` Tesser responds synchronously with the new Payment record. At this point the `desired` overlay echoes what you submitted; `estimated.*`, `actual.*`, and `steps[]` are all empty or null because Tesser has not yet requested the cross-ramp quote from the provider. ```json title="POST /v1/payments — synchronous response" { "data": { "id": "550e8400-e29b-41d4-a716-446655440020", "workspace_id": "550e8400-e29b-41d4-a716-446655440001", "organization_reference_id": "pmt_2026_05_18_001", "direction": "outbound", "funding_account_id": null, "payment_info": { "payment_description_wire": "Invoice 2026-0518" }, "desired": { "from": { "account_id": null, "amount": null, "currency": "MXN", "network": null }, "to": { "account_id": null, "amount": "1000", "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": null, "risk_status_reasons": [], "participants": [], "provider_metadata": [], "risk_reviewed_by": null, "risk_reviewed_at": null, "balance_status": null, "balance_reserved_at": null, "steps": [], "created_at": "2026-05-18T15:00:00.000Z", "updated_at": "2026-05-18T15:00:00.000Z", "expires_at": null } } ``` Tesser then requests a cross-ramp quote from the provider to compute the end-to-end cost and FX rate, and fires a single `payment.quote_created` webhook carrying the MXN amount the originator needs to fund (`estimated.from.amount`) and the two planned steps. The initial POST carries no accounts, so every `account_id` on the quote is `null`. They populate once you attach accounts with the PATCH in Phase 2 — except step 2's `from.account_id`, which stays `null` for the life of the payment. If the provider refuses the quote — for example an amount outside its quotable range (`capa-3001`) — no `payment.quote_created` fires; instead the payment carries a single `failed` step with the provider's reason in `status_reasons`, and a terminal `payment.updated` webhook is sent. Transient provider errors are retried internally and do not fail the payment. ```json title="Webhook — payment.quote_created (abridged)" { "id": "7c9e6a1b-2d3f-4a5b-8c9d-0e1f2a3b4c5d", "type": "payment.quote_created", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "organization_reference_id": "pmt_2026_05_18_001", "direction": "outbound", "funding_account_id": null, "desired": { "from": { "account_id": null, "amount": null, "currency": "MXN", "network": null }, "to": { "account_id": null, "amount": "1000", "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": null, "amount": "17953.64", "currency": "MXN", "network": null }, "to": { "account_id": null, "amount": "1000", "currency": "USD", "network": null } }, "risk_status": null, "balance_status": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440101", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "17953.64", "currency": "MXN", "network": null }, "to": { "account_id": null, "amount": "17953.64", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "capa", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "completed_at": null, "failed_at": null // ...timestamp fields omitted for brevity }, { "id": "550e8400-e29b-41d4-a716-446655440102", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": null, "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "capa", "status": "created", "status_reasons": [], "created_at": "2026-05-18T15:00:05.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "completed_at": null, "failed_at": null // ...timestamp fields omitted for brevity } ], "created_at": "2026-05-18T15:00:00.000Z", "updated_at": "2026-05-18T15:00:05.000Z", "expires_at": "2026-05-18T15:00:15.000Z" } } } ``` ### Fees and FX rates The ratio of `estimated.from.amount` to `estimated.to.amount` is Tesser's all-in end-to-end FX rate. A few things to keep in mind: - **Surface this rate (with any markup you apply) to the originator in your UI.** If the originator accepts, continue to Phase 2 to lock the rate; if they decline, don't execute the payment. - **The provider's costs are embedded in the quoted MXN amount (`estimated.from.amount`).** This includes the payout rail fee (if applicable). Tesser requests enough destination currency from the provider so that the beneficiary still receives the full `desired.to.amount` in the `desired.to.currency`. - Cross-ramp payments can be created 24/7/365. Open-market FX rates apply only Monday to Friday, 8:00 AM – 3:00 PM Mexico City time (`America/Mexico_City`); a quote requested outside those hours carries the provider's off-hours pricing, which is already reflected in the quoted rate (there is no separate line item). - **Tesser's fee is embedded in the rate, not invoiced separately.** For cross-ramp payments, Tesser's fee is the premium spread baked into the quoted `estimated.from.amount`; the provider settles it to Tesser in USD, so there is no separate invoice. The provider's own quote fees (a flat fixed fee plus a spread amount) are recorded on the payout step's `fees[]` when that step completes, so you can reconcile them. :::note The provider holds a quoted rate for only about **10 seconds** — the window in which you must **lock** it by executing Phase 2 (`PATCH`). `expires_at` is set in two phases: it is `null` on the synchronous `POST` response, becomes the end of the quote window on `payment.quote_created`, and moves to the **funding deadline** once the rate is locked (see [Phase 3](#phase-3-fund-the-cross-ramp-step-1)). If the quote window lapses before you `PATCH`, request a fresh quote by creating a new payment. ::: ## Phase 2: Submit Payment Details and Lock the Rate Once the originator accepts the rate, your organization populates the account identifiers via a PATCH call. This finalizes the payment's `desired` overlay **and** instructs Tesser to create the cross-ramp with the provider, locking the quoted rate. - `desired.from.account_id` and `funding_account_id` both reference the **originator's MXN virtual account issued by your organization** — the account from which the originator pushes the source fiat in step 1. - `desired.to.account_id` is the beneficiary's US bank account, where the provider delivers the USD in step 2. - The `payment_info` object carries optional detail about **why the originator is sending this payment**. Both of its fields are optional: - `payment_description_wire` — free text (maximum 140 characters) to appear on the beneficiary's bank account statement (e.g. an invoice number). Tesser forwards it to the provider when the rate is locked, so it must be final by this PATCH. It can also be supplied on the initial POST; if both carry it, the PATCH value is used. - `payment_reason_code` — a short code classifying the purpose of the payment. A standardized set of accepted values will be published in a future release; you can omit it today. ```json title="PATCH /v1/payments/{paymentId} — request body" { "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440010" }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047" } }, "payment_info": { "payment_description_wire": "Invoice 2026-0518" } } ``` The PATCH succeeds synchronously and Tesser publishes a `payment.updated` webhook reflecting the populated account IDs on the `desired` and `estimated` overlays and on the steps: the originator's account on step 1's `from`, the provider's MXN virtual account (created at lock) on step 1's `to`, and the beneficiary's bank on step 2's `to`. ```json title="Webhook — payment.updated (accounts attached, abridged)" { "id": "8d0f7b2c-3e4a-4b6c-9d0e-1f2a3b4c5d6e", "type": "payment.updated", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440020", "direction": "outbound", "funding_account_id": "550e8400-e29b-41d4-a716-446655440010", "payment_info": { "payment_description_wire": "Invoice 2026-0518" }, "desired": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440010", "amount": null, "currency": "MXN", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440010", "amount": "17953.64", "currency": "MXN", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "USD", "network": null } }, "risk_status": null, "balance_status": null, "steps": [ { "id": "550e8400-e29b-41d4-a716-446655440101", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440010", "amount": "17953.64", "currency": "MXN", "network": null }, "to": { "account_id": "c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "amount": "17953.64", "currency": "MXN", "network": null } }, "provider_key": "capa", "status": "created" // ...remaining fields as in payment.quote_created }, { "id": "550e8400-e29b-41d4-a716-446655440102", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "USD", "network": null } }, "provider_key": "capa", "status": "created" // ...remaining fields as in payment.quote_created } ], "expires_at": "2026-05-18T19:30:00.000Z" } } } ``` :::warning{title="One locked cross-ramp per originator at a time"} An originator can hold only one locked cross-ramp awaiting funds. Locking a second payment for the same originator while the first is unfunded is refused with `payments-3088` and HTTP 409. The originator's deposit account is reused across their payments, so two unfunded orders could not be told apart by an incoming deposit. Retry once the payment in flight is funded or expires. To run cross-ramps in parallel while testing, onboard more than one originator. ::: ## Phase 3: Fund the Cross-Ramp (Step 1) With the rate locked, fund the cross-ramp by pushing funds from the MXN virtual account your organization has issued to the MXN virtual account issued by the provider for the originator. The originator must fund before the payment's `expires_at` — the provider's funding cutoff — or the payment expires (see [Failure Modes](#failure-modes)). Step 1 stays `created` until the provider observes the incoming funds; step 1 transitions `created → completed` when the provider notifies Tesser of MXN receipt. Tesser sets the funding cutoff (`expires_at`) from the provider's same-day settlement window, as confirmed with the provider: when the rate is locked on a working day before 1:30 PM Mexico City time (`America/Mexico_City`), the cutoff is 1:30 PM that same day, which is what ensures same-day delivery of funds to the beneficiary. Locks at or after 1:30 PM — or on a weekend or Mexican bank holiday — roll the cutoff to 1:30 PM on the next working day. (The 8:00 AM market open affects rate availability and off-hours pricing, not the funding cutoff.) ### Look up the provider's deposit instructions Retrieve the provider's MXN virtual-account details from Tesser. Step 1's `estimated.to.account_id` — first carried on the `payment.updated` webhook, once the rate is locked — holds the Tesser identifier for the provider's MXN virtual account. Call [Get an account by ID](/api/accounts#get-an-account-by-id) with that account ID to retrieve the bank account information needed for the SPEI push. The account number is returned masked by default. Pass `include_secure=true` on the [Get an account by ID](/api/accounts#get-an-account-by-id) request to retrieve the full account number required to complete the push. Step 1's `actual` overlay reflects the amount sent to the provider. ```json title="Webhook — step.completed (step 1, abridged)" { "id": "9e1a8c3d-4f5b-4c7d-a0e1-2f3b4c5d6e7f", "type": "step.completed", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440101", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440010", "amount": "17953.64", "currency": "MXN", "network": null }, "to": { "account_id": "c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "amount": "17953.64", "currency": "MXN", "network": null } }, "actual": { "from": { "account_id": "550e8400-e29b-41d4-a716-446655440010", "amount": "17953.64", "currency": "MXN", "network": null }, "to": { "account_id": "c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "amount": "17953.64", "currency": "MXN", "network": null } }, "transaction_hash": null, "fees": [], "provider_key": "capa", "status": "completed", "status_reasons": [], "submitted_at": null, "confirmed_at": null, "completed_at": "2026-05-18T16:05:00.000Z", "failed_at": null } } } ``` Step 1 moves straight from `created` to `completed` when the provider reports the funds, so `submitted_at` and `confirmed_at` remain `null`; `completed_at` is the only lifecycle timestamp stamped on this step. ## Phase 4: Provider Converts and Pays Out (Step 2) Once the MXN lands in the provider's MXN virtual account, the provider converts it to USD and pays the USD out to the beneficiary's US bank account. Step 2 is driven entirely by the provider; your organization does not sign for it or initiate it. ```text title="Lifecycle" created → submitted → confirmed → completed ↘ failed ``` `submitted_at` populates when Tesser marks the payout step `submitted`; `confirmed_at` when the provider confirms the destination-side transfer is underway; `completed_at` when the provider settles the USD to the beneficiary's bank. Step 2's `from` side is denominated in USD (out of the provider's USD virtual account) and its `to` side delivers USD to the beneficiary — the MXN→USD conversion was absorbed inside the provider between step 1 and step 2. ```json title="Webhook — step.completed (step 2, abridged)" { "id": "b0f2c9d4-5a6c-4d8e-b1f2-3a4c5d6e7f80", "type": "step.completed", "data": { "object": { "id": "550e8400-e29b-41d4-a716-446655440102", "payment_id": "550e8400-e29b-41d4-a716-446655440020", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": null, "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047", "amount": "1000", "currency": "USD", "network": null } }, "transaction_hash": null, "fees": [ { "fee_amount": "265.32", "fee_currency": "MXN", "fee_type": "fixed_fee" }, { "fee_amount": "17.69", "fee_currency": "MXN", "fee_type": "spread_amount" } ], "provider_key": "capa", "status": "completed", "status_reasons": [], "submitted_at": "2026-05-18T16:05:10.000Z", "confirmed_at": "2026-05-18T16:06:00.000Z", "completed_at": "2026-05-18T16:12:00.000Z", "failed_at": null } } } ``` The payout step's `fees[]` carries the provider's own quote fees — a flat fixed fee (`fixed_fee`) and a spread amount (`spread_amount`), in the provider's units — recorded once the step completes so you can reconcile them. Tesser's own fee (the premium spread) is not a line item here; it is embedded in the quoted rate and settled to Tesser by the provider. ## Terminal State When step 2 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 the MXN the originator funded against the locked rate. ## Failure Modes When a payment reaches a terminal outcome — both 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). Failed steps always have all-null `actual.*`. ### Incorrect MXN amount funded If you send more or less than the quoted `estimated.from.amount`: - The payment is **not** automatically failed. - Step 1's `actual` overlay reflects the amount the originator actually sent, and step 1 remains in `created` while the mismatch is reconciled manually between your organization, Tesser, and the provider (for example, refunding an overpayment or collecting a shortfall). - Once the mismatch is resolved, the payout is delivered. :::note Contact Tesser support if a payment remains in `created` longer than expected. ::: ### Quote expires while the rate is being locked A `PATCH` sent inside the quote window can still reach the provider after the quote has lapsed, because the provider needs a few seconds to create the order. The provider then refuses the order, so no deposit account is issued and nothing moves. The `PATCH` fails with `payments-3015` and HTTP 400, the same error as a `PATCH` sent after `expires_at`. Step 1 transitions to `failed` carrying the `transfers-9208` reason (`The quote expired before the provider accepted it`), and the failure cascades to step 2, which transitions to `failed` carrying `transfers-9201` (`An upstream step failed, so this step was not executed`). Tesser fires `step.failed` for each step and a terminal `payment.updated`. Create a new payment for a fresh quote. ### Originator funds never arrive If the originator never pushes the MXN, step 1 cannot complete. If `expires_at` is reached without funds arriving, the payment expires: step 1 transitions to `failed` carrying the `transfers-9204` reason (`The transfer expired before it could be executed`), and the failure cascades to step 2, which transitions to `failed` carrying `transfers-9201` (`An upstream step failed, so this step was not executed`). There is no dedicated `payment.expired` event; the expiry surfaces as the terminal `payment.updated` webhook with both steps `failed` — and since nothing moved, the payment's `actual` overlay stays all-null. ### Payout failure or compliance rejection If the cross-ramp provider cannot complete the payout — for example, a compliance rejection or a receiving-bank issue — step 2 transitions to `failed`, its `status_reasons` carries the provider's reason, and its `fees[]` records the provider's cancellation fee (`fee_type: "cross_ramp_cancellation"`, 0.5% of the payout amount) charged on a cancelled locked rate. Treat a stalled or failed step 2 as a customer-service event and contact Tesser support. ## Webhook Events by Phase The following table summarizes the webhook events fired through the lifecycle of a successful cross-ramp payment.
Phase Webhook event Carries
Quote payment.quote_created End-to-end FX rate via estimated.from.amount, plus the two planned steps[] (both status: "created"); no account IDs yet (the provider's deposit account is provisioned at lock), originator/beneficiary accounts null
PATCH payment.updated Account IDs populated on the desired overlay and attached to the steps: originator → step 1 from, provider MXN deposit account → step 1 to, beneficiary → step 2 to; the cross-ramp is created with the provider and the rate is locked
Step 1 step.completed The provider notified Tesser that the originator's MXN push landed in the provider's MXN virtual account
Step 2 step.submitted → step.confirmed → step.completed The provider converted the MXN and paid out USD: submitted_at when Tesser marked the payout step submitted, confirmed_at when the provider confirmed the payout underway, completed_at when the USD settled to the beneficiary's bank
Terminal payment.updated Top-level actual overlay populated; payment in terminal state
--- ## Document: Simulate a Travel Rule Inbound Payment Rehearse your Travel Rule handling — authorized and rejected — against a real Notabene counterparty, in sandbox and staging. URL: /how-tos/receive-an-inbound-payment/simulate-a-travel-rule-inbound-payment # Simulate a Travel Rule Inbound Payment `POST /v1/payments/simulate-travel-rule-inbound` has a Tesser-operated simulator VASP originate a real Notabene Travel Rule transfer into one of your managed wallets, so you can test your integration's handling of an inbound Travel Rule exchange without standing up a second VASP. Everything about the compliance decision is genuine: a real Notabene transfer is created, real PII is exchanged, and your organization's own name-matching and authorization logic decides the outcome — the same code path a real counterparty-initiated inbound runs through. Only the counterparty is simulated. :::warning{title="The response arrives before the money does"} Where the transfer is authorized, real testnet USDC **is** broadcast — but not before this endpoint answers. The response comes back as soon as the payment exists, with its step `created` and `transaction_hash: null`. The broadcast follows a few seconds later, and the step then confirms and credits on its own. A `201` with an unconfirmed step is the expected first state, not a stuck one. ::: ## Prerequisites - **Sandbox or staging only.** The endpoint is unavailable in production, gated the same way as `simulate-inbound`. Calling it in production returns `payments-3053`. - **The `notabene-travel-rule-inbound` feature flag must be enabled for your organization.** Without it, the webhooks that carry the compliance decision are received and dropped, so the call would otherwise hang until it times out — the endpoint checks the flag up front instead and fails fast with `payments-3083`. Contact Tesser support to have the flag enabled for your organization. - **Your organization must have its Travel Rule VASP identity configured with Tesser.** This is set up during onboarding; if it is missing, the call fails with `payments-3085`. - **`to_account` must be a managed `stablecoin_ethereum` wallet in your workspace.** Creating a managed wallet registers it with Notabene automatically — no separate registration step. An account that does not exist, isn't managed, or isn't a `stablecoin_ethereum` wallet fails the call (`payments-1002` / `payments-3055`). You never see or supply any Notabene credentials — the simulator VASP's identity and your organization's own Travel Rule identity are both handled server-side. See [Authentication](/overviews/authentication) for how to obtain the bearer token (`$CLIENT_TOKEN` below). ## Request | Field | Required | Description | | --- | --- | --- | | `to_account` | Yes | The managed wallet account ID the simulated counterparty claims to be paying. | | `claimed_beneficiary_name` | No | The name the simulated counterparty claims for the beneficiary. Omit it to have the simulator claim the destination account owner's own name — the transfer is authorized. Supply a name that doesn't match, and Tesser's own name-matching rejects the transfer, which is how you rehearse your refusal path. | | `network` | No | One of `BASE_SEPOLIA`, `ETHEREUM_SEPOLIA`, `POLYGON_AMOY`. Defaults to `BASE_SEPOLIA`. | | `presentation` | No | `full` or `partial` — how completely the simulated counterparty satisfies your presentation policies, see [The partially satisfied presentation](#the-partially-satisfied-presentation). Defaults to `full`. | | `mocked_risk_status` | No | Forces the `risk_status` of the screening that runs once the simulated money arrives — see [Forcing a risk verdict](#forcing-a-risk-verdict). Omitted, the inbound is screened for real. | The simulated payment books 1 USDC, except on `ETHEREUM_SEPOLIA`, where it books 3500 USDC: our Travel Rule provider prices Sepolia USDC itself, so only a transfer whose own value clears the US$3,000 threshold is flagged Travel Rule there. The other two testnet USDCs are unpriced, so they flag on the declared value alone and the amount stays small. On the authorized branch that exact amount is what gets broadcast. ## The authorized call Omit `claimed_beneficiary_name` and the simulator claims the destination wallet owner's actual name, so the name match succeeds and the transfer is authorized: ```bash curl -X POST https://api.tesser.xyz/v1/payments/simulate-travel-rule-inbound \ -H "Authorization: Bearer $CLIENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_account": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "network": "BASE_SEPOLIA" }' ``` As soon as the compliance decision lands and the resulting payment exists, the endpoint responds `201`: ```json { "data": { "id": "5a9c3e7f-2b1d-4f48-a6e0-8c4b2d1f9a3e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "direction": "inbound", "funding_account_id": "d4c7a9e2-3f1b-4e86-9a05-6b2e8f1c7d33", "estimated": { "from": { "account_id": null, "amount": "1", "currency": "USDC", "network": "BASE_SEPOLIA" }, "to": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "1", "currency": "USDC", "network": "BASE_SEPOLIA" } }, // ... other payment fields (desired, actual, created_at, ...), as on any inbound payment "risk_status": "unchecked", "risk_status_reasons": [], "participants": [ // ... ORIGINATOR, ORIGINATOR_VASP and BENEFICIARY entries, exactly as a real counterparty-initiated inbound would carry them ], "provider_metadata": [ { "provider_metadata_type": "TRAVEL_RULE", "provider_metadata_key": "NOTABENE", "provider_metadata_data": { // ... the raw Notabene transfer record } } ], "expires_at": null, "steps": [ { "step_sequence": 1, "step_type": "transfer", "status": "created", "transaction_hash": null, "confirmed_at": null // ... } ], "travel_rule": { "transfer_id": "e3b9f2a1-7c4d-4e0a-9b1f-2d8c5a6e3f70", "is_travel_rule": true, "presentation": "full", "presentation_answered": true } } } ``` `travel_rule` sits alongside the payment fields inside `data` — it is not a separate resource. It carries only what is known at the moment the payment ID is returned: | Field | Description | | --- | --- | | `transfer_id` | The Notabene transfer ID the simulator created. It is not a Tesser payment ID and cannot be passed to `GET /v1/payments/{id}`, nor filtered on — see [The 202 response](#the-202-response) for how to find the payment it becomes. | | `is_travel_rule` | Whether Notabene flagged the simulated transfer as Travel Rule. Notabene sets this shortly after the transfer is created; `false` means either it never did within the wait budget or reading the transfer was permanently refused — either way, no compliance decision will follow and no money will be sent. | | `presentation` | Which rehearsal ran, echoing the request: `full` or `partial`. | | `presentation_answered` | Whether the simulated counterparty's PII reached your organization's Travel Rule policy. `false` means the PII exchange itself failed — no name-matching decision was made, and no money will be sent. | The decision has already been made by the time you get a `201` — the payment exists *because* it landed. The response simply does not carry its details, and the transaction hash and confirmation genuinely do follow later. All of it is read from the payment: | What you want | Where to read it | | --- | --- | | The Travel Rule decision | `steps[].status` on `GET /v1/payments/{id}` — `created` where the transfer was authorized, `failed` where it was rejected | | The raw Notabene transfer | `provider_metadata[]`, under `provider_metadata_key: "NOTABENE"` — provider data as received, whose own `status` can lag the decision (see [The partially satisfied presentation](#the-partially-satisfied-presentation)) | | The beneficiary name match | `participants[]` — score, threshold and resolution | | The originator's account | `funding_account_id` — a [generic account](/how-tos/create-an-account#generic-account) numbered `simulator-originator`, owned by the simulated originator (Ada Lovelace) in the `ORIGINATOR` entry of `participants[]`. See [The Originator's Funding Account](/how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet#the-originators-funding-account) | | The transaction hash | `steps[].transaction_hash` | | Confirmation and the credit | `steps[].status`, and the `payment.updated` webhook | :::warning{title="201 is not proof of authorization"} A `201` response means the compliance round trip *finished* — it does not mean it finished with authorization. A rejected transfer also produces a payment here, with its step `failed` rather than `created`. Read `steps[].status` for the actual decision — never the HTTP status code alone, and not the transfer's own `status` inside `provider_metadata`, which is raw provider data and is not updated for every decision Tesser takes. ::: ### What happens after the response For an authorized transfer, in order, over roughly the next few seconds: 1. Tesser broadcasts the payment's booked amount in test USDC from the platform faucet to your managed wallet. 2. The simulator settles the Notabene transfer with that real transaction, and Notabene's settlement webhook stamps the hash onto the payment's pending step. `GET /v1/payments/{id}` now shows a `transaction_hash` on a step still `created`. 3. The transfer is detected on chain, matched to this payment, and the step moves to `confirmed` — the balance is credited and screening runs. Steps 2 and 3 are the same code a genuine counterparty-initiated inbound runs through; nothing about them is specific to the simulator. If you subscribe to webhooks, you will see `payment.updated` as the step confirms. A rejected transfer stops before step 1: no money is sent, and the step stays `failed`. ## The refusal call Supply a `claimed_beneficiary_name` that does not match the destination wallet's owner, and Tesser's own name-matching logic refuses the transfer — the same decision it would make against a genuine counterparty: ```bash curl -X POST https://api.tesser.xyz/v1/payments/simulate-travel-rule-inbound \ -H "Authorization: Bearer $CLIENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_account": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "claimed_beneficiary_name": "Someone Else Entirely", "network": "BASE_SEPOLIA" }' ``` The simulated counterparty claims that wrong name as the beneficiary. Your organization's name-matching compares it against the destination account owner's actual name, does not find a match, and rejects the transfer. The response is still `201` — the compliance round trip finished, it just finished with a rejection: ```json { "data": { "id": "8c4b2d1f-9a3e-4f48-a6e0-5a9c3e7f2b1d", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "direction": "inbound", "steps": [ { "step_sequence": 1, "step_type": "transfer", "status": "failed", "status_reasons": [ { "error_code": "transfers-9215", "error_message": "The transfer failed due to failed Beneficiary name match check" } ], "transaction_hash": null, "confirmed_at": null // ... } ], "travel_rule": { "transfer_id": "9f1a2b6c-3d8e-4a70-b5f2-1c9e6d3a8b70", "is_travel_rule": true, "presentation": "full", "presentation_answered": true } // ... } } ``` The step's `status` is `failed`, not `created`, and no money is ever sent for it — a refusal is where the simulation stops. See [Payment Participants](/overviews/payment-participants) for how a rejected Beneficiary name-match generally surfaces on a payment's `participants[]`. ## The 202 response The compliance round trip — Notabene flagging the transfer as Travel Rule, the PII exchange, and your organization's name-matching decision — can take longer than the request's wait budget (about 40 seconds). When it does, the endpoint responds `202` with what it knows so far, rather than blocking further: ```json { "data": { "status": "pending", "travel_rule": { "transfer_id": "e3b9f2a1-7c4d-4e0a-9b1f-2d8c5a6e3f70", "is_travel_rule": true, "presentation": "full", "presentation_answered": true } } } ``` **A `202` does not mean the simulation failed, and it does not affect the money.** The decision is still coming; when it lands, an authorized transfer is still broadcast and still credits your wallet exactly as it would have on the `201` path. All you have lost is the payment ID in the response. `is_travel_rule: false` means either Notabene never flagged the simulated transfer as Travel Rule within the wait budget or reading the transfer was permanently refused, so no decision — and no payment, and no money — will ever follow for that transfer. `presentation_answered: false` means the simulated counterparty's PII never reached your organization's policy, and likewise nothing further will happen. Do **not** re-issue the same request to find the earlier payment: every call creates a brand-new Notabene transfer and a new simulated inbound, so retrying can never surface the one you are waiting on, only add another — and on the authorized path it sends a second lot of money. There is also no supported way to look the earlier transfer up by ID — `GET /v1/payments`'s filters (`start_date`, `end_date`, `direction`) don't include a transfer or provider-reference filter. Once the compliance decision lands, get the resulting payment one of two ways: - Subscribe to the `payment.created` webhook, if you receive webhooks in this environment, and read the payment ID off the event. - List inbound payments (`GET /v1/payments?direction=inbound`) and match the `transfer_id` you got from your original request or `202` response against `provider_metadata[].provider_metadata_data["@id"]` on each result yourself. `travel_rule` is only present in this endpoint's own response — `GET /v1/payments` does not return it. ## The partially satisfied presentation A real counterparty does not always answer everything you ask of it. Where your organization raises more than one presentation policy on a transfer, the counterparty's PII can satisfy one and leave another open — Notabene then reports the presentation as *partially* satisfied rather than complete. Pass `presentation: "partial"` to rehearse that: ```bash curl -X POST https://api.tesser.xyz/v1/payments/simulate-travel-rule-inbound \ -H "Authorization: Bearer $CLIENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_account": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "presentation": "partial", "network": "BASE_SEPOLIA" }' ``` Two presentation policies go up on the transfer instead of one, and the simulator answers only the second in full. The first stays open — it asks for a set of fields no PII body can satisfy — so Notabene evaluates the exchange as partially satisfied. It cannot be produced by sending thinner PII: Notabene refuses an IVMS101 body that omits anything the policy asks for, so the split has to be between policies, not within one. Tesser still authorizes on the strength of the beneficiary name match alone, and still records the payment, exactly as it does on the `full` path — a partially satisfied presentation is not a rejection. What changes is what your integration sees on the way there, which is the point of rehearsing it. One difference to expect: with a policy left open, Notabene never moves the transfer's own `status` past `INCOMING`, so the copy under `provider_metadata` keeps saying `INCOMING` on a payment that was authorized. The decision is on `steps[].status`, as always. `claimed_beneficiary_name` composes with this: supply a name that does not match and the transfer is refused on the name, partial presentation or not. ## Forcing a risk verdict Because the authorized branch produces real inbound money, that money is screened like any other inbound. Pass `mocked_risk_status` to force the verdict instead of letting screening run for real: ```bash curl -X POST https://api.tesser.xyz/v1/payments/simulate-travel-rule-inbound \ -H "Authorization: Bearer $CLIENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_account": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "mocked_risk_status": "automatically_rejected", "network": "BASE_SEPOLIA" }' ``` Accepted values are `automatically_approved`, `awaiting_decision` and `automatically_rejected` — the same set `simulate-inbound` takes. The verdict is applied when the broadcast is detected on chain, so it appears as `risk_status` on the payment a few seconds after the response, not in the response itself. This is what lets you rehearse a combination neither simulator could produce on its own: a transfer your Travel Rule policy **authorizes** whose money your risk screening then **rejects**. On a rejected transfer the field has no effect, because no money is sent and nothing is screened. ## `simulate-travel-rule-inbound` vs `simulate-inbound` Both simulators now produce a credited inbound payment. They differ in what they let you control on the way there:
| | `simulate-travel-rule-inbound` | `simulate-inbound` | | --- | --- | --- | | Simulates | A Travel Rule counterparty, and the money that follows | The money | | Counterparty | A real Notabene transfer from a Tesser-operated simulator VASP | N/A — funds come from the platform faucet | | On-chain transfer | On the authorized branch only, of the payment's booked amount | Always, a fixed 1 USDC | | Balance change | On the authorized branch, once the transfer confirms | Yes, once the transfer confirms | | Pending step | `created`, then `confirmed` — or `failed` on a rejection, with no money sent | `created`, then `confirmed` | | What you control | `claimed_beneficiary_name` to force authorization or rejection, `presentation` for a partially satisfied exchange, and `mocked_risk_status` | `mocked_risk_status` |
Reach for `simulate-inbound` when you only care about inbound money arriving and want the cheapest, fastest path to it. Reach for this endpoint when the Travel Rule exchange is part of what you are testing. ## Errors | Error Code | HTTP Status | Meaning | | --- | --- | --- | | `payments-1002` | 404 | `to_account` did not resolve to an account in this workspace. | | `payments-3053` | 400 | Simulation is not available in production. | | `payments-3054` | 400 | An unsupported `network` value, from an internal configuration gap rather than the request itself — a `network` outside `BASE_SEPOLIA`, `ETHEREUM_SEPOLIA`, `POLYGON_AMOY` is rejected by request validation before this error can fire. | | `payments-3055` | 400 | `to_account` exists but is not a managed `stablecoin_ethereum` wallet with an on-chain address. | | `payments-3083` | 400 | The `notabene-travel-rule-inbound` feature flag is not enabled for your organization. Contact Tesser support to have it enabled. | | `payments-3084` | 503 | The simulator VASP is not configured in this environment. This is an environment issue on Tesser's side, not something a request can fix. | | `payments-3085` | 400 | Your organization has no Travel Rule VASP identity configured with Tesser. | | `payments-3086` | 400 | The destination account resolved to no usable beneficiary name to claim. Supply `claimed_beneficiary_name`, or give the account's entity a name. | See the [Errors](/overviews/errors#payments) reference for the full Payments error table, and [Compliance and Risk Management](/overviews/compliance-and-risk) for how Travel Rule and wallet risk screening apply across all payment directions. --- ## Document: Receive an Inbound Payment to a Wallet Observe and reconcile inbound stablecoin payments sent on-chain to one of your managed wallets — what events fire, what the resource looks like, and how the sending wallet is identified. URL: /how-tos/receive-an-inbound-payment/receive-an-inbound-payment-to-a-wallet # Receive an Inbound Payment to a Wallet An inbound payment is a Tesser Payment resource with `direction = "inbound"`. It represents funds sent on-chain from an external wallet to one of your managed accounts at Tesser. This guide covers the wallet-recipient flow: inbound payments credited to a managed wallet at Tesser. Before reading further, review the [Funds Movement Lifecycle and Data Model](/overviews/funds-movement-lifecycle-and-data-model) for the shared data shape, lifecycle phases, and status taxonomy that apply to inbound payments along with Tesser's other funds-movement resources, and [Compliance and Risk Management](/overviews/compliance-and-risk) for the two compliance checks — Travel Rule data exchange and wallet risk screening — that shape the inbound lifecycle. ## What's Different About Inbound Payments Inbound payments share the Payment resource shape with outbound payments but diverge in several ways. Note these before you begin your integration: - **Two entry paths.** An inbound payment either begins with a Travel Rule authorization request from the originator VASP, in which case the payment record is created *before funds move*, or arrives as a direct on-chain transfer, in which case the record is created when Tesser sees the transaction in a block. See [How an Inbound Payment Begins](#how-an-inbound-payment-begins). - **No customer-side planning.** The `desired` overlay does not populate. `estimated` populates when Tesser records the inbound payment; `actual` populates only when the block finalizes (at `step.completed` / `payment.updated`). There is no quote, no `payment.quote_created` webhook, and no `PATCH` step. - **No balance check.** `balance_status` is `null` for the life of an inbound payment. Funds are being _credited_ to the wallet's balance, not debited from it, so there is no balance to reserve and no `unreserved` → `reserved` transition to report. - **No signing.** Your integration never calls a sign endpoint for an inbound payment. The on-chain transaction was signed by the external sender, not by you. - **Exactly one step.** The payment contains exactly one step, of type `transfer`. Inbound payments get created with step `status` as `created` when the record predates the transaction (a Travel Rule authorization flow) and with step `status` as `confirmed` when the record is created from a direct on-chain transfer without the Travel Rule flow. Step `status` will be `confirmed` once the transaction is in a block, `completed` once the block finalizes, and `failed` in the case where the funds do not settle — a rejected authorization, a sender who never broadcasts, or a block that never finalizes. - **No expiration.** `expires_at` is always `null` on an inbound payment. You are receiving funds on-chain rather than instructing a payment, so there is nothing to expire. - **The originator is identified through `participants[]`, not supplied by you.** Tesser reconciles the originating participant, and the VASP hosting the sending wallet, if there is one, against your counterparty and tenant records, and records the outcome in the payment's `participants[]` array. See [How the Sending Wallet Is Identified](#how-the-sending-wallet-is-identified). - **`funding_account_id` is the originator's account at their VASP, when Travel Rule data names one.** It points at a [generic account](/how-tos/create-an-account#generic-account) owned by the `ORIGINATOR`. Otherwise it is `null`, and the sending wallet is identified through `estimated.from.account_id` and `participants[]`. See [The Originator's Funding Account](#the-originators-funding-account). - **Risk screening targets the sender and is advisory.** Because the recipient is your managed wallet, Tesser screens the external sending wallet against your organization's risk policy and notifies you of the outcome via `payment.risk_updated`. Screening runs in parallel with the on-chain lifecycle and does not gate the credit — the receiving wallet's balance updates when the transaction is confirmed in a block, regardless of the risk outcome. ## How an Inbound Payment Begins An inbound payment can begin one of two ways: - **Through a Travel Rule authorization flow.** The sending (originator) VASP requests authorization through a Travel Rule data-exchange provider *before* broadcasting the transaction on-chain, and exchanges identity information as part of that request. Tesser creates a payment record and populates it with the data available from the Travel Rule provider. - **As a direct on-chain transfer.** A sending wallet pushes funds to the wallet with no prior authorization request. No Travel Rule data accompanies the transfer, so Tesser creates the payment record when it sees the transaction in a block and infers what it can about the sender from screening the sending wallet through a blockchain analytics provider. B{How did the
payment initiate?} B -->|Travel Rule
authorization request| C[Travel Rule
pre-authorization checks] C --> P1[Tesser creates the payment record,
recording the authorization outcome] P1 --> C1{Authorized?} C1 -->|No| R[Funds are not sent;
the step fails] C1 -->|Yes| D[Funds confirmed on-chain] B -->|Direct on-chain transfer| D D --> REC{Reconciles to a record from
a Travel Rule flow?} REC -->|No| P2[Tesser creates the
payment record] REC -->|Yes| U[The step moves to confirmed] P2 --> S[Sending account populates on
the estimated overlay] U --> S S --> BAL[Receiving wallet is credited] BAL --> F[Wallet screening] F --> G[Participants and risk fields populate;
payment.risk_updated fires] G --> H[Block finalizes:
step.completed fires]`} /> :::note{title="A note on settlement"} For inbound payments, funds are credited to your available balance **regardless of the screening outcome**. Tesser's `risk_status` is an advisory signal you consume to decide whether to credit your end user; it is not a hold on funds Tesser has already credited. See [Why Availability Isn't Gated on the Screening Outcome](#why-availability-isnt-gated-on-the-screening-outcome). ::: ## Continuous Monitoring of Wallets for Inbound Payments You do not need to pre-register inbound payments in order to receive funds on-chain. Tesser monitors wallets for on-chain activity and when an inbound transaction is detected, will create a payment record, counterparties and accounts (if needed), and notify your integration via webhooks. Tesser will first become aware of an on-chain transaction when that transaction is confirmed in a block. When funds arrive on-chain, Tesser looks up the transaction against the Travel Rule data-exchange provider. If it corresponds to an authorization or Travel Rule message, the transfer is reconciled to the payment record created from that flow rather than creating a second record. ## How the Sending Wallet Is Identified Tesser identifies the participant(s) on the other side of an inbound payment in the payment's top-level `participants[]` array. For an inbound payment, the array can identify the `ORIGINATOR`, the `ORIGINATOR_VASP`, a `CUSTODY_SERVICE` and/or `INTERMEDIARY_VASP`, the `BENEFICIARY` (the counterparty or tenant that owns the receiving wallet), or a `CONTRACT` when the sending address is a contract rather than an end-user wallet. Which roles populate, where each one's identity data comes from, and how each is reconciled to your records depends on whether Travel Rule data accompanied the transfer and whether the sending wallet is VASP-hosted or self-hosted. :::note See [Payment Participants](/overviews/payment-participants) for the per-role tables, the `resource` object's fields, and the full set of `resolution` values. ::: Four points matter most for an inbound integration: - **Names are matched fuzzily.** Provider-supplied names are scored 0–100 against your counterparty and tenant records, and a score of 85 or above matches — so a typo or a different word order still resolves to the record you already hold. Where a non-beneficiary participant does not reach the threshold, Tesser creates a new counterparty for it and the entry carries `resolution: "COUNTERPARTY_CREATED"`. See [Payment Participants](/overviews/payment-participants) for what the scorer does and does not tolerate. - **Your beneficiary record is canonical.** Tesser never creates a new beneficiary record from provider data. If an originator VASP supplies a beneficiary name that does not match your record, the `BENEFICIARY` entry carries `resolution: "DID_NOT_MATCH"`. An inbound payment with `resolution: "DID_NOT_MATCH"` for the `BENEFICIARY` role is how you know a Travel Rule authorization was received but rejected prior to funds movement. Where no provider-supplied beneficiary name existed to match at all, the entry carries `resolution: "USER_SUPPLIED_ID"`. The failed step's `status_reasons[]` carries `transfers-9215` for this rejection; other Travel Rule rejections (e.g. a rejection recorded in the Notabene dashboard) carry `transfers-9203`. - **One sending wallet can produce more than one array entry.** A hot wallet used to send funds out of an exchange, for example, may be attributed both to the exchange itself (`ORIGINATOR_VASP`) and to the third-party custodian that holds its wallets (`CUSTODY_SERVICE`). - **Travel Rule data takes precedence for entity attribution.** Where a Travel Rule authorization flow supplied the identity data, Tesser treats it as the source of truth for the roles it covers. Wallet screening attribution still fills in roles the Travel Rule payload does not describe, e.g. a custody service or an intermediary VASP the originator VASP did not declare. ### Recording the Sending Wallet on the Payment When Tesser records an inbound payment from a wallet address it has not seen before, it automatically creates an unmanaged wallet account (`is_managed: false`) for the sending wallet and assigns it to the counterparty the sender reconciled to. If Tesser recognizes the wallet address — because the sender is a repeat sender, or because an outbound payment previously sent funds to this address — Tesser identifies the existing account by looking up the wallet address. `estimated.from.account_id` populates as soon as Tesser has the transaction, because the sending address is on the transaction itself — it does not wait for the screening outcome. For a direct on-chain transfer it is present from record creation; for a Travel Rule flow it fills in when funds land on-chain. The `participants[]` entry that identifies *who* that wallet belongs to arrives later, with the screening result. See [When Each Field Populates](#when-each-field-populates). The auto-created account follows this shape: ```json { "id": "2c7a9e1f-3b8d-4625-a0f4-6d1e5c9b2a8f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "tenant_id": null, "counterparty_id": "b8b8fc6c-62b5-4617-bc45-28ac30456f49", "type": "stablecoin_ethereum", "name": "External Wallet 0x742d35Cc6634...95f8fE0B", "crypto_wallet_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE0B", "is_managed": false, "assets": [ { "currency": "USDC", "network": "ETHEREUM", "available_balance": "0" } ], "created_at": "2024-03-01T10:02:00.000Z", "updated_at": "2024-03-01T10:02:00.000Z" } ``` The account is assigned to whichever record the sender reconciled to. Read the corresponding `participants[]` entry's `resource.resource_type` to know which: for a `COUNTERPARTY` the account carries `counterparty_id`, and looking that counterparty up returns its `tenant_id` for tenanted integrations; for a `TENANT` the sender resolved to one of your tenants directly; `WORKSPACE` means the funds came from one of your own treasury wallets. Subsequent inbound payments from the same wallet address reuse this account. The sending account's `assets[].available_balance` stays `"0"`. Tesser does not monitor balances on unmanaged accounts — the record exists to identify where the funds came from, not to track what that wallet holds. ### Counterparty Names for Unattributed Sending Wallets Where the sender cannot be identified by name (the transfer carried no Travel Rule data, and the wallet screening provider attributes the address to an unnamed wallet cluster rather than a named entity), Tesser still creates a counterparty so the payment has something to reconcile to. These counterparties are named after the cluster: the words "Wallet Cluster" followed by the full wallet address, for example `Wallet Cluster 0x742d35Cc6634C0532925a3b844Bc9e7595f8fE0B`. A cluster groups the address(es) the screening provider believes are controlled by the same owner. A counterparty with a Wallet Cluster name therefore means "we know these addresses belong to one owner, but not the specific identity of that owner." Repeat sends from any address in the cluster reconcile to the same counterparty. ## When Each Field Populates An inbound payment record is created at the earliest point Tesser has something to record, and its remaining fields fill in as the compliance checks resolve.
| Stage | What has happened | What populates | | --- | --- | --- | | **Travel Rule authorization request received** *(Travel Rule flows only)* | Tesser runs the beneficiary-name match and authorizes or rejects the request, then creates the payment record and its single `transfer` step in `created` status. Funds have not yet moved. | `estimated.to.account_id`, and the `estimated` amount, currency, and network at both the top level and the step level; `participants[]`; the `TRAVEL_RULE` entry in `provider_metadata[]`; and `funding_account_id` when the originator VASP sent an account number. `estimated.from.account_id` is left blank. `risk_status` is `unchecked` and `risk_status_reasons[]` is empty. | | **Funds land on-chain** | For a Travel Rule record, Tesser reconciles the transfer to it and now knows which wallet sent the funds; the step moves from `created` to `confirmed`. When no record matches, Tesser creates one now, with its step already in `confirmed`. Either way, wallet screening kicks off and the receiving wallet is credited. | The step's `status`, `transaction_hash`, and `confirmed_at`; and `estimated.from.account_id`. The receiving wallet's available and total balance are credited. For records created at this stage: `estimated.to.account_id`, network, and estimated amounts are also populated, with `participants[]` empty, `risk_status` `unchecked`, and `risk_status_reasons[]` empty. | | **Screening resolves** | The wallet screen completes and Tesser determines whether the sender's identity reconciles to existing records or requires new counterparties. | `risk_status` and `risk_status_reasons[]`; the `WALLET_RISK_SCREENING` entry in `provider_metadata[]`; and, for records created at the funds-landing stage, `participants[]`, all at once. | | **Block finalizes** | The transaction is final and can no longer be reorganized away. | The step moves to `completed`; the step-level and top-level `actual` overlays populate. The balance does not change here — it was credited at confirmation, and finalization makes that credit permanent. | | **Manual review** *(only when `risk_status` is `awaiting_decision`)* | Your reviewer records a decision. | `risk_status` moves to `manually_approved` or `manually_rejected`; `risk_reviewed_by` and `risk_reviewed_at` populate. `risk_status_reasons[]` is unchanged from the automatic outcome. |
`participants[]` is populated once in a single write. For the timing across all payment directions, see [When participants[] Populates](/overviews/payment-participants#when-participants-populates). ## Inbound Payment Creation (`payment.created`) `payment.created` fires when Tesser creates the payment record. When that happens depends on the entry path. :::note{title="About the payload examples on this page"} The webhook payloads below are **abridged** to the fields that matter at each stage — `// ...` marks fields present on the wire but omitted here. Every payload carries the complete payment resource, wrapped in the standard `{ id, type, created_at, data: { object } }` envelope. For the full field list see the [Payments API reference](/api/payments); for the envelope, see [Webhooks](/webhooks/general). ::: ### Travel Rule Authorization Flow For a payment that begins with a Travel Rule authorization request, `payment.created` fires **before funds move**. The payload records the destination account, the reconciled participants, the raw transfer record in `provider_metadata[]`, and the single `transfer` step in `created` status — the step exists to hold the money movement the authorization anticipates, but nothing has happened on-chain yet, so `transaction_hash` and `confirmed_at` are both `null`. The sending account is necessarily absent at this point because the transaction has not happened yet: - **`estimated.from.account_id` is `null`.** The originator VASP tells Tesser who is sending, not which wallet they will send from, so there is no sending account to reference until funds land. It is never pre-filled with a stand-in. - **`estimated.*.network` is populated.** Tesser only authorizes supported CAIP-19 assets, so the declared token identifies the network before funds move. `expires_at` is `null`, as it is on every inbound payment. #### The Originator's Funding Account The originator VASP usually sends the originator's account number with its own records (IVMS101 `accountNumber`): a customer id, an IBAN, or any other identifier the VASP uses. Tesser records it as the payment's `funding_account_id`, pointing at a [generic account](/how-tos/create-an-account#generic-account) owned by the record in the `ORIGINATOR` entry of `participants[]`: - **Tesser reuses the owner's account for that number when one exists,** whoever created it. If you registered the originator's account yourself with [`POST /v1/accounts/generic`](/how-tos/create-an-account#generic-account), the payment points at that account. Repeat payments from the same originator and number share one account. - **Otherwise Tesser creates one,** with `created_by: "SYSTEM"`, the originator's account number as `generic_account_number`, and a name of the form `" (travel rule)"`. You can rename it with `PATCH /v1/accounts/{account_id}`. - **Tesser uses the first non-empty account number** the VASP sent and doesn't interpret it. It is never matched against wallet addresses, so it never points at the sending wallet's account, even when the number looks like one. The raw value stays in the `TRAVEL_RULE` entry in `provider_metadata[]`. - **`funding_account_id` is `null`** when the VASP sent no account number or named no originator. Nothing else about the payment changes. - **When the funds land before the Travel Rule data,** Tesser has already created the payment from the on-chain transfer and sent `payment.created`. The Travel Rule data then attaches to that payment and sets `funding_account_id` by the rules above, and a `payment.updated` delivers it along with the new `participants[]`. When the Travel Rule data names the originator VASP, wallet screening runs again instead, and its `payment.risk_updated` carries the same fields, unless a risk review decision is already recorded on the payment: screening doesn't run again then, and `payment.updated` delivers them. - **Once set, it doesn't change.** A redelivered Travel Rule authorization leaves the payment as it is, and Travel Rule data arriving after the funds never replaces a funding account the payment already has. A generic account has no network, currency, or balance. It identifies the originator. It can't send or receive funds. #### Beneficiary Name Match Once Tesser receives the Travel Rule data from the Originator VASP, it will check whether the Beneficiary name sent by the Originator matches the name your organization recorded for the counterparty owner of the destination wallet account. In other words, does your organization and the Originator VASP agree that "John Smith" owns the destination wallet, or does the Originator VASP think a different entity owns the destination wallet? If the check passes, Tesser will return an "authorization" response to the Travel Rule data provider. If the check fails, Tesser will instruct the Travel Rule provider to reject the inbound payment. The result of the check is visible on the `participants[]`. The `BENEFICIARY` entry in `participants[]` will be `EXACT_MATCH` or `PARTIAL_MATCH` for an authorized request, `DID_NOT_MATCH` for one Tesser rejected on the beneficiary-name match. When the check fails, the failed step's `status_reasons[]` carries `transfers-9215`; other Travel Rule rejections carry `transfers-9203`. ```json { "type": "payment.created", "data": { "object": { "id": "9f3b7e1c-4a2d-4f69-b5a8-0c6e2d9f1a3b", "direction": "inbound", "funding_account_id": "5d2e8c41-9a7b-4f13-b6e0-8c3f1a2d7e94", "estimated": { "from": { "account_id": null, "amount": "1000.00", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "1000.00", "currency": "USDC", "network": "BASE" } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [ { "role": "ORIGINATOR", "resource": { "resource_type": "COUNTERPARTY", "id": "3c9a1e57-8b2f-4d06-9e41-7a5c2f8d1b60", "name_match_score": "23", "name_match_threshold": "85", "name_match_comparison_id": "b41f7c29-6d83-45ea-a1c7-9f20d3e8b574", "resolution": "COUNTERPARTY_CREATED" } } // ... ORIGINATOR_VASP and BENEFICIARY entries ], "provider_metadata": [ { "provider_metadata_type": "TRAVEL_RULE", "provider_metadata_key": "NOTABENE", "provider_metadata_data": { // ... the raw transfer record, passed through exactly as the originator VASP sent it } } ], "expires_at": null, "steps": [ { "step_sequence": 1, "step_type": "transfer", "status": "created", "transaction_hash": null, "confirmed_at": null // ... } ] // ... } } } ``` When funds subsequently land on-chain, Tesser matches the on-chain transaction to the payment record and moves the step from `created` to `confirmed` status. That transition fires `step.confirmed` followed by `payment.updated` webhooks, which carry the step's new status, `transaction_hash`, `confirmed_at`, and the now-known `estimated.from.account_id`. From that point the payment follows the same path as a direct on-chain transfer. `step.confirmed` is specific to this entry path. A direct on-chain transfer never fires it, because its step is already `confirmed` when the record is created — there is no transition to report. ### Direct On-Chain Transfer For a transfer that arrives with no Travel Rule data, `payment.created` fires when Tesser sees the transaction in a block. The step is already in `confirmed` and the `estimated` overlay is fully populated at both the top level and the step level, `estimated.from.account_id` included — the sending address is on the transaction, so the sending account is known from the outset. `participants[]` and `provider_metadata[]` are empty, because both depend on the screening result, and the `actual` overlay is present with all sub-fields still `null`. ```json { "type": "payment.created", "data": { "object": { "id": "9f3b7e1c-4a2d-4f69-b5a8-0c6e2d9f1a3b", "direction": "inbound", "estimated": { "from": { "account_id": "2c7a9e1f-3b8d-4625-a0f4-6d1e5c9b2a8f", "amount": "1000.00", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "1000.00", "currency": "USDC", "network": "ETHEREUM" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "provider_metadata": [], "steps": [ { "step_sequence": 1, "step_type": "transfer", "status": "confirmed", "transaction_hash": "0xabcdef0123", "confirmed_at": "2024-03-01T10:02:00.000Z", "completed_at": null // ... } ] // ... } } } ``` ## Inbound Payment Risk Review (`payment.risk_updated`) Tesser screens the sending wallet against your organization's risk policy and reports the outcome on the payment as `risk_status` via a `payment.risk_updated` webhook. Screening runs on every inbound payment, whether or not a Travel Rule authorization flow preceded it. See [Wallet Risk Screening Data](/overviews/wallet-risk-screening) for how to read `risk_status_reasons[]`, and [Wallet Screening Risk Categories](/overviews/wallet-screening-risk-categories) for the category values. For a payment that arrived as a direct on-chain transfer, the `payment.risk_updated` payload is also where `participants[]` first populates: identifying who the sending wallet belongs to depends on the screening attribution, so the two land together. The sending *account* is not part of this — `estimated.from.account_id` was already populated when Tesser recorded the transaction. Risk screening runs in parallel with on-chain finalization, so `payment.risk_updated` and `step.completed` can fire in either order. If your policy requires manual review, an initial `payment.risk_updated` fires with `risk_status: "awaiting_decision"`. Optionally submit a decision via the [risk review decision API](/api/payments#submit-risk-review) or in the Tesser dashboard. If a decision is recorded, `risk_reviewed_by` and `risk_reviewed_at` are populated and a second `payment.risk_updated` fires with `risk_status` transitioned to `manually_approved` or `manually_rejected`. :::tip{title="Test each risk outcome"} In sandbox you can simulate an inbound payment at a chosen risk outcome on demand with `POST /v1/payments/simulate-inbound`. See [Test Compliance Risk Checks](/how-tos/test-compliance-risk-checks#test-inbound-risk-checks). ::: Example webhook payload when the sending wallet is automatically approved per your organization's policy. The sending wallet was attributed to a VASP whose name did not exactly match an existing counterparty, so a new counterparty was created for it; the beneficiary carries `USER_SUPPLIED_ID` because no Travel Rule data accompanied the transfer and there was no supplied name to match: ```json { "type": "payment.risk_updated", "data": { "object": { "id": "9f3b7e1c-4a2d-4f69-b5a8-0c6e2d9f1a3b", "estimated": { "from": { "account_id": "2c7a9e1f-3b8d-4625-a0f4-6d1e5c9b2a8f", "amount": "1000.00", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "1000.00", "currency": "USDC", "network": "ETHEREUM" } }, "risk_status": "automatically_approved", "risk_status_reasons": [], "participants": [ { "role": "ORIGINATOR_VASP", "resource": { "resource_type": "COUNTERPARTY", "id": "b8b8fc6c-62b5-4617-bc45-28ac30456f49", "name_match_score": "62", "name_match_threshold": "85", "name_match_comparison_id": "f6a2c81d-3e97-4b5a-a24e-8c1d0b9e6f42", "resolution": "COUNTERPARTY_CREATED" } }, { "role": "BENEFICIARY", "resource": { "resource_type": "COUNTERPARTY", "id": "e8b3d016-4f7a-49c2-95d8-2c61a0f9e735", "name_match_score": null, "name_match_threshold": null, "name_match_comparison_id": null, "resolution": "USER_SUPPLIED_ID" } } ], "provider_metadata": [ { "provider_metadata_type": "WALLET_RISK_SCREENING", "provider_metadata_key": "TRM_LABS", "provider_metadata_data": { // ... the raw screening response } } ] // ... } } } ``` Example webhook payload after a reviewer manually approves the sending wallet. The screen surfaced a High-severity counterparty exposure above its materiality threshold, which routed the payment to review: ```json { "type": "payment.risk_updated", "data": { "object": { "id": "9f3b7e1c-4a2d-4f69-b5a8-0c6e2d9f1a3b", "risk_status": "manually_approved", "risk_status_reasons": [ { "risk_status_reason_type": "COUNTERPARTY", "risk_status_reason_category": "Mixer", "risk_status_reason_message": "Address has COUNTERPARTY exposure to Mixer", "risk_status_reason_severity": "High", "risk_status_reason_significance": "DECISIVE", "risk_status_reason_participant_id": null, "volume_usd_percent": "38.43", "volume_usd_percent_threshold": "2", "volume_usd_amount": "1225310.31", "volume_usd_amount_threshold": null, "confidence_score": null, "confidence_score_threshold": null } ], "risk_reviewed_by": "8e1f5a3b-2c4d-4e6f-9a8b-7d3c5e9f1a2b", "risk_reviewed_at": "2024-03-01T10:05:30.000Z" // ... } } } ``` ## Inbound Payment Completion (`step.completed` and `payment.updated`) When the block finalizes, several things occur: - The step status transitions from `confirmed` to `completed`. - The step-level and top-level `actual` overlays populate. - Tesser fires `step.completed` followed by `payment.updated` webhooks. Finalization does not move the balance. The receiving wallet was credited earlier, when the transaction was confirmed in a block — see [Spending the Inbound Funds](#spending-the-inbound-funds). What finalization changes is that the credit can no longer be reversed. The `payment.updated` payload below shows a terminal state in which risk was manually approved before the block finalized. Because screening and finalization are independent, `step.completed` can fire first — in that case this payload's `risk_status` will still be `"unchecked"` or `"awaiting_decision"`, and `participants[]` will still be empty for a direct on-chain transfer. The `actual` overlay is unaffected either way: it mirrors the settled transaction, including `actual.from.account_id`, and does not wait on the screen. ```json { "type": "payment.updated", "data": { "object": { "id": "9f3b7e1c-4a2d-4f69-b5a8-0c6e2d9f1a3b", "actual": { "from": { "account_id": "2c7a9e1f-3b8d-4625-a0f4-6d1e5c9b2a8f", "amount": "1000.00", "currency": "USDC", "network": "ETHEREUM" }, "to": { "account_id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "amount": "1000.00", "currency": "USDC", "network": "ETHEREUM" } }, "risk_status": "manually_approved", "steps": [ { "step_sequence": 1, "status": "completed", "transaction_hash": "0xabcdef0123", "confirmed_at": "2024-03-01T10:02:00.000Z", "completed_at": "2024-03-01T10:14:00.000Z", "failed_at": null // ... } ] // ... } } } ``` ## Spending the Inbound Funds The receiving wallet's available and total balance are credited when the step reaches `confirmed` — that is, as soon as the transaction is in a block, not when the block finalizes. **We nevertheless recommend waiting for the step to reach `completed` before spending inbound funds.** Do not attempt on-chain transfers, rebalances, payouts, or off-ramps to fiat against a credit that is only `confirmed`. Until the block finalizes the transaction can still be reorganized away, and if that happens Tesser reverses the credit — leaving you having spent against funds that no longer exist. Finalization is what makes the credit permanent. The credit lands on the receiving managed wallet's matching asset — the entry whose `currency` and `network` match the payment. Fetch it with `GET /v1/accounts/{estimated.to.account_id}`: ```json { "id": "7a3d8f2e-6b4c-4a91-b8e5-2f9c1d7e3a0b", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "tenant_id": null, "counterparty_id": "e8b3d016-4f7a-49c2-95d8-2c61a0f9e735", "type": "stablecoin_ethereum", "name": "Jordan Rivera USDC Wallet", "crypto_wallet_address": "0x9f2c4e8a1b6d3f507c9e2a4b8d1f6039e5c7a2b4", "is_managed": true, "assets": [ { "currency": "USDC", "network": "ETHEREUM", "available_balance": "1000.00" } ], "created_at": "2024-02-14T08:30:00.000Z", "updated_at": "2024-03-01T10:14:00.000Z" } ``` Balances are returned in major units, the same convention the payment's `estimated` and `actual` amounts use, so a balance and a payment amount are directly comparable. The balance is credited regardless of the risk outcome — risk screening does not hold the funds. We recommend, separately from the finality point above, that you wait for an approving `risk_status` (`automatically_approved` or `manually_approved`) before spending inbound funds, and reconcile any `automatically_rejected` or `manually_rejected` outcome against your own compliance policy before putting the funds to use. ### Why Availability Isn't Gated on the Screening Outcome The credit tracks the chain, not the screening outcome. Tesser credits the wallet when the transaction lands and reverses that credit only if the chain itself takes the transaction back; `risk_status` never holds funds. That is a deliberate choice: if Tesser withheld a rejected payment from available balance, the funds would be stranded in the receiving wallet with no way to act on them, because an on-chain transfer cannot be sent back automatically. Crediting the balance preserves your ability to respond to a rejection — returning the funds to the originator, say, or moving them to a dedicated wallet to quarantine them. Your organization's compliance policy should determine what to do with the funds after rejection. :::warning{title="Do not naively return funds to the sender's address"} The sending wallet may not be a self-hosted wallet under the original sender's control. It could be an exchange hot wallet, a smart-contract address, or any other on-chain address — the `participants[]` array will tell you when the sender was attributed to a hosting VASP (`ORIGINATOR_VASP`) or to a contract (`CONTRACT`). Returning funds to such an address could deposit them into a pooled exchange account or a contract from which they cannot be recovered. If you need to return funds, coordinate the return address with the sender out of band rather than reusing the sender account's `crypto_wallet_address` (retrievable by looking up `estimated.from.account_id` via `GET /v1/accounts/{account_id}` — use `estimated`, not `actual`, since you may be acting on a rejection before the block finalizes). ::: ## Failure Modes for Inbound Payments An inbound payment fails in one of two ways, and both look the same on the wire: the step transitions to `failed`, `step.failed` fires, `payment.updated` follows with the failed step, and the funds do not remain in the receiving wallet. What distinguishes them is how far the payment got. A chain reorganization fails a step that had reached `confirmed` — there was a transaction, it was credited, and both were undone. A rejected authorization or a sender who never broadcasts fails a step still in `created` — no transaction ever existed and nothing was ever credited. Read the step's prior status and its `status_reasons` to tell which occurred. A rejection caused by the beneficiary name match carries `transfers-9215`; other Travel Rule rejections carry `transfers-9203`. ### Chain Reorganization If the block containing the on-chain transaction does not finalize — most commonly because a chain reorganization removes the transaction from the canonical chain — the inbound payment terminates and the credit made at confirmation is reversed. This is the case the "wait for `completed`" recommendation exists for: the funds were in the wallet's available balance, and then they were not. What you will observe: - `step.failed` fires. The step transitions from `confirmed` to `failed`, `failed_at` populates, and step-level `status_reasons` carries the failure detail. - `payment.updated` fires alongside, carrying the full updated payment with the failed step. - Top-level `actual.*` stays all-null because nothing settled. `estimated.*` remains populated from record creation, preserving the original observation; `desired.*` is all-null as always for inbound. - **Risk screening is unaffected.** A reorganization fails the step; it does not cancel a screen in progress or suppress its result. A screen that was still running completes normally and `payment.risk_updated` fires as usual, and a screen that had already completed keeps its `risk_status`, `risk_status_reasons[]`, and `WALLET_RISK_SCREENING` entry in `provider_metadata[]` on the failed payment. The screening outcome describes the sending wallet, which does not become less true because the transaction was undone. For a payment that came through a Travel Rule authorization flow, `participants[]` and the `TRAVEL_RULE` entry in `provider_metadata[]` were written at record creation and are likewise preserved. - The credit is reversed. The receiving wallet's available and total balance return to what they were before the transaction was confirmed. Example `payment.updated` webhook payload at the terminal failed state, for a payment that arrived as a direct on-chain transfer whose screening had not yet completed when the reorganization was detected. `risk_status` is `unchecked` and `participants[]` is empty *at this moment only* — the screen is still running, and a later `payment.risk_updated` will carry its outcome: ```json { "type": "payment.updated", "data": { "object": { "id": "9f3b7e1c-4a2d-4f69-b5a8-0c6e2d9f1a3b", "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "risk_status": "unchecked", "risk_status_reasons": [], "participants": [], "steps": [ { "step_sequence": 1, "status": "failed", "status_reasons": [ { "error_code": "transfers-9202", "error_message": "The inbound transaction was reorganized away before finality" } ], "transaction_hash": "0xabcdef0123", "completed_at": null, "failed_at": "2024-03-01T10:14:00.000Z" // ... } ] // ... } } } ``` ### Rejected or Never-Funded Travel Rule Records A payment record created from a Travel Rule authorization request that Tesser rejected on the beneficiary-name match — or one whose funds simply never arrive — records the authorization outcome and the supplied identity data, but no money ever moves against it. The step fails rather than lingering: this is a terminal state, reached without any on-chain transaction. - **The step transitions from `created` to `failed`,** `failed_at` populates, and step-level `status_reasons` carries the reason. `step.failed` fires, followed by `payment.updated`. Note the step never reaches `confirmed` — that is what separates this from a chain reorganization, where the step was `confirmed` before it failed. - `estimated.from.account_id`, the step's `transaction_hash`, `confirmed_at`, and every `actual.*` field stay `null`; there was never an on-chain transaction to reconcile. The declared amount, currency, network, and destination remain on `estimated`. - `participants[]`, `funding_account_id`, and the `TRAVEL_RULE` entry in `provider_metadata[]` are preserved as written at record creation. A rejected authorization is identifiable from the `BENEFICIARY` entry's `resolution: "DID_NOT_MATCH"`. - `risk_status` remains `unchecked` — wallet screening runs against the sending wallet, which is only known once funds land, so it never runs for a record that is never funded. This is the one case where `unchecked` is terminal rather than transitional: the step reaching `failed` is your signal that no screening outcome is coming. - The receiving wallet's balance is untouched. Nothing was ever credited, because the credit happens at `confirmed` and this step never got there. ## Inbound Payment Event Sequence The happy path for an inbound payment fires the following webhook events. `payment.risk_updated` and `step.completed` run on independent clocks and can fire in either order; the receiving wallet's balance is credited at `confirmed`, before either of them, regardless of the risk outcome. | Event | Fires when | | --- | --- | | `payment.created` | Tesser creates the payment record, always with one `transfer` step and the `estimated` overlay populated at the top level and the step level. **Travel Rule flow:** fires when the authorization request is handled, before funds move; the step is in `created`, `estimated.from.account_id` is `null`, and the declared amount, currency, network, destination, `participants[]`, and `TRAVEL_RULE` metadata are present. **Direct on-chain transfer:** fires when the transaction is included in a block; the step is already in `confirmed`, `estimated.from.account_id` is populated, and `participants[]` and `provider_metadata[]` are still empty. | | `step.confirmed` | *(Travel Rule flow only.)* Funds land on-chain and Tesser matches the transaction to the record created from the authorization request, moving the step from `created` to `confirmed`. A direct on-chain transfer does not fire this event — its step is already `confirmed` at `payment.created`, so there is no transition. | | `payment.updated` | *(Travel Rule flow only.)* Fires alongside `step.confirmed`, carrying the step's new status, `transaction_hash`, `confirmed_at`, and the now-known `estimated.from.account_id`. | | `payment.risk_updated` | The risk screening of the sending wallet completes. `risk_status` transitions to `automatically_approved`, `automatically_rejected`, or `awaiting_decision`. For a direct on-chain transfer the same payload carries the newly populated `participants[]` and the `WALLET_RISK_SCREENING` entry in `provider_metadata[]`. For the manual-review path, a second `payment.risk_updated` fires once a reviewer records a decision via the [risk review decision API](/api/payments#submit-risk-review) or in the Tesser dashboard, transitioning `risk_status` to `manually_approved` or `manually_rejected` and populating `risk_reviewed_by` and `risk_reviewed_at`. | | `step.completed` | The block finalizes. The step transitions to `completed` and the step-level `actual` overlay populates. | | `payment.updated` | Fires alongside `step.completed`. The top-level `actual` overlay populates to reflect the finalized amounts. For a Travel Rule flow this is the second `payment.updated` on the payment. | The step's status is the shortest summary of where a payment stands: `created` (recorded, nothing on-chain yet) → `confirmed` (in a block) → `completed` (finalized), or `failed` from either of the first two. Every failure path shares one shape — `step.failed` followed by `payment.updated`, with the payment terminating without crediting funds — whether the cause is a chain reorganization, a rejected Travel Rule authorization, or a sender who never broadcasts. See [Failure Modes for Inbound Payments](#failure-modes-for-inbound-payments) for what distinguishes them on the record. ## Reconciliation via the API If your integration misses a webhook or needs to reconcile state on a schedule, fetch inbound payments via the Payments API: ```bash GET /v1/payments?direction=inbound ``` Auto-created unmanaged wallet accounts are also retrievable via `GET /v1/accounts` with the `is_managed=false` filter, and the counterparties they belong to via `GET /v1/counterparties`. See the [Payments API reference](/api/payments), [Accounts API reference](/api/accounts), and [Counterparties API reference](/api/counterparties) for the full set of query parameters. --- ## Document: Withdraw Funds via a Liquidity Provider Move funds out of your managed accounts at a liquidity provider to your external bank account. URL: /how-tos/manage-your-treasury/withdraw-funds-via-a-liquidity-provider # Withdraw Funds via a Liquidity Provider Withdrawals via a liquidity provider move funds out of your managed accounts to your external bank account. :::note Funds can only be withdrawn from a liquidity provider to a bank account. If the destination is a wallet, the resource should be a [rebalance](./rebalance-funds) (when the wallet is Tesser-provisioned) or a [payment](/how-tos/send-a-stablecoin-payout/create-a-payout-from-a-wallet) (when the wallet belongs to a third party). ::: Before creating a withdrawal, review the [Funds Movement Lifecycle and Data Model](/overviews/funds-movement-lifecycle-and-data-model) overview to understand the shared data shape, lifecycle phases, and statuses that apply to withdrawals and Tesser's other funds-movement resources. ## Prerequisites Liquidity providers require funds movement to and from their platforms to be "first-party" only. For a withdrawal, the destination bank account must be registered both with Tesser and with the liquidity provider executing the withdrawal. Where supported, Tesser will register the destination bank account at your enrolled liquidity provider on your behalf. This mirrors how deposits work, except funds flow toward the fiat bank account rather than from it. For more information on account creation, see [Create an account](/how-tos/create-an-account). The `desired.from.account_id` must be one of your managed accounts at a liquidity provider (for example, an OpenFX USD ledger or a Circle USDC ledger) or a self-custodial wallet. To send funds to a third party, see [Send a Stablecoin Payout](/how-tos/send-a-stablecoin-payout/create-a-stablecoin-payout). ## Withdrawal Workflow A withdrawal executes over one or more steps. - A `transfer` step moves funds from one account to another — for example, an on-chain push from a wallet to a provider's ledger, or a fiat transfer from a provider's ledger to your external bank account. A `transfer` step's `from` and `to` may differ in currency: when a provider redeems a stablecoin into fiat as part of a single API call to the provider (e.g., Circle redeeming USDC into USD on the way out to your bank), the redemption is modeled as one cross-currency `transfer` step. - A `swap` step exchanges currencies within the same account (the step's `estimated.from.account_id` and `estimated.to.account_id` are equal). Tesser uses a standalone `swap` step when the provider's ledger can hold both source and destination currencies (e.g., OpenFX redeeming USDT into USD on its own ledger). For providers whose redemption is bundled with the withdrawal itself (e.g., Circle), the currency change rides on a `transfer` step instead, and the withdrawal has no standalone `swap` step. Each step has a status. See [Step Statuses](/overviews/funds-movement-lifecycle-and-data-model#step-statuses) for the full status taxonomy. Tesser plans the sequence of steps during the [Planning](/overviews/funds-movement-lifecycle-and-data-model#planning) phase, executes them during the [Execution](/overviews/funds-movement-lifecycle-and-data-model#execution) phase, and populates the top-level `actual.*` overlay when the withdrawal reaches its [Terminal State](/overviews/funds-movement-lifecycle-and-data-model#terminal-state-and-divergence). ## Exchange Rates for Withdrawals When a withdrawal crosses currencies — whether as a standalone `swap` step (e.g., OpenFX redeeming USDT to USD on its own ledger) or as a cross-currency `transfer` step (e.g., Circle redeeming USDC to USD as part of the withdrawal) — the exchange rate depends on the liquidity provider executing the withdrawal. As with deposits, 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 | OpenFX | Exchange rate between sell and buy currencies fluctuates; current rates can be guaranteed for a period of time | | Exchange | Kraken | Exchange rate fluctuates; no guaranteed rate | ## Withdrawal Creation Submit a request to [`POST /v1/treasury/withdrawals`](/api/treasury#create-withdrawal). - If applicable, you should specify on which `tenant`'s behalf you are requesting the withdrawal. - For the withdrawal, populate the following fields in the `desired` object: - `desired.from.account_id`: The identifier of one of your managed accounts at a liquidity provider, for example, an OpenFX or Circle ledger, or a self-custodial wallet. - `desired.from.amount`: Amount to withdraw, in `desired.from.currency`. - `desired.from.currency`: Currency held at `desired.from.account_id`. - `desired.from.network`: Only specified when `desired.from.account_id` is a self-custodial wallet (e.g., `BASE`). - `desired.to.account_id`: Identifier of your external destination — the bank account registered with Tesser ahead of time. - `desired.to.currency`: Currency to deliver to the destination (e.g., `USD` when withdrawing a stablecoin into a USD bank account). *Note: The withdrawal'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 3 scenarios:** 1. Scenario 1 — Withdrawing USDT from an OpenFX ledger to your USD bank account. 2. Scenario 2 — Withdrawing USDC from a Circle ledger to your USD bank account. 3. Scenario 3 — Withdrawing USDC from a self-custodial wallet via OpenFX to your USD bank account. Example request (USDT from an OpenFX ledger withdrawn to your external USD bank account): ```json { "tenant_id": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "currency": "USD" } } } ``` Example request (USDC from a Circle ledger withdrawn via Circle redemption to your external USD bank account): ```json { "tenant_id": null, "desired": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "currency": "USD" } } } ``` Example request (USDC on BASE from a self-custodial wallet, withdrawn via OpenFX to your external USD bank account): ```json { "tenant_id": null, "desired": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "currency": "USD" } } } ``` In the API response, Tesser will create and return an `id` for the withdrawal. The top-level `balance_status` is initially `unreserved`. For ledger sources (Scenarios 1 and 2), Tesser performs the balance check asynchronously and publishes the outcome via a `withdrawal.balance_updated` webhook shortly after `withdrawal.quote_created`. For wallet sources (Scenario 3), the balance check runs synchronously inside your call to the step-signing API. See [Withdrawal Balance Check](#withdrawal-balance-check-withdrawalbalance_updated) below. The `estimated` and `actual` overlays are all-null at creation; `estimated` is populated when `withdrawal.quote_created` fires, and `actual` is populated when the withdrawal reaches its terminal state. Example response (USDT from an OpenFX ledger withdrawn to your external USD bank account): ```json { "data": { "id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example response (USDC from a Circle ledger withdrawn via Circle redemption to your external USD bank account): ```json { "data": { "id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example response (USDC on BASE from a self-custodial wallet, withdrawn via OpenFX to your external USD bank account): ```json { "data": { "id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` ## Withdrawal Quote Created (`withdrawal.quote_created`) After your `POST /v1/treasury/withdrawals` request is accepted, Tesser plans the route the funds will take and obtains a reference exchange rate. Tesser then sends a `withdrawal.quote_created` webhook — the first webhook fired for the withdrawal. The payload carries the planned `steps[]` array (each step with `status: "created"`) together with the populated `estimated` overlay at both the withdrawal and step levels. This webhook always fires, even for same-currency withdrawals, to keep the lifecycle uniform across resource types. For cross-currency withdrawals, the ratio of `estimated.from.amount` to `estimated.to.amount` is the indicative exchange rate at the liquidity provider. Circle redemptions are 1:1, so for Scenario 2 the indicative rate matches the source amount. Example `withdrawal.quote_created` webhook (USDT from an OpenFX ledger withdrawn to your external USD bank account; swap step + transfer step): ```json { "id": "b5e9c3a7-2f4d-48e6-8b1c-7a3f9d2e5b4c", "type": "withdrawal.quote_created", "created_at": "2025-12-01T10:00:00.700Z", "data": { "object": { "id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8d3f1c6a-4b9e-4275-9a8d-2c5e7f4b1a3d", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "9e4d2a7c-1f5b-4836-a0c2-7d6e3f1b9c8a", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` :::note For Scenario 1, the swap step models OpenFX's USDT-to-USD redemption: USDT is debited and USD is credited at the same OpenFX ledger account (`estimated.from.account_id` and `estimated.to.account_id` both equal the OpenFX ledger UUID). The follow-on transfer step then pushes the redeemed USD from the OpenFX ledger to your external bank account. ::: Example `withdrawal.quote_created` webhook (USDC from a Circle ledger withdrawn via Circle redemption to your external USD bank account; single cross-currency transfer step): ```json { "id": "c7f3a9d2-5b8e-4f14-a1c6-3d9b2e7f4a5c", "type": "withdrawal.quote_created", "created_at": "2025-12-01T10:00:01.000Z", "data": { "object": { "id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "3a8e2f5c-9d4b-4f73-a2c0-6b1d3e9f7c4a", "withdrawal_id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` :::note For Scenario 2, Circle's USDC-to-USD redemption is absorbed inside the single `transfer` step at parity (1:1, no slippage): `estimated.from.currency` is `USDC` (debited at the Circle ledger) and `estimated.to.currency` is `USD` (credited at your external bank). There is no standalone `swap` step. ::: Example `withdrawal.quote_created` webhook (USDC on BASE from a self-custodial wallet, withdrawn via OpenFX to your external USD bank account; on-chain transfer + swap + bank transfer): ```json { "id": "d8e2c5a9-4b7f-4135-9c2d-6f3a1b8e4d7c", "type": "withdrawal.quote_created", "created_at": "2025-12-01T10:00:01.500Z", "data": { "object": { "id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "2c8a4e7b-5f1d-4936-a8c0-3e9b1d6f2a5c", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.500Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "6f4b9d3e-1a8c-4275-b0e6-5d2f8a3c9b1d", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 2, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.500Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "8e5c1f9a-3d7b-4148-a6c0-2f4d8b1e9c3a", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.500Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.500Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` :::note For Scenario 3, Step 1 is an on-chain transfer that delivers USDC from the wallet to the OpenFX ledger account. Because the funds movement happens on chain, the step's `from.network` and `to.network` are both populated with `BASE` in the `estimated` and `actual` overlays. Step 2 is a swap at the OpenFX ledger that converts USDC into USD (`estimated.from.account_id` and `estimated.to.account_id` both equal the OpenFX ledger UUID); both `network` fields are `null` because the swap is purely a ledger-level adjustment. Step 3 pushes the redeemed USD from the OpenFX ledger to your external bank account. ::: ## Withdrawal Balance Check (`withdrawal.balance_updated`) Withdrawals debit funds from the managed account at `desired.from.account_id`, so Tesser performs a balance check before the withdrawal executes. When the balance check runs and how it surfaces depends on the type of account: - **Ledger sources (Scenarios 1 and 2):** the check runs asynchronously after the withdrawal is created. Shortly after `withdrawal.quote_created` fires, Tesser reads the ledger's available balance and emits `withdrawal.balance_updated` with the outcome. - **Wallet sources (Scenario 3):** the check runs synchronously inside your call to the [step-signing API](/api/treasury#sign-withdrawal-step). On success, the sign endpoint accepts your signature and `withdrawal.balance_updated` fires with `balance_status: "reserved"` from the sign-API path alongside `step.signed`. On insufficient funds, the sign endpoint returns a 4XX response and `withdrawal.balance_updated` fires with `balance_status: "awaiting_funds"`. Tesser monitors the wallet's on-chain balance and republishes `step.signature_requested` (with a refreshed `signature_requested_at`) once the wallet is funded; repeat the signing flow with the refreshed step to retry, until `expires_at`. In both cases, a `reserved` `balance_status` means execution can proceed and `balance_reserved_at` records when the reservation succeeded. See [Balance Statuses](/overviews/funds-movement-lifecycle-and-data-model#balance-statuses). :::note If `desired.from.account_id` does not have sufficient funds when the balance check runs, `balance_status` is set to `awaiting_funds`. For ledger sources, Tesser republishes `withdrawal.balance_updated` as the ledger balance is re-evaluated. For wallet sources, Tesser republishes `step.signature_requested` once on-chain funding is observed, and the next `withdrawal.balance_updated` (with `balance_status: "reserved"`) fires on the subsequent successful sign. The retry loop continues until funds arrive or the withdrawal expires — see [Failure Modes for Withdrawals](#failure-modes-for-withdrawals). ::: Example `withdrawal.balance_updated` webhook (Scenario 1, balance reserved at the source OpenFX ledger): ```json { "id": "a4b6c8d0-3e5f-4a1b-8c2d-4e6f8a0b1c2d", "type": "withdrawal.balance_updated", "created_at": "2025-12-01T10:00:01.000Z", "data": { "object": { "id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8d3f1c6a-4b9e-4275-9a8d-2c5e7f4b1a3d", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "9e4d2a7c-1f5b-4836-a0c2-7d6e3f1b9c8a", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ## Withdrawal Step Execution Once funds have been reserved, Tesser begins executing each step in `step_sequence` order. The shape of step execution differs by scenario. ### Scenario 1: Withdrawing USDT from an OpenFX ledger For Scenario 1, Step 1 is a swap at the OpenFX ledger that redeems USDT into USD. Tesser submits the trade, observes its acceptance, and observes its fill in close succession, so `step.submitted`, `step.confirmed`, and `step.completed` arrive close together. The swap's `estimated.from.account_id` and `estimated.to.account_id` are equal — both are the OpenFX ledger UUID `2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b`. Step 2 is the bank transfer that pushes the redeemed USD from the OpenFX ledger to your external bank account. It follows the standard step lifecycle: `step.submitted` fires when OpenFX accepts the withdrawal instruction; `step.confirmed` fires when OpenFX's local payment network confirms acceptance of the outbound push; `step.completed` fires when funds settle at the external bank account. Example `step.completed` webhook for Scenario 1, swap step (step 1): ```json { "id": "a4b9c5e2-7d3f-4218-9a6c-5e1f8d3b2a7c", "type": "step.completed", "created_at": "2025-12-01T10:02:00.500Z", "data": { "object": { "id": "8d3f1c6a-4b9e-4275-9a8d-2c5e7f4b1a3d", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.425", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:02:00.500Z", "submitted_at": "2025-12-01T10:02:00.000Z", "confirmed_at": "2025-12-01T10:02:00.300Z", "completed_at": "2025-12-01T10:02:00.500Z", "failed_at": null } } } ``` :::note For ledger-internal swap steps, `transaction_hash` is always `null` — the redemption happens entirely within the provider's books and never touches a chain. ::: Example `step.completed` webhook for Scenario 1, transfer step (step 2; terminal step): ```json { "id": "e1d6f4a3-9c8b-4f25-a7c0-3b1d8e9f4c5a", "type": "step.completed", "created_at": "2025-12-01T11:30:00.500Z", "data": { "object": { "id": "9e4d2a7c-1f5b-4836-a0c2-7d6e3f1b9c8a", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.425", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.425", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.500Z", "submitted_at": "2025-12-01T10:02:30.000Z", "confirmed_at": "2025-12-01T11:25:00.000Z", "completed_at": "2025-12-01T11:30:00.500Z", "failed_at": null } } } ``` ### Scenario 2: Withdrawing USDC from a Circle ledger For Scenario 2, Tesser initiates the withdrawal via Circle's API as a single call. The withdrawal executes over **one** `transfer` step that moves USDC from the Circle ledger account to your external USD bank account, absorbing the USDC→USD redemption inside the step. `estimated.from.currency` is `USDC` (debited at the Circle ledger) and `estimated.to.currency` is `USD` (credited at your bank). Circle's redemption is 1:1, so `estimated.to.amount` matches `estimated.from.amount` at the indicative quote. Circle accepts the withdrawal synchronously, so `step.submitted` and `step.confirmed` fire in close succession. Final settlement at the receiving bank lands later via Circle's terminal webhook, at which point `step.completed` fires. Example `step.completed` webhook for Scenario 2, cross-currency transfer step (the only step; terminal step): ```json { "id": "c5f2a9d7-4b8e-4137-a6c0-3d1f8b9e7c5a", "type": "step.completed", "created_at": "2025-12-01T11:30:00.500Z", "data": { "object": { "id": "3a8e2f5c-9d4b-4f73-a2c0-6b1d3e9f7c4a", "withdrawal_id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.500Z", "submitted_at": "2025-12-01T10:02:00.700Z", "confirmed_at": "2025-12-01T10:02:00.800Z", "completed_at": "2025-12-01T11:30:00.500Z", "failed_at": null } } } ``` ### Scenario 3: Withdrawing USDC from a self-custodial wallet via OpenFX When the `desired.from.account_id` is a self-custodial wallet, Tesser cannot submit the on-chain transaction on its own — you must locally sign the unsigned transaction with your wallet's signing key. Tesser emits `step.signature_requested` once Step 1 is prepared, with the unsigned transaction available on the step DTO as `unsigned_transaction` (also retrievable via `GET /v1/treasury/withdrawals/{withdrawalId}`). You sign the unsigned transaction client-side, then submit the signature to `POST /v1/treasury/withdrawals/{withdrawalId}/steps/{stepId}/sign`. Tesser validates the signed transaction targets the prepared step, producing `step.signed`. Tesser then broadcasts on-chain, producing `step.submitted`, `step.confirmed`, and finally `step.completed` for Step 1. The step carries `transaction_hash` from `step.signed` onward — it is derived from the signed payload — so its presence identifies the transaction rather than proving it was broadcast; `submitted_at` is the field that marks the attempt. If the same wallet has an earlier transfer awaiting broadcast, this one queues behind it — the sign call returns 200 and the step stays `signed` until its turn, with `submitted_at` still `null`; see [signing a wallet step](/how-tos/sign-a-wallet-step). Once Step 1 settles, its `actual.to.amount` reflects the amount OpenFX reports as credited and can differ from `estimated.to.amount`. Step 2 swaps that credited amount at the OpenFX ledger (USDC → USD) and executes synchronously: `step.submitted`, `step.confirmed`, and `step.completed` arrive in close succession. Step 3 (USD push from the OpenFX ledger to your external bank account) follows the standard step lifecycle. :::note{title="Testing this flow in sandbox"} OpenFX does not provide on-chain deposit addresses in its sandbox. In sandbox and staging, Tesser sends the signed transfer to a Tesser-controlled sandbox address instead. After Step 1 reaches `submitted`, create a deposit in the OpenFX sandbox account connected to Tesser with the same amount and currency as Step 1 and the corresponding network. Tesser completes Step 1 and starts the swap after OpenFX sends the completed deposit webhook. Create the OpenFX sandbox deposit only after Step 1 is submitted, and run only one pending wallet-to-OpenFX flow with the same amount, currency, and network at a time. Identical pending flows cannot be matched safely. Production sends funds directly to the registered OpenFX deposit address and does not require this manual deposit. If the OpenFX deposit does not match a submitted flow, Tesser does not advance the withdrawal. Instead, Tesser records a completed inbound Payment to your OpenFX ledger, emits `payment.created`, and adds the received amount to the ledger's `available_balance`. If the deposit could match more than one submitted flow, Tesser does not choose between them: no flow advances, and Tesser does not create a Payment or update the ledger's tracked balance. Contact Tesser support before creating another deposit or retrying the withdrawal. See [OpenFX Ledger Inbound Payments](/webhooks/payment-updates#openfx-ledger-inbound-payments) for the inbound Payment's event behavior. ::: Example `step.signature_requested` webhook (Scenario 3, on-chain transfer from wallet to OpenFX ledger): ```json { "id": "e4f6a8b0-1c3d-4e5f-9a7b-2c4d6e8f0a1b", "type": "step.signature_requested", "created_at": "2025-12-01T10:00:30.000Z", "data": { "object": { "id": "2c8a4e7b-5f1d-4936-a8c0-3e9b1d6f2a5c", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "unsigned_transaction": "0x02abcdef0123", "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:30.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } } } ``` 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 `POST /v1/treasury/withdrawals/{withdrawalId}/steps/{stepId}/sign` to execute the withdrawal step. See [Sign a Wallet Step](/how-tos/sign-a-wallet-step#sign-the-step-with-the-localsigner-sdk) for install and the full SDK reference. ```typescript 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 { data: 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 step. await fetch( `https://api.tesser.xyz/v1/treasury/withdrawals/${step.withdrawal_id}/steps/${step.id}/sign`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ signature }), } ); ``` ```kotlin import kotlinx.coroutines.runBlocking import xyz.tesser.sdk.LocalSigner import xyz.tesser.sdk.SigningConfig import xyz.tesser.sdk.StepForSigning val signer = LocalSigner( SigningConfig( publicKey = System.getenv("SIGNING_PUBLIC_KEY"), privateKey = System.getenv("SIGNING_PRIVATE_KEY"), enclaveId = System.getenv("SIGNING_ENCLAVE_ID"), ), ) runBlocking { // `step` is the step.signature_requested webhook's data.object (parsed). // `fetchAccount` / `submitSignature` wrap your own HTTP client. val signWith = fetchAccount(step.estimated.from.accountId).cryptoWalletAddress val toSign = StepForSigning( id = step.id, transferId = step.withdrawalId, unsignedTransaction = step.unsignedTransaction, signWith = signWith, network = step.estimated.from.network, ) val signed = signer.signStep(toSign) // POST /v1/treasury/withdrawals/{withdrawalId}/steps/{stepId}/sign submitSignature(toSign.transferId, toSign.id, signed.signature) } ``` `signStep` returns a `signature` to send to the withdrawal sign endpoint to execute the step, plus `metadata` (`stampHeaderName`, `stampHeaderValue`, `body`) for debugging that stays client-side. (The Kotlin result also echoes `unsignedTransaction` for audit trails.) Tesser then broadcasts the signed transaction. You will receive `step.signed`, followed by `step.submitted`, `step.confirmed` (with `transaction_hash` populated), and `step.completed` for Step 1. For wallet-originated on-chain steps, `step.submitted` is an event-only marker — see [Step Statuses](/overviews/funds-movement-lifecycle-and-data-model#step-statuses) in the lifecycle overview. Example `step.completed` webhook for Scenario 3, on-chain transfer step (step 1): ```json { "id": "a5b7c9d1-3e5f-4a7b-8c9d-0e1f2a3b4c5d", "type": "step.completed", "created_at": "2025-12-01T10:05:35.000Z", "data": { "object": { "id": "2c8a4e7b-5f1d-4936-a8c0-3e9b1d6f2a5c", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "fees": [], "transaction_hash": "0xabcdef0123", "provider_key": "turnkey", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:35.000Z", "submitted_at": "2025-12-01T10:04:05.000Z", "confirmed_at": "2025-12-01T10:04:07.500Z", "completed_at": "2025-12-01T10:05:35.000Z", "failed_at": null } } } ``` Example `step.completed` webhook for Scenario 3, swap step (step 2; USDC → USD at OpenFX): ```json { "id": "f7c3a8d4-2b9e-4516-a0c6-3d1f8b2e7c5a", "type": "step.completed", "created_at": "2025-12-01T10:06:00.500Z", "data": { "object": { "id": "6f4b9d3e-1a8c-4275-b0e6-5d2f8a3c9b1d", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 2, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.425", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:06:00.500Z", "submitted_at": "2025-12-01T10:06:00.000Z", "confirmed_at": "2025-12-01T10:06:00.300Z", "completed_at": "2025-12-01T10:06:00.500Z", "failed_at": null } } } ``` Example `step.completed` webhook for Scenario 3, transfer step (step 3; terminal step): ```json { "id": "d2e8f5a3-4b9c-4617-a0c6-1f3d8b2e7c4a", "type": "step.completed", "created_at": "2025-12-01T11:30:00.500Z", "data": { "object": { "id": "8e5c1f9a-3d7b-4148-a6c0-2f4d8b1e9c3a", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.425", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.425", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.500Z", "submitted_at": "2025-12-01T10:06:30.000Z", "confirmed_at": "2025-12-01T11:25:00.000Z", "completed_at": "2025-12-01T11:30:00.500Z", "failed_at": null } } } ``` ## Withdrawal Info After Completion When the last step reaches a terminal state, Tesser populates the top-level `actual.*` overlay and emits a `withdrawal.updated` webhook carrying the full updated Withdrawal object. You can also retrieve the withdrawal at any time via [`GET /v1/treasury/withdrawals/{withdrawalId}`](/api/treasury#get-withdrawal-by-id) — the response payload below matches the body of the terminal `withdrawal.updated` webhook for each scenario. Example `GET /v1/treasury/withdrawals/{withdrawalId}` response (Scenario 1, complete): ```json { "data": { "id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.425", "currency": "USD", "network": null } }, "steps": [ { "id": "8d3f1c6a-4b9e-4275-9a8d-2c5e7f4b1a3d", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.425", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:02:00.500Z", "submitted_at": "2025-12-01T10:02:00.000Z", "confirmed_at": "2025-12-01T10:02:00.300Z", "completed_at": "2025-12-01T10:02:00.500Z", "failed_at": null }, { "id": "9e4d2a7c-1f5b-4836-a0c2-7d6e3f1b9c8a", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.425", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.425", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.500Z", "submitted_at": "2025-12-01T10:02:30.000Z", "confirmed_at": "2025-12-01T11:25:00.000Z", "completed_at": "2025-12-01T11:30:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example `GET /v1/treasury/withdrawals/{withdrawalId}` response (Scenario 2, complete): ```json { "data": { "id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "steps": [ { "id": "3a8e2f5c-9d4b-4f73-a2c0-6b1d3e9f7c4a", "withdrawal_id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.500Z", "submitted_at": "2025-12-01T10:02:00.700Z", "confirmed_at": "2025-12-01T10:02:00.800Z", "completed_at": "2025-12-01T11:30:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example `GET /v1/treasury/withdrawals/{withdrawalId}` response (Scenario 3, complete): ```json { "data": { "id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:04:05.000Z", "desired": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.425", "currency": "USD", "network": null } }, "steps": [ { "id": "2c8a4e7b-5f1d-4936-a8c0-3e9b1d6f2a5c", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "fees": [], "transaction_hash": "0xabcdef0123", "provider_key": "turnkey", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:35.000Z", "submitted_at": "2025-12-01T10:04:05.000Z", "confirmed_at": "2025-12-01T10:04:07.500Z", "completed_at": "2025-12-01T10:05:35.000Z", "failed_at": null }, { "id": "6f4b9d3e-1a8c-4275-b0e6-5d2f8a3c9b1d", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 2, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.425", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:06:00.500Z", "submitted_at": "2025-12-01T10:06:00.000Z", "confirmed_at": "2025-12-01T10:06:00.300Z", "completed_at": "2025-12-01T10:06:00.500Z", "failed_at": null }, { "id": "8e5c1f9a-3d7b-4148-a6c0-2f4d8b1e9c3a", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.425", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.425", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.500Z", "submitted_at": "2025-12-01T10:06:30.000Z", "confirmed_at": "2025-12-01T11:25:00.000Z", "completed_at": "2025-12-01T11:30:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:30:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` ## Webhook Events by Scenario Below are the webhook events you will observe for each of the three withdrawal scenarios in this guide. Cross-currency withdrawal at OpenFX (USDT → USD → bank): | # | What happens | What you receive / do | Fields populated | |---|---|---|---| | 1 | You submit the withdrawal request | HTTP response with `id`; `balance_status: "unreserved"` | `desired.from.amount` | | 2 | Tesser plans the route and obtains a quote | `withdrawal.quote_created` webhook fires with 2 steps and the populated `estimated` overlay | Steps array; `estimated.from.amount` and `estimated.to.amount` at withdrawal and step level (reflects indicative rate) | | 3 | Tesser reserves USDT at the `desired.from.account_id` | `withdrawal.balance_updated` webhook with `balance_status: "reserved"` | `balance_status`, `balance_reserved_at` | | 4 | OpenFX redeems USDT for USD on its books | `step.submitted`, `step.confirmed`, and `step.completed` fire on the swap step in close succession | Step 1: `actual.from.amount`, `actual.to.amount` (reflects actual fill rate), all timestamps | | 5 | OpenFX initiates the push of funds to the external bank | `step.submitted` on the transfer step | Step 2: `submitted_at`, `actual.from.amount` | | 6 | OpenFX confirms acceptance | `step.confirmed` on the transfer step | Step 2: `confirmed_at` | | 7 | Funds settle at the external bank; withdrawal is complete | `step.completed` on the transfer step, followed by `withdrawal.updated` with the terminal Withdrawal object | Step 2: `completed_at`, `actual.to.amount`. Withdrawal-level `actual.from.amount` and `actual.to.amount`. | Cross-currency withdrawal at Circle (USDC → USD → bank): | # | What happens | What you receive / do | Fields populated | |---|---|---|---| | 1 | You submit the withdrawal request | HTTP response with `id`; `balance_status: "unreserved"` | `desired.from.amount` | | 2 | Tesser plans the route and obtains a quote | `withdrawal.quote_created` webhook fires with 1 cross-currency transfer step and the populated `estimated` overlay | Steps array; `estimated.from.amount` and `estimated.to.amount` at withdrawal and step level (1:1 Circle redemption) | | 3 | Tesser reserves USDC at the `desired.from.account_id` | `withdrawal.balance_updated` webhook with `balance_status: "reserved"` | `balance_status`, `balance_reserved_at` | | 4 | Tesser submits the withdrawal to Circle | `step.submitted` on the transfer step | Step 1: `submitted_at` | | 5 | Circle accepts the withdrawal request | `step.confirmed` on the transfer step (fires close to `step.submitted` since Circle accepts synchronously) | Step 1: `confirmed_at` | | 6 | Circle's withdrawal completes; funds settle at the external bank | `step.completed` on the transfer step, followed by `withdrawal.updated` with the terminal Withdrawal object | Step 1: `completed_at`, `actual.from.amount` (USDC at Circle ledger), `actual.to.amount` (USD at bank). Withdrawal-level `actual.from.amount` and `actual.to.amount`. | On-chain transfer from a self-custodial wallet, then withdrawal at OpenFX: | # | What happens | What you receive / do | Fields populated | |---|---|---|---| | 1 | You submit the withdrawal request | HTTP response with `id`; `balance_status: "unreserved"` | `desired.from.amount` | | 2 | Tesser plans the route and obtains a quote | `withdrawal.quote_created` webhook fires with 3 steps and the populated `estimated` overlay | Steps array; `estimated.from.amount` and `estimated.to.amount` at withdrawal and step level | | 3 | Tesser asks you to sign the on-chain transfer | `step.signature_requested` on Step 1 | Step 1: `status` updates; signing payload supplied via the API | | 4 | You submit the signature | `POST /v1/treasury/withdrawals/{withdrawalId}/steps/{stepId}/sign` with `{signature}`; `step.signed` fires; `withdrawal.balance_updated` with `balance_status: "reserved"` once the signed step is accepted | `balance_status`, `balance_reserved_at`; Step 1 `status: "signed"` | | 5 | Tesser broadcasts the signed transaction | `step.submitted` on Step 1 | Step 1: `submitted_at` | | 6 | The on-chain transaction is broadcast and accepted into the network's mempool (not yet in a block) | `step.confirmed` on Step 1 | Step 1: `confirmed_at`, `transaction_hash` | | 7 | OpenFX credits the on-chain deposit. In sandbox and staging, create the matching OpenFX sandbox deposit after Step 1 is submitted; production observes the deposit sent directly to OpenFX. | `step.completed` on Step 1 after Tesser receives the completed OpenFX deposit webhook | Step 1: `completed_at`, `actual.from.amount`, `actual.to.amount` | | 8 | OpenFX redeems USDC for USD on its books | `step.submitted`, `step.confirmed`, and `step.completed` fire on the swap step in close succession | Step 2: `actual.from.amount`, `actual.to.amount` (reflects actual fill rate), all timestamps | | 9 | OpenFX initiates the push of funds to the external bank | `step.submitted` on the transfer step | Step 3: `submitted_at`, `actual.from.amount` | | 10 | OpenFX confirms acceptance | `step.confirmed` on the transfer step | Step 3: `confirmed_at` | | 11 | Funds settle at the external bank; withdrawal is complete | `step.completed` on the transfer step, followed by `withdrawal.updated` with the terminal Withdrawal object | Step 3: `completed_at`, `actual.to.amount`. Withdrawal-level `actual.from.amount` and `actual.to.amount`. | ## Failure Modes for Withdrawals If a withdrawal 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. ### Swap Fails (Scenarios 1 and 3 only) If the liquidity provider's swap step cannot complete before the withdrawal's `expires_at` timestamp, the swap step transitions to `failed` and the subsequent transfer step also fails because there were no funds to push to the external bank. This failure mode applies only to scenarios with a standalone `swap` step — **Scenario 1** (OpenFX redeems USDT→USD as a swap step at its ledger) and **Scenario 3** (OpenFX redeems USDC→USD as a swap step at its ledger). Scenario 2 has no standalone swap step (Circle's redemption is absorbed into the single `transfer` step), so this failure mode does not apply to Sc 2 — see [Fiat Funds Transfer Fails (All Scenarios)](#fiat-funds-transfer-fails-all-scenarios) below for the Sc 2 failure path. In Scenario 1 (where the swap is the only execution work before the bank push), no step reaches `step.status = completed` — top-level `actual.*` stays all-null per the Failure Modes rule. In Scenario 3, the prior on-chain transfer step (Step 1) had already completed, so top-level `actual.from` and `actual.to` populate from Step 1's `actual.*` (USDC at the OpenFX ledger). What you will observe (illustration uses Scenario 1 — OpenFX swap give-up): - `step.failed` on the swap step. Step-level `actual.*` is null; `status_reasons` carries the failure detail. - `step.failed` on the transfer step. This step never entered `submitted`, because there were no funds to push to the external bank. - The withdrawal's top-level `actual.*` is all null because no step reached `completed`. `desired.to.account_id` stays as the originally requested external bank account (client intent is never overwritten); `desired.from` and `estimated.*` describe what was requested and planned. - A `withdrawal.updated` webhook fires alongside the terminal `step.failed` events, carrying the full updated Withdrawal object. - The USDT balance at the OpenFX ledger remains in your account and is available to use for a future rebalance or withdrawal. Example `step.failed` webhook on Step 1 (swap, never filled): ```json { "id": "f2c8e4a3-9b7d-4f15-a6c0-2d3f8e1b9c4a", "type": "step.failed", "created_at": "2025-12-01T14:00:00.000Z", "data": { "object": { "id": "8d3f1c6a-4b9e-4275-9a8d-2c5e7f4b1a3d", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [ { "error_code": "transfers-9304", "error_message": "The currency conversion trade failed" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": "2025-12-01T10:32:18.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `step.failed` webhook on Step 2 (fiat transfer, never submitted): ```json { "id": "a9d3e7c4-2f8b-4516-a0c6-3e1f9b2a8c5d", "type": "step.failed", "created_at": "2025-12-01T14:00:00.100Z", "data": { "object": { "id": "9e4d2a7c-1f5b-4836-a0c2-7d6e3f1b9c8a", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.100Z" } } } ``` Example `withdrawal.updated` webhook (Scenario 1, swap give-up; USDT remains at OpenFX ledger): ```json { "id": "b3c9f4e7-2d8a-4561-9b0c-4d3f1e9b2a7c", "type": "withdrawal.updated", "created_at": "2025-12-01T14:00:00.300Z", "data": { "object": { "id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8d3f1c6a-4b9e-4275-9a8d-2c5e7f4b1a3d", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [ { "error_code": "transfers-9304", "error_message": "The currency conversion trade failed" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": "2025-12-01T10:32:18.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" }, { "id": "9e4d2a7c-1f5b-4836-a0c2-7d6e3f1b9c8a", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.100Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.300Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ### Fiat Funds Transfer Fails (All Scenarios) The fiat funds `transfer` step (the step that delivers funds to your external bank account) may fail: - **Between submission and confirmation** — Tesser called the provider, but the provider returned a non-200 response (or no response within Tesser's timeout). `submitted_at` is populated; `confirmed_at` is null; `failed_at` is populated. The upstream balance reservation is rolled back. Funds remain at the upstream source. - **After confirmation** — The provider accepted the request (so `confirmed_at` is populated), but the provider's terminal webhook later reported failure. `submitted_at` and `confirmed_at` are populated; `completed_at` is null; `failed_at` is populated. What happened to the funds at the provider between confirmation and the terminal webhook depends on the provider's internal handling — for Circle, the redeemed USD typically returns to the Circle ledger; for OpenFX, the USD typically remains at the OpenFX ledger. Step-level `actual.*` is null on the failed transfer step regardless of sub-case (failed steps never populate `actual.*`). Top-level `actual.*` is determined by the last completed step: - **Scenario 1:** Step 1 (swap) had completed, so top-level `actual.from` reflects Step 1's `actual.from` (USDT at the OpenFX ledger) and top-level `actual.to` reflects Step 1's `actual.to` (USD at the OpenFX ledger). USD remains at the OpenFX ledger. - **Scenario 2:** No upstream step completed (Sc 2 has only the one step that just failed), so top-level `actual.*` stays all-null. USDC remains at the Circle ledger. - **Scenario 3:** Step 1 (on-chain transfer) and Step 2 (swap) had completed, so top-level `actual.from` reflects Step 1's `actual.from` (USDC at the source wallet) and top-level `actual.to` reflects Step 2's `actual.to` (USD at the OpenFX ledger). USD remains at the OpenFX ledger. What you will observe (illustration uses Scenario 2 — Circle rejected the withdrawal between submission and confirmation): - `step.failed` on the transfer step. `submitted_at` is populated; `confirmed_at` is null; `status_reasons` carries the provider's failure detail; step-level `actual.*` is null. - The withdrawal's top-level `actual.*` is all-null because no step reached `completed`. `desired.to.account_id` stays as the originally requested external bank account. - A `withdrawal.updated` webhook fires alongside the terminal `step.failed`, carrying the full updated Withdrawal object. - The USDC balance at the Circle ledger remains in your account and is available to use for a future rebalance or withdrawal. Example `step.failed` webhook (Scenario 2; Circle rejected the withdrawal between submission and confirmation): ```json { "id": "d4e8f2a7-3c9b-4615-a6c0-1d2f3e9b4c7a", "type": "step.failed", "created_at": "2025-12-01T10:02:35.000Z", "data": { "object": { "id": "3a8e2f5c-9d4b-4f73-a2c0-6b1d3e9f7c4a", "withdrawal_id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "failed", "status_reasons": [ { "error_code": "transfers-9302", "error_message": "The provider payout failed" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:02:35.000Z", "submitted_at": "2025-12-01T10:02:30.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T10:02:35.000Z" } } } ``` Example `withdrawal.updated` webhook (Scenario 2, withdrawal rejected; USDC remains at Circle ledger): ```json { "id": "e5f3a7d2-4b8c-4561-9a0d-2c3f1e9b8a4c", "type": "withdrawal.updated", "created_at": "2025-12-01T10:02:35.100Z", "data": { "object": { "id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "3a8e2f5c-9d4b-4f73-a2c0-6b1d3e9f7c4a", "withdrawal_id": "9b4d7c2e-5f8a-43b1-ae6d-2f7c9e3b1a8d", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "failed", "status_reasons": [ { "error_code": "transfers-9302", "error_message": "The provider payout failed" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:02:35.000Z", "submitted_at": "2025-12-01T10:02:30.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T10:02:35.000Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:02:35.100Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ### On-Chain Transfer From Wallet Fails (Scenario 3) If Step 1's on-chain transfer from the self-custodial wallet to the OpenFX ledger never enters `submitted` — for example, because the customer never signs the step before `expires_at`, or because the sign endpoint rejects the signature — the withdrawal terminates with USDC remaining at the source wallet. The subsequent swap and transfer steps never start. (Post-broadcast failure modes — chain reorg, gas exhaustion, nonce conflict on a signed-and-broadcast transaction — are covered separately.) In sandbox and staging, a Step 1 that has reached `submitted` or `confirmed` but has not completed may be waiting for the matching OpenFX sandbox deposit. Create the deposit with the same amount and currency as Step 1 and the corresponding network; do not submit another identical wallet-to-OpenFX flow to work around the pending step. What you will observe: - `step.failed` on Step 1 (on-chain transfer). Step-level `actual.*` is null; `status_reasons` is `[]` because the step never started — the failure cause lives at the resource level (the withdrawal reached `expires_at` waiting for the customer signature, or the sign endpoint rejected the signature). - `step.failed` on Step 2 (swap) and Step 3 (bank transfer). Neither step was ever submitted. Step-level `actual.*` is null; `status_reasons` is `[]`. - The withdrawal's top-level `actual.*` is all null because no step reached `completed`. `desired.to.account_id` stays as the originally requested external bank account; `desired.from` and `estimated.*` describe what was requested and planned. - A `withdrawal.updated` webhook fires alongside the terminal `step.failed` events, carrying the full updated Withdrawal object. Example `step.failed` webhook for Step 1 (on-chain transfer from BASE wallet, never confirmed): ```json { "id": "fb4c5d6e-7f8a-4b9c-9d0e-1f2a3b4c5d6e", "type": "step.failed", "created_at": "2025-12-01T14:00:00.000Z", "data": { "object": { "id": "2c8a4e7b-5f1d-4936-a8c0-3e9b1d6f2a5c", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "turnkey", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `withdrawal.updated` webhook (Scenario 3, on-chain transfer failed; USDC remains at the source wallet): ```json { "id": "ec7d3a9f-2b4c-4561-a0d8-3e1f5b9c4a7d", "type": "withdrawal.updated", "created_at": "2025-12-01T14:00:00.300Z", "data": { "object": { "id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "2c8a4e7b-5f1d-4936-a8c0-3e9b1d6f2a5c", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "turnkey", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" }, { "id": "6f4b9d3e-1a8c-4275-b0e6-5d2f8a3c9b1d", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 2, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.100Z" }, { "id": "8e5c1f9a-3d7b-4148-a6c0-2f4d8b1e9c3a", "withdrawal_id": "5d7c3a9e-2b4f-4861-9c0d-8e3f1b2d4a7c", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.100Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.300Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ### Insufficient Funds If the `desired.from.account_id` does not have enough balance to cover `desired.from.amount` when the balance check runs, the withdrawal enters `balance_status: "awaiting_funds"`. For ledger sources (Scenarios 1 and 2), the async check after creation detects the shortfall and Tesser fires `withdrawal.balance_updated` with `balance_status: "awaiting_funds"`; Tesser then retries the reservation as the balance at `desired.from.account_id` changes (e.g., as a deposit lands or another withdrawal frees funds). For wallet sources (Scenario 3), the sync check inside the step-signing API returns a 4XX rejecting the signature and `withdrawal.balance_updated` fires shortly afterward with `balance_status: "awaiting_funds"`; Tesser monitors the wallet's on-chain balance and republishes `step.signature_requested` (with a refreshed `signature_requested_at`) once the wallet is funded, so you can retry the signing flow with the refreshed step. If the reservation does not succeed before `expires_at`, the withdrawal times out. What you will observe: - `withdrawal.balance_updated` with `balance_status: "awaiting_funds"` shortly after `withdrawal.quote_created` (ledger sources) or shortly after a rejected sign call (wallet sources). - (Optional) further `withdrawal.balance_updated` events as balance changes are detected. - At `expires_at`: `step.failed` on every step that was still in `created` status. Step-level `actual.*` is null; `status_reasons` is `[]` because the step never started — the failure cause lives at the resource level (the withdrawal timed out after remaining in `awaiting_funds` until `expires_at`). - The withdrawal's top-level `actual.*` is all null because no step reached `step.status = completed` — no funds ever moved. - A terminal `withdrawal.updated` webhook fires alongside the `step.failed` events, carrying the full updated Withdrawal object reflecting the terminal state. The example below illustrates Scenario 1; the same pattern applies in Scenarios 2 and 3, with one `failed` step entry per never-started step. Example `withdrawal.balance_updated` webhook (Scenario 1, source ledger short): ```json { "id": "ac1b3d5e-7f9a-4b2c-8d4e-6f8a0b2c4d6e", "type": "withdrawal.balance_updated", "created_at": "2025-12-01T10:00:01.000Z", "data": { "object": { "id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "awaiting_funds", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8d3f1c6a-4b9e-4275-9a8d-2c5e7f4b1a3d", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "9e4d2a7c-1f5b-4836-a0c2-7d6e3f1b9c8a", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `withdrawal.updated` webhook (Scenario 1, source ledger remained underfunded; withdrawal expired): ```json { "id": "ed6f8a0b-2c4e-4f6a-9b0c-3d5f7a9b1c3e", "type": "withdrawal.updated", "created_at": "2025-12-01T14:00:00.500Z", "data": { "object": { "id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "outbound", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8d3f1c6a-4b9e-4275-9a8d-2c5e7f4b1a3d", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USDT", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" }, { "id": "9e4d2a7c-1f5b-4836-a0c2-7d6e3f1b9c8a", "withdrawal_id": "4f8c2e9a-1b6d-4a37-8e5f-3c9d2a7b1e6f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USD", "network": null }, "to": { "account_id": "c4a7e9b3-5d2f-4e8a-b6c1-9f3a7d2e8b4f", "amount": "999.475", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.100Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.500Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` --- ## Document: Rebalance Funds Move funds between your managed accounts, including across currencies and networks. URL: /how-tos/manage-your-treasury/rebalance-funds # Rebalance Funds Rebalances move funds between managed accounts on the Tesser platform. Rebalances can be same-currency as well as cross-currency (that include a swap step). Common patterns include moving funds between two ledgers at the same provider, between a provider ledger and a self-custodial wallet, or between two self-custodial wallets. Before creating a rebalance, review the [Funds Movement Lifecycle and Data Model](/overviews/funds-movement-lifecycle-and-data-model) overview to understand the shared data shape, lifecycle phases, and statuses that apply to rebalances and Tesser's other funds-movement resources. ## Prerequisites The `desired.from.account_id` and `desired.to.account_id` must each be one of your managed accounts — either a provider ledger (e.g., a Circle ledger or an OpenFX ledger) or a self-custodial wallet you have registered with Tesser. For more information on registering accounts, see [Create an account](/how-tos/create-an-account). Rebalances are first-party only — they move funds between your own managed accounts and never involve a third party. If you want to send funds to a third party, see [Send a Stablecoin Payout](/how-tos/send-a-stablecoin-payout/create-a-stablecoin-payout) and [Create a counterparty](/how-tos/create-a-counterparty). For Scenario 2 in this guide, the source ledger is at OpenFX and assumes USD has already landed at that ledger via a fiat deposit. See [Deposit Funds via a Liquidity Provider](/how-tos/manage-your-treasury/deposit-funds-via-a-liquidity-provider) for the earlier part of the deposit lifecycle. ## Rebalance Workflow A rebalance executes as one or more steps. Tesser plans the route asynchronously when you submit the create request, then drives each step through the shared step lifecycle. - A `transfer` step moves funds from one account to another. Same-currency moves between two ledgers, between a ledger and a wallet, or between two wallets are all `transfer` steps. - A `swap` step exchanges currencies inside a single account. The step's `estimated.from.account_id` and `estimated.to.account_id` are the same. Ledger-to-wallet rebalances swap first and then transfer the resulting stablecoin. Wallet-to-OpenFX rebalances transfer the stablecoin to the OpenFX ledger first and then swap it there. For a complete description of how rebalances move through planning, balance reservation, execution, and terminal state, see the lifecycle overview's [Planning](/overviews/funds-movement-lifecycle-and-data-model#planning), [Execution](/overviews/funds-movement-lifecycle-and-data-model#execution), and [Terminal State and Divergence](/overviews/funds-movement-lifecycle-and-data-model#terminal-state-and-divergence) sections. Step statuses are documented under [Step Statuses](/overviews/funds-movement-lifecycle-and-data-model#step-statuses). ## Exchange Rates for Cross-Token Rebalances When a rebalance crosses currencies, Tesser sources an indicative quote from the relevant liquidity provider and reports it via the `estimated.from.amount` and `estimated.to.amount` fields. The actual fill rate may differ slightly from the indicative quote and is reflected in `actual.*` once the swap step completes. See [Planning](/overviews/funds-movement-lifecycle-and-data-model#planning) for how Tesser obtains and reports quotes. | Type of liquidity provider | Example | Behavior | |---|---|---| | On/Off ramp | 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 | For same-currency rebalances, `estimated.*` matches `desired.*` (1:1) and there is no swap step. ## Rebalance Creation Submit a request to [POST /v1/treasury/rebalances](/api/treasury#create-rebalance). - If applicable, you should specify on which `tenant`'s behalf you are requesting the rebalance. - For the rebalance, populate the following fields in the `desired` object: - `desired.from.account_id`: The identifier of the account funds will be moved from. - `desired.from.amount`: The amount of `desired.from.currency` to move from the source. - `desired.from.currency`: The currency to move from the source. - `desired.from.network`: The network of the funds at `desired.from.account_id` (only applicable when `desired.from.account_id` is a wallet). - `desired.to.account_id`: The identifier of the destination account. - `desired.to.currency`: The currency to land at the destination. - `desired.to.network`: The destination network (only applicable when the destination account is a wallet). *Note: `desired.to.amount` is not auto-populated. The Rebalance's `desired.to.amount` remains `null` throughout the lifecycle; the indicative target amount is supplied in `estimated.to.amount` once the quote is obtained.* ### Wallet to OpenFX Ledger OpenFX also supports rebalancing a stablecoin from a self-custodial wallet into your OpenFX ledger. Set `desired.from.network` to the wallet's network and omit `desired.to.network` because the destination is a ledger. When the source and destination currencies match, the route contains one signed on-chain transfer. When they differ, Tesser transfers the source stablecoin first and then swaps it at OpenFX. Example request (rebalance USDC from a self-custodial wallet on BASE into USD at an OpenFX ledger): ```json { "tenant_id": null, "desired": { "from": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "currency": "USD" } } } ``` :::note{title="Testing this flow in sandbox"} OpenFX does not provide on-chain deposit addresses in its sandbox. In sandbox and staging, Tesser sends the signed transfer to a Tesser-controlled sandbox address instead. After the transfer step reaches `submitted`, create a deposit in the OpenFX sandbox account connected to Tesser with the same amount and currency as the transfer step and the corresponding network. Tesser completes the transfer step and starts any following swap after OpenFX sends the completed deposit webhook. Create the OpenFX sandbox deposit only after the transfer step is submitted, and run only one pending wallet-to-OpenFX flow with the same amount, currency, and network at a time. Identical pending flows cannot be matched safely. Production sends funds directly to the registered OpenFX deposit address and does not require this manual deposit. If the OpenFX deposit does not match a submitted flow, Tesser does not advance the rebalance. Instead, Tesser records a completed inbound Payment to your OpenFX ledger, emits `payment.created`, and adds the received amount to the ledger's `available_balance`. If the deposit could match more than one submitted flow, Tesser does not choose between them: no flow advances, and Tesser does not create a Payment or update the ledger's tracked balance. Contact Tesser support before creating another deposit or retrying the rebalance. See [OpenFX Ledger Inbound Payments](/webhooks/payment-updates#openfx-ledger-inbound-payments) for the inbound Payment's event behavior. ::: **The examples in this guide show 3 scenarios:** 1. Same-token rebalance between two ledger accounts at Circle (USDC, no network) 2. Cross-token rebalance from a USD ledger at OpenFX to a self-custodial wallet on BASE (USDC) 3. Same-token rebalance between two self-custodial wallets (USDT on POLYGON) Example request (rebalance USDC between two Circle ledger accounts): ```json { "tenant_id": null, "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC" }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "currency": "USDC" } } } ``` Example request (rebalance USD at an OpenFX ledger into USDC at a self-custodial wallet on BASE): ```json { "tenant_id": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "currency": "USDC", "network": "BASE" } } } ``` Example request (rebalance USDT on POLYGON between two self-custodial wallets): ```json { "tenant_id": null, "desired": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "currency": "USDT", "network": "POLYGON" } } } ``` In the API response, Tesser will create and return an `id` for the rebalance request. At creation, `balance_status` is `unreserved` and `balance_reserved_at` is `null` — both update once the balance check completes. Example response (rebalance USDC between two Circle ledger accounts): ```json { "data": { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example response (rebalance USD at an OpenFX ledger into USDC at a self-custodial wallet on BASE): ```json { "data": { "id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDC", "network": "BASE" } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example response (rebalance USDT on POLYGON between two self-custodial wallets): ```json { "data": { "id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": null, "currency": "USDT", "network": "POLYGON" } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` ## Rebalance Quote Created (`rebalance.quote_created`) After your `POST /v1/treasury/rebalances` request is accepted, Tesser plans the route the funds will take and obtains a reference exchange rate. Tesser then sends a `rebalance.quote_created` webhook — the first webhook fired for the rebalance. The payload carries the planned `steps[]` array (each step with `status: "created"`) together with the populated `estimated` overlay at both the rebalance and step levels. This webhook always fires, even for same-token same-network rebalances, to keep the lifecycle uniform across resource types and to future-proof bridging across networks. For same-currency rebalances, the ratio of `estimated.from.amount` to `estimated.to.amount` is 1:1. For cross-currency rebalances, the ratio is the indicative exchange rate at the liquidity provider. Example `rebalance.quote_created` webhook (rebalance USDC between two Circle ledger accounts): ```json { "id": "b8c4d5e6-2f3a-4b7c-9d8e-1f2a3b4c5d6e", "type": "rebalance.quote_created", "created_at": "2025-12-01T10:00:00.600Z", "data": { "object": { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `rebalance.quote_created` webhook (rebalance USD at an OpenFX ledger into USDC at a self-custodial wallet on BASE): ```json { "id": "c2d4e6f8-1a3b-4c5d-9e7f-2a4b6c8d0e1f", "type": "rebalance.quote_created", "created_at": "2025-12-01T10:00:00.600Z", "data": { "object": { "id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDC", "network": "BASE" } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "4d6f7a8b-1c2d-4e5f-8a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "5e7f8a9b-2c3d-4e5f-9a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `rebalance.quote_created` webhook (rebalance USDT on POLYGON between two self-custodial wallets): ```json { "id": "d3e5f7a9-2b4c-4d6e-8f0a-3b5c7d9e1f2a", "type": "rebalance.quote_created", "created_at": "2025-12-01T10:00:00.600Z", "data": { "object": { "id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": null, "currency": "USDT", "network": "POLYGON" } }, "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "rebalance_id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "turnkey", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ## Rebalance Balance Check (`rebalance.balance_updated`) Rebalances debit funds from the managed account at `desired.from.account_id`, so Tesser performs a balance check before the rebalance executes. When the balance check runs and how it surfaces depends on the type of account: - **Ledger sources (Scenarios 1 and 2):** the check runs asynchronously after the rebalance is created. Shortly after `rebalance.quote_created` fires, Tesser reads the ledger's available balance and emits `rebalance.balance_updated` with the outcome. - **Wallet sources (Scenario 3 and wallet-to-OpenFX rebalances):** the check runs synchronously inside your call to the [step-signing API](/api/treasury#sign-rebalance-step). On success, the sign endpoint accepts your signature and `rebalance.balance_updated` fires with `balance_status: "reserved"` from the sign-API path alongside `step.signed`. On insufficient funds, the sign endpoint returns a 4XX response and `rebalance.balance_updated` fires with `balance_status: "awaiting_funds"`. Tesser monitors the wallet's on-chain balance and republishes `step.signature_requested` (with a refreshed `signature_requested_at`) once the wallet is funded; repeat the signing flow with the refreshed step to retry, until `expires_at`. In both cases, a `reserved` `balance_status` means execution can proceed and `balance_reserved_at` records when the reservation succeeded. See [Balance Statuses](/overviews/funds-movement-lifecycle-and-data-model#balance-statuses). :::note If `desired.from.account_id` does not have sufficient funds when the balance check runs, `balance_status` is set to `awaiting_funds`. For ledger sources, Tesser republishes `rebalance.balance_updated` as the ledger balance is re-evaluated. For wallet sources, Tesser republishes `step.signature_requested` once on-chain funding is observed, and the next `rebalance.balance_updated` (with `balance_status: "reserved"`) fires on the subsequent successful sign. The retry loop continues until funds arrive or the rebalance expires — see [Failure Modes for Rebalances](#failure-modes-for-rebalances). ::: Example `rebalance.balance_updated` webhook (Scenario 1, balance reserved at the source Circle ledger): ```json { "id": "a4b6c8d0-3e5f-4a1b-8c2d-4e6f8a0b1c2d", "type": "rebalance.balance_updated", "created_at": "2025-12-01T10:00:01.000Z", "data": { "object": { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ## Rebalance Step Execution Once funds have been reserved, Tesser begins executing each step in `step_sequence` order. The events you observe depend on whether the step has on-chain visibility and whether it requires a wallet signature. ### Scenario 1: Circle-Internal Transfer Tesser initiates the internal move via Circle's API and observes Circle's synchronous confirmation, so `step.submitted`, `step.confirmed`, and `step.completed` arrive in close succession. The terminal `step.completed` is followed by a `rebalance.updated` event carrying the full updated Rebalance object. Example `step.completed` webhook (Scenario 1, single Circle internal transfer): ```json { "id": "f1e2d3c4-5b6a-4798-8e0d-3f4a5b6c7d8e", "type": "step.completed", "created_at": "2025-12-01T10:05:00.500Z", "data": { "object": { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:00.500Z", "submitted_at": "2025-12-01T10:05:00.300Z", "confirmed_at": "2025-12-01T10:05:00.400Z", "completed_at": "2025-12-01T10:05:00.500Z", "failed_at": null } } } ``` :::note For Circle-internal moves, `transaction_hash` is always `null`. ::: ### Scenario 2: Swap at OpenFX, Then On-Chain Transfer to a Wallet Step 1 is a swap at the OpenFX ledger. Tesser submits the trade, observes its acceptance, and observes its fill in close succession, so `step.submitted`, `step.confirmed`, and `step.completed` arrive close together. The swap's `estimated.from.account_id` and `estimated.to.account_id` are equal — both are the OpenFX ledger UUID `2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b`. Step 2 is an on-chain transfer that moves the swapped USDC to the BASE wallet. The transfer originates from the OpenFX ledger and follows the full step lifecycle — `step.submitted`, `step.confirmed`, and `step.completed` — with the `transaction_hash` populated once Tesser observes the on-chain transaction. Example `step.completed` webhook (Scenario 2, on-chain transfer to a BASE wallet): ```json { "id": "c2e5f3d1-7b9a-4c48-8e0d-3f4a5b6c7d8e", "type": "step.completed", "created_at": "2025-12-01T10:36:35.000Z", "data": { "object": { "id": "5e7f8a9b-2c3d-4e5f-9a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.475", "currency": "USDC", "network": "BASE" } }, "fees": [], "transaction_hash": "0x7e8c3f2a1b4d ... 7c8d9e0f", "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:36:35.000Z", "submitted_at": "2025-12-01T10:35:05.000Z", "confirmed_at": "2025-12-01T10:35:07.500Z", "completed_at": "2025-12-01T10:36:35.000Z", "failed_at": null } } } ``` ### Scenario 3: On-Chain Transfer Between Self-Custodial Wallets When the `desired.from.account_id` is a self-custodial wallet, Tesser cannot submit the on-chain transaction on its own — you must locally sign the unsigned transaction with your wallet's signing key. Tesser emits `step.signature_requested` once the step is prepared, with the unsigned transaction available on the step DTO as `unsigned_transaction` (also retrievable via `GET /v1/treasury/rebalances/{rebalanceId}`). You sign client-side, then submit the resulting signature to `POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign`. Tesser validates the signed transaction targets the prepared step, producing `step.signed`. Tesser then broadcasts on-chain, producing `step.submitted`, `step.confirmed` (with `transaction_hash`), and finally `step.completed`. If the same wallet has an earlier transfer still waiting to be broadcast, this one queues behind it: the sign call returns 200 and the step stays `signed`, already carrying its `transaction_hash` (derived from the signed payload) with `submitted_at` still `null`. It broadcasts as soon as the earlier transfers are submitted, and `step.submitted` / `step.confirmed` follow as above — no action needed on your side. See [signing a wallet step](/how-tos/sign-a-wallet-step) for the queue's ordering rules. Example `step.signature_requested` webhook (Scenario 3, on-chain wallet-to-wallet transfer): ```json { "id": "e4f6a8b0-1c3d-4e5f-9a7b-2c4d6e8f0a1b", "type": "step.signature_requested", "created_at": "2025-12-01T10:00:30.000Z", "data": { "object": { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "rebalance_id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "unsigned_transaction": "0x02ed81893a85 ... 949c4e7f2b1d8a4e6cb3 ... 3a764000080c0", "provider_key": "turnkey", "status": "signature_requested", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:30.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } } } ``` 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 `POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign` to execute the rebalance step. See [Sign a Wallet Step](/how-tos/sign-a-wallet-step#sign-the-step-with-the-localsigner-sdk) for install and the full SDK reference. ```typescript 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 { data: 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 step. await fetch( `https://api.tesser.xyz/v1/treasury/rebalances/${step.rebalance_id}/steps/${step.id}/sign`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ signature }), } ); ``` ```kotlin import kotlinx.coroutines.runBlocking import xyz.tesser.sdk.LocalSigner import xyz.tesser.sdk.SigningConfig import xyz.tesser.sdk.StepForSigning val signer = LocalSigner( SigningConfig( publicKey = System.getenv("SIGNING_PUBLIC_KEY"), privateKey = System.getenv("SIGNING_PRIVATE_KEY"), enclaveId = System.getenv("SIGNING_ENCLAVE_ID"), ), ) runBlocking { // `step` is the step.signature_requested webhook's data.object (parsed). // `fetchAccount` / `submitSignature` wrap your own HTTP client. val signWith = fetchAccount(step.estimated.from.accountId).cryptoWalletAddress val toSign = StepForSigning( id = step.id, transferId = step.rebalanceId, unsignedTransaction = step.unsignedTransaction, signWith = signWith, network = step.estimated.from.network, ) val signed = signer.signStep(toSign) // POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign submitSignature(toSign.transferId, toSign.id, signed.signature) } ``` `signStep` returns a `signature` to send to the rebalance step sign API to execute the step, plus `metadata` (`stampHeaderName`, `stampHeaderValue`, `body`) for debugging that stays client-side. (The Kotlin result also echoes `unsignedTransaction` for audit trails.) Tesser then broadcasts the signed transaction. You will receive `step.signed`, followed by `step.submitted`, `step.confirmed` (with `transaction_hash` populated), and `step.completed`. For wallet-originated on-chain steps, `step.submitted` is an event-only marker — see [Step Statuses](/overviews/funds-movement-lifecycle-and-data-model#step-statuses) in the lifecycle overview. Example `step.completed` webhook (Scenario 3, on-chain wallet-to-wallet transfer): ```json { "id": "a5b7c9d1-3e5f-4a7b-8c9d-0e1f2a3b4c5d", "type": "step.completed", "created_at": "2025-12-01T10:05:35.000Z", "data": { "object": { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "rebalance_id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "fees": [], "transaction_hash": "0xb1c2d3e4f5a6 ... f9a0b1c2", "provider_key": "turnkey", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:35.000Z", "submitted_at": "2025-12-01T10:04:05.000Z", "confirmed_at": "2025-12-01T10:04:07.500Z", "completed_at": "2025-12-01T10:05:35.000Z", "failed_at": null } } } ``` ### Executing a Wallet-to-OpenFX Rebalance A wallet-to-OpenFX rebalance uses the same signing flow as Scenario 3. Tesser emits `step.signature_requested` for the on-chain transfer, and you submit the signature through `POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign`. The transfer then moves through `step.signed`, `step.submitted`, and `step.confirmed`. In production, OpenFX detects the transfer to its registered deposit address and sends the deposit webhook that completes the transfer step. In sandbox and staging, create the matching OpenFX sandbox deposit after the transfer reaches `submitted`, as described under [Wallet to OpenFX Ledger](#wallet-to-openfx-ledger). The transfer step completes only after Tesser receives the completed deposit webhook. Its `actual.to.amount` reflects the amount OpenFX reports as credited and can differ from `estimated.to.amount`. A same-currency rebalance then completes; a cross-currency rebalance continues to the OpenFX swap step using the credited amount. ## Rebalance Info After Completion When the last step reaches a terminal state, Tesser populates the top-level `actual.*` overlay and emits a `rebalance.updated` webhook carrying the full updated Rebalance object. You can also retrieve the rebalance at any time via [`GET /v1/treasury/rebalances/{rebalanceId}`](/api/treasury#get-rebalance-by-id) — the response payload below matches the body of the terminal `rebalance.updated` webhook for each scenario. `desired.to.amount` remains `null` even after the rebalance completes; the indicative target is in `estimated.to.amount` and the realized amount is in `actual.to.amount`. Example `GET /v1/treasury/rebalances/{rebalanceId}` response (Scenario 1, complete): ```json { "data": { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:00.500Z", "submitted_at": "2025-12-01T10:05:00.300Z", "confirmed_at": "2025-12-01T10:05:00.400Z", "completed_at": "2025-12-01T10:05:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example `GET /v1/treasury/rebalances/{rebalanceId}` response (Scenario 2, complete with 0.5 bps slippage on the USDC fill): ```json { "data": { "id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDC", "network": "BASE" } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.475", "currency": "USDC", "network": "BASE" } }, "steps": [ { "id": "4d6f7a8b-1c2d-4e5f-8a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:33:00.500Z", "submitted_at": "2025-12-01T10:33:00.100Z", "confirmed_at": "2025-12-01T10:33:00.300Z", "completed_at": "2025-12-01T10:33:00.500Z", "failed_at": null }, { "id": "5e7f8a9b-2c3d-4e5f-9a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.475", "currency": "USDC", "network": "BASE" } }, "fees": [], "transaction_hash": "0x7e8c3f2a1b4d ... 7c8d9e0f", "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:36:35.000Z", "submitted_at": "2025-12-01T10:35:05.000Z", "confirmed_at": "2025-12-01T10:35:07.500Z", "completed_at": "2025-12-01T10:36:35.000Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:36:35.200Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example `GET /v1/treasury/rebalances/{rebalanceId}` response (Scenario 3, complete): ```json { "data": { "id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "reserved", "balance_reserved_at": "2025-12-01T10:00:01.000Z", "desired": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": null, "currency": "USDT", "network": "POLYGON" } }, "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "steps": [ { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "rebalance_id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "fees": [], "transaction_hash": "0xb1c2d3e4f5a6 ... f9a0b1c2", "provider_key": "turnkey", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:35.000Z", "submitted_at": "2025-12-01T10:04:05.000Z", "confirmed_at": "2025-12-01T10:04:07.500Z", "completed_at": "2025-12-01T10:05:35.000Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:05:35.200Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` ## Webhook Events by Scenario Below are the webhook events you will observe for each of the three rebalance scenarios in this guide. **Scenario 1 — Same-token rebalance between two Circle ledger accounts** | # | What happens | What you receive / do | Fields populated | |---|---|---|---| | 1 | You submit the rebalance request | HTTP response with `id`; `balance_status: "unreserved"` | `desired.from.amount` | | 2 | Tesser plans the route and obtains a quote | `rebalance.quote_created` webhook fires with 1 step and the populated `estimated` overlay | Steps array; `estimated.from.amount` and `estimated.to.amount` at rebalance and step level (1:1) | | 3 | Tesser reserves funds at the `desired.from.account_id` | `rebalance.balance_updated` webhook with `balance_status: "reserved"` | `balance_status`, `balance_reserved_at` | | 4 | Circle moves the USDC between ledger accounts | `step.submitted`, `step.confirmed`, and `step.completed` fire on the single step in close succession, followed by a `rebalance.updated` event carrying the full Rebalance with `actual.*` populated | Step 1: `actual.from.amount`, `actual.to.amount`, all timestamps. Rebalance-level `actual.from.amount` and `actual.to.amount`. Rebalance complete. | **Scenario 2 — Cross-token rebalance from OpenFX ledger to a BASE wallet** | # | What happens | What you receive / do | Fields populated | |---|---|---|---| | 1 | You submit the rebalance request | HTTP response with `id`; `balance_status: "unreserved"` | `desired.from.amount` | | 2 | Tesser plans the route and obtains a quote | `rebalance.quote_created` webhook fires with 2 steps and the populated `estimated` overlay | Steps array; `estimated.from.amount` and `estimated.to.amount` at rebalance and step level | | 3 | Tesser reserves USD at the `desired.from.account_id` | `rebalance.balance_updated` webhook with `balance_status: "reserved"` | `balance_status`, `balance_reserved_at` | | 4 | Tesser executes the swap (USD → USDC) at the OpenFX ledger | `step.submitted`, `step.confirmed`, and `step.completed` fire on the swap step in close succession | Step 1: `actual.from.amount`, `actual.to.amount` (reflects actual fill rate), all timestamps | | 5 | Tesser submits the on-chain transfer of USDC to the BASE wallet | `step.submitted` on the transfer step | Step 2: `submitted_at`, `actual.from.amount` | | 6 | The on-chain transaction is broadcast and accepted into the network's mempool (not yet in a block) | `step.confirmed` on the transfer step | Step 2: `confirmed_at`, `transaction_hash` | | 7 | The transfer reaches finality; rebalance is complete | `step.completed` on the transfer step, followed by `rebalance.updated` with the terminal Rebalance object | Step 2: `completed_at`, `actual.to.amount`. Rebalance-level `actual.to.amount`. | **Scenario 3 — Same-token rebalance between two self-custodial wallets** | # | What happens | What you receive / do | Fields populated | |---|---|---|---| | 1 | You submit the rebalance request | HTTP response with `id`; `balance_status: "unreserved"` | `desired.from.amount` | | 2 | Tesser plans the route and obtains a quote | `rebalance.quote_created` webhook fires with 1 step and the populated `estimated` overlay | Steps array; `estimated.from.amount` and `estimated.to.amount` at rebalance and step level (1:1) | | 3 | 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 | | 4 | You submit the signed transaction | `POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign` with `{ "signature": "0x..." }`; `step.signed` fires; `rebalance.balance_updated` with `balance_status: "reserved"` once the signed step is accepted | `balance_status`, `balance_reserved_at`; step `status: "signed"` | | 5 | Tesser broadcasts the signed transaction | `step.submitted` on the step | Step 1: `submitted_at` | | 6 | The on-chain transaction is broadcast and accepted into the network's mempool (not yet in a block) | `step.confirmed` on the step | Step 1: `confirmed_at`, `transaction_hash` | | 7 | The transfer reaches finality; rebalance is complete | `step.completed` on the step, followed by `rebalance.updated` with the terminal Rebalance object | Step 1: `completed_at`, `actual.from.amount`, `actual.to.amount`. Rebalance-level `actual.from.amount` and `actual.to.amount`. | **Additional route — Stablecoin from a self-custodial wallet to an OpenFX ledger** | # | What happens | What you receive / do | Fields populated | |---|---|---|---| | 1 | You submit the rebalance request | HTTP response with `id`; `balance_status: "unreserved"` | `desired.from.amount` | | 2 | Tesser plans the route and obtains a quote | `rebalance.quote_created` with one transfer step for a same-currency route, or a transfer followed by a swap for a cross-currency route | Steps array; `estimated.from.amount` and `estimated.to.amount` | | 3 | Tesser asks you to sign the on-chain transfer | `step.signature_requested` on the transfer step | Transfer step signing payload | | 4 | You submit the signature | `POST /v1/treasury/rebalances/{rebalanceId}/steps/{stepId}/sign` with `{ "signature": "0x..." }`; `step.signed` and `rebalance.balance_updated` with `balance_status: "reserved"` fire | `balance_status`, `balance_reserved_at`; transfer step `status: "signed"` | | 5 | Tesser broadcasts the signed transaction | `step.submitted` on the transfer step | Transfer step `submitted_at` | | 6 | The transaction is accepted by the network | `step.confirmed` on the transfer step | Transfer step `confirmed_at`, `transaction_hash` | | 7 | OpenFX credits the on-chain deposit. In sandbox and staging, create the matching OpenFX sandbox deposit after the transfer is submitted; production observes the deposit sent directly to OpenFX. | `step.completed` on the transfer step after Tesser receives the completed OpenFX deposit webhook; for a same-currency route, this is followed by `rebalance.updated` carrying the terminal Rebalance object | Transfer step `completed_at`, `actual.from.amount`, `actual.to.amount`; for a same-currency route, rebalance-level `actual.*` | | 8 | For a cross-currency route, OpenFX executes the swap; a same-currency route is already complete | Cross-currency only: `step.submitted`, `step.confirmed`, and `step.completed` on the swap, followed by `rebalance.updated` | Swap `actual.*`; rebalance-level `actual.*` | ## Failure Modes for Rebalances If a rebalance 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. ### Circle-Internal Transfer Rejected (Scenario 1) If Circle rejects the internal move between your two Circle ledger accounts, `step.submitted` fires (Tesser handed off to Circle) and the single `transfer` step then transitions to `failed` when Circle's response is a rejection rather than a confirmation. Funds remain at the `desired.from.account_id`. What you will observe: - `step.failed` on the single Circle-internal step. `actual.*` is null because no funds moved. - The rebalance's top-level `actual.*` is null for the same reason. `desired.*` is preserved. - A `rebalance.updated` webhook fires alongside the terminal `step.failed`, carrying the full updated Rebalance object with the populated terminal state. Example `step.failed` webhook (Scenario 1, Circle-internal step rejected): ```json { "id": "fa1a1b2c-d4e5-4ff0-90ab-aa34567890ab", "type": "step.failed", "created_at": "2025-12-01T10:30:00.000Z", "data": { "object": { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "failed", "status_reasons": [ { "error_code": "transfers-9301", "error_message": "The provider rejected the transfer step" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:30:00.000Z", "submitted_at": "2025-12-01T10:29:59.900Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T10:30:00.000Z" } } } ``` Example `rebalance.updated` webhook (Scenario 1, Circle-internal rejected): ```json { "id": "ea1a1b2c-d4e5-4fe0-90ab-bb34567890ab", "type": "rebalance.updated", "created_at": "2025-12-01T10:30:00.200Z", "data": { "object": { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "failed", "status_reasons": [ { "error_code": "transfers-9301", "error_message": "The provider rejected the transfer step" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:30:00.000Z", "submitted_at": "2025-12-01T10:29:59.900Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T10:30:00.000Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:30:00.200Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ### Trade Fails to Complete (OpenFX) (Scenario 2a) If Tesser attempts the swap at the OpenFX ledger and cannot get the trade to succeed before the rebalance's `expires_at` timestamp, the rebalance terminates with USD still at the OpenFX ledger. The conversion into USDC did not happen, and the subsequent on-chain transfer step is never submitted. What you will observe: - `step.failed` on Step 1 (swap). The step's `actual.*` is null because the swap never completed; failure details are in `status_reasons`. - `step.failed` on Step 2 (wallet transfer). This step never entered `submitted`, because there were no USDC to transfer. - The rebalance's top-level `actual.*` is all null because no step reached `step.status = completed` — the swap step failed, and Step 2 (wallet transfer) never started. `desired.to` stays as `USDC`/`BASE` at the wallet (client intent is never overwritten); `desired.from` and `estimated.*` describe what was requested and planned. - A `rebalance.updated` webhook fires alongside the terminal `step.failed` events, carrying the full updated Rebalance object with the populated `actual.*` overlay. Example `step.failed` webhook on Step 1 (swap, never filled): ```json { "id": "fa2a2bb2-2222-4ff2-8222-bb22222222a2", "type": "step.failed", "created_at": "2025-12-01T14:00:00.000Z", "data": { "object": { "id": "4d6f7a8b-1c2d-4e5f-8a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [ { "error_code": "transfers-9304", "error_message": "The currency conversion trade failed" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": "2025-12-01T10:32:18.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `step.failed` webhook on Step 2 (wallet transfer, never submitted): ```json { "id": "fa3b3cc3-3333-4ff3-8333-cc33333333a3", "type": "step.failed", "created_at": "2025-12-01T14:00:00.100Z", "data": { "object": { "id": "5e7f8a9b-2c3d-4e5f-9a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.100Z" } } } ``` Example `rebalance.updated` webhook (Scenario 2a, swap never filled — USD remains at the OpenFX ledger): ```json { "id": "ea2c4e6f-8a0b-4c2d-9e3f-4a5b6c7d8e9f", "type": "rebalance.updated", "created_at": "2025-12-01T14:00:00.300Z", "data": { "object": { "id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDC", "network": "BASE" } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "4d6f7a8b-1c2d-4e5f-8a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [ { "error_code": "transfers-9304", "error_message": "The currency conversion trade failed" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": "2025-12-01T10:32:18.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" }, { "id": "5e7f8a9b-2c3d-4e5f-9a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.100Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.300Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ### Wallet Transfer Fails Post-Swap (Scenario 2b) If the swap at the OpenFX ledger succeeds but the on-chain transfer to the BASE wallet fails (e.g., the transaction reverts on-chain or cannot be confirmed before `expires_at`), the rebalance terminates with USDC sitting at the OpenFX ledger. The swap step is `completed` with the actual fill amount; the transfer step is `failed`. What you will observe: - `step.completed` on Step 1 (swap). `actual.to.amount` reflects the realized fill (`999.475` USDC). - `step.failed` on Step 2 (wallet transfer). `actual.*` is null because no on-chain transfer was confirmed. - The rebalance's top-level `actual.from` reflects the `desired.from.amount` and `desired.from.currency` (`1000` USD). `actual.to` resolves to USDC at the OpenFX ledger account `2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b` with amount `999.475`. The USDC that resulted from the swap is available to use for a future (new) rebalance. - A `rebalance.updated` webhook fires alongside the terminal `step.failed` event. Example `step.failed` webhook on Step 2 (wallet transfer, post-swap failure): ```json { "id": "fb4c5d6e-7f8a-4b9c-9d0e-1f2a3b4c5d6e", "type": "step.failed", "created_at": "2025-12-01T11:00:00.000Z", "data": { "object": { "id": "5e7f8a9b-2c3d-4e5f-9a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [ { "error_code": "transfers-9303", "error_message": "The transfer step failed to execute" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:00:00.000Z", "submitted_at": "2025-12-01T10:35:05.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T11:00:00.000Z" } } } ``` Example `rebalance.updated` webhook (Scenario 2b, USDC stuck at OpenFX ledger): ```json { "id": "eb5d7f9a-1c3e-4b5d-8f0a-2c4e6f8a0b1c", "type": "rebalance.updated", "created_at": "2025-12-01T11:00:00.200Z", "data": { "object": { "id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDC", "network": "BASE" } }, "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USDC", "network": null } }, "steps": [ { "id": "4d6f7a8b-1c2d-4e5f-8a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 1, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:33:00.500Z", "submitted_at": "2025-12-01T10:33:00.100Z", "confirmed_at": "2025-12-01T10:33:00.300Z", "completed_at": "2025-12-01T10:33:00.500Z", "failed_at": null }, { "id": "5e7f8a9b-2c3d-4e5f-9a7b-8c9d0e1f2a3b", "rebalance_id": "8b2c3d4e-5f6a-4890-9bcd-2345678901bc", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [ { "error_code": "transfers-9303", "error_message": "The transfer step failed to execute" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:00:00.000Z", "submitted_at": "2025-12-01T10:35:05.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T11:00:00.000Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:00:00.200Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ### On-Chain Transfer Between Wallets Fails (Scenario 3) If the on-chain wallet-to-wallet transfer in Scenario 3 fails after broadcast — for example because of a chain reorg, gas exhaustion, or a nonce conflict — the rebalance terminates with USDT remaining at the source wallet. Pre-broadcast failures (e.g., the signed step never reaches the chain) are surfaced by the step-signing API itself; see the [`/how-tos/sign-a-wallet-step`](/how-tos/sign-a-wallet-step) peer guide. What you will observe: - `step.failed` on Step 1 (wallet-to-wallet transfer). Step-level `actual.*` is null; `status_reasons` carries the failure detail. `transaction_hash` is present because the transfer broadcast onto the chain before failing. - The rebalance's top-level `actual.*` is all null because no step reached `step.status = completed`. - `balance_status` rolls back to `unreserved` and `balance_reserved_at` clears to `null` at terminal failure. The funds remain at the source wallet on-chain; the reservation that was held at step signing is released so the wallet's balance is no longer locked against this rebalance. - A `rebalance.updated` webhook fires alongside the terminal `step.failed` event. Example `step.failed` webhook on Step 1 (on-chain wallet transfer broadcast but not confirmed): ```json { "id": "ec5a7d9f-3e2c-4561-b8d0-1f4e5a9c2b6d", "type": "step.failed", "created_at": "2025-12-01T11:00:00.000Z", "data": { "object": { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "rebalance_id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": "0xa1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e", "provider_key": "turnkey", "status": "failed", "status_reasons": [ { "error_code": "transfers-9305", "error_message": "The on-chain transaction failed to broadcast" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:00:00.000Z", "submitted_at": "2025-12-01T10:35:05.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T11:00:00.000Z" } } } ``` Example `rebalance.updated` webhook (Scenario 3, on-chain transfer failed; USDT remains at the source wallet): ```json { "id": "fa3e6c8b-7d2f-4561-9c0d-2e4f1b8d3a5c", "type": "rebalance.updated", "created_at": "2025-12-01T11:00:00.300Z", "data": { "object": { "id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": null, "currency": "USDT", "network": "POLYGON" } }, "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "rebalance_id": "7c3d4e5f-6a7b-4901-bcde-3456789012cd", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "6ff06a5d-30eb-42ae-9f60-e5b7680c94f2", "amount": "1000", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "9c4e7f2b-1d8a-4e6c-b3f5-8a2d6e9b1c4f", "amount": "1000", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": "0xa1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e", "provider_key": "turnkey", "status": "failed", "status_reasons": [ { "error_code": "transfers-9305", "error_message": "The on-chain transaction failed to broadcast" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:00:00.000Z", "submitted_at": "2025-12-01T10:35:05.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T11:00:00.000Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T11:00:00.300Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ### Insufficient Funds (Scenario 1 illustration) If the `desired.from.account_id` does not have enough balance to cover `desired.from.amount` when the async balance check runs (Scenarios 1 and 2), Tesser fires `rebalance.balance_updated` with `balance_status: "awaiting_funds"` shortly after `rebalance.quote_created` and queues the rebalance, retrying the reservation as the balance at `desired.from.account_id` changes (e.g., as a deposit lands or another rebalance frees funds). For wallet sources (Scenario 3 and wallet-to-OpenFX rebalances), insufficient funds surface as a 4XX from the step-signing API; `rebalance.balance_updated` fires shortly afterward, and your retry path is to fund the wallet and call sign again. If the reservation does not succeed before `expires_at`, the rebalance times out. What you will observe: - `rebalance.balance_updated` with `balance_status: "awaiting_funds"` shortly after `rebalance.quote_created` (ledger sources) or shortly after a rejected sign call (wallet sources). - (Optional) further `rebalance.balance_updated` events as balance changes are detected. - At `expires_at`: `step.failed` on every step that was still in `created` status. Step-level `actual.*` is null; `status_reasons` is `[]` because the step never started — the failure cause lives at the resource level (the rebalance timed out after remaining in `awaiting_funds` until `expires_at`). - The rebalance's top-level `actual.*` is all null because no step reached `step.status = completed` — no funds ever moved. - A terminal `rebalance.updated` webhook fires alongside the `step.failed` events, carrying the full updated Rebalance object reflecting the terminal state. Example `rebalance.balance_updated` webhook (Scenario 1, source ledger short): ```json { "id": "ac1b3d5e-7f9a-4b2c-8d4e-6f8a0b2c4d6e", "type": "rebalance.balance_updated", "created_at": "2025-12-01T10:00:01.000Z", "data": { "object": { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "awaiting_funds", "balance_reserved_at": null, "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:01.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `rebalance.updated` webhook (Scenario 1, timed out — funds at `desired.from.account_id` never replenished): ```json { "id": "ed6f8a0b-2c4e-4f6a-9b0c-3d5f7a9b1c3e", "type": "rebalance.updated", "created_at": "2025-12-01T14:00:00.500Z", "data": { "object": { "id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "rebalance", "balance_status": "unreserved", "balance_reserved_at": null, "desired": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "rebalance_id": "9a1b2c3d-4e5f-4789-a0bc-1234567890ab", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "c1c1e7d1-1aaa-4f01-b001-aaaa11110001", "amount": "1000", "currency": "USDC", "network": null }, "to": { "account_id": "c2c2f8d2-2bbb-4f02-b002-bbbb22220002", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.500Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` --- ## Document: Deposit Funds via a Liquidity Provider Deposit fiat funds via a liquidity provider, with or without on-ramping to stablecoin. URL: /how-tos/manage-your-treasury/deposit-funds-via-a-liquidity-provider # Deposit Funds via a Liquidity Provider 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](/overviews/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](/how-tos/create-an-account). ## Deposit Workflow Like a payment, a deposit will execute over multiple steps. - A `transfer` step indicates funds are moving from one account to another. Accounts can be a bank account, ledger, or wallet. In a `transfer` step, the source and destination currencies (`estimated.from.currency` and `estimated.to.currency`) may be the same or different. - A `swap` step indicates currencies have been exchanged within the same account (`estimated.from.account_id` and `estimated.to.account_id` are 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](/overviews/funds-movement-lifecycle-and-data-model#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 | 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. :::note If you choose to pre-fund, you will create a deposit for the transfer of fiat to the provider and then a [rebalance](/api/treasury#create-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 `desired` object: - `desired.from.account_id`: The identifier of the bank account fiat funds will be sent from (see [above](#prerequisites)). - `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. :::note Ensure the `desired.to.account_id` is associated with a liquidity provider that can support your requested `desired.from.currency` and `desired.to.currency`. ::: - `desired.to.currency`: Stablecoin currency you are on-ramping to. - `desired.to.network`: Stablecoin network on-ramping to (optional). Not applicable if `desired.to.account_id` has 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:** 1. Depositing and on-ramping to a ledger at Circle 2. Depositing fiat to a ledger at OpenFX 3. Depositing and on-ramping into a self-custodial wallet via OpenFX 4. Depositing and on-ramping into a self-custodial wallet via Circle Example request (deposit and on-ramp to a ledger at Circle) ```json { "tenant_id": null, "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD" }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "currency": "USDC" } } } ``` Example request (deposit fiat to a ledger at OpenFX) ```json { "tenant_id": null, "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD" }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "currency": "USD" } } } ``` Example request (deposit and on-ramp to a wallet via OpenFX) ```json { "tenant_id": null, "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "currency": "USDT", "network": "POLYGON" } } } ``` Example request (deposit and on-ramp to a wallet via Circle) ```json { "tenant_id": null, "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "currency": "USDC", "network": "BASE" } } } ``` 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): ```json { "data": { "id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example response (deposit fiat to a ledger at OpenFX): ```json { "data": { "id": "3c5d6f8e-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example response (deposit and on-ramp to a wallet via OpenFX): ```json { "data": { "id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDT", "network": "POLYGON" } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` Example response (deposit and on-ramp to a wallet via Circle): ```json { "data": { "id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDC", "network": "BASE" } }, "estimated": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } ``` ## 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): ```json { "id": "b8c4d5e6-2f3a-4b7c-9d8e-1f2a3b4c5d6e", "type": "deposit.quote_created", "created_at": "2025-12-01T10:00:00.600Z", "data": { "object": { "id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.600Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `deposit.quote_created` webhook (deposit fiat to a ledger at OpenFX): ```json { "id": "e2f3a4b5-6c7d-4e8f-9a0b-1c2d3e4f5a6b", "type": "deposit.quote_created", "created_at": "2025-12-01T10:00:02.000Z", "data": { "object": { "id": "3c5d6f8e-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "4d6f7a8b-1c2d-4e5f-6a7b-8c9d0e1f2a3b", "deposit_id": "3c5d6f8e-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:02.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "5e7f8a9b-2c3d-4e5f-6a7b-8c9d0e1f2a3b", "deposit_id": "3c5d6f8e-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:02.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:02.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `deposit.quote_created` webhook (deposit and on-ramp to a wallet via OpenFX): ```json { "id": "e47a9b21-5c4d-4f82-9a13-7b8e3d1c5f04", "type": "deposit.quote_created", "created_at": "2025-12-01T10:00:02.000Z", "data": { "object": { "id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDT", "network": "POLYGON" } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:02.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:02.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "5c7a8e4d-1b9f-4c62-a8e3-7d6f3b4a2c9e", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 3, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:02.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "9d3f6a2c-8e5b-4f17-b0a6-1c4e7d9f3b8a", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:02.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:02.000Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `deposit.quote_created` webhook (deposit and on-ramp to a wallet via Circle): ```json { "id": "d7c4f1e8-3b9a-4258-9c0e-2f6a8d1b5e3c", "type": "deposit.quote_created", "created_at": "2025-12-01T10:00:00.700Z", "data": { "object": { "id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDC", "network": "BASE" } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "4f8b1a3c-7e2d-4951-a8c5-6d9e3f1b2a4c", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "6c2d5e9a-3f7b-4824-b1e5-8a4c7f9d2e1b", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null }, { "id": "8e1f4b2d-5a6c-4937-a7d8-9b3e5f1c2a4d", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "created", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T10:00:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ## 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](/api/accounts#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. :::warning{title="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](#webhook-events-by-scenario) for the exact event sequence. When an OpenFX fiat deposit ends at your OpenFX ledger, the credited amount is added to the ledger's `available_balance` when the credit step completes. If the deposit includes a following swap or wallet-transfer step, the credited amount remains committed to that deposit while the remaining steps execute and is not available for another funds movement between steps. Example `step.completed` webhook (terminal step for deposit and on-ramp to a ledger at Circle): ```json { "id": "f1e2d3c4-5b6a-4798-8e0d-3f4a5b6c7d8e", "type": "step.completed", "created_at": "2025-12-02T14:30:00.500Z", "data": { "object": { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:30:00.400Z", "completed_at": "2025-12-02T14:30:00.500Z", "failed_at": null } } } ``` :::note 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): ```json { "id": "e2c6d8b1-4f7a-4e93-9b8d-2a5c7f3e1d4b", "type": "deposit.updated", "created_at": "2025-12-02T14:30:00.700Z", "data": { "object": { "id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:25:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:25:00.400Z", "completed_at": "2025-12-02T14:25:00.500Z", "failed_at": null }, { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:30:00.400Z", "completed_at": "2025-12-02T14:30:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `step.completed` webhook (terminal step for deposit fiat to a ledger at OpenFX): ```json { "id": "f3a4b5c6-7d8e-4f9a-0b1c-2d3e4f5a6b7c", "type": "step.completed", "created_at": "2025-12-02T14:30:00.500Z", "data": { "object": { "id": "5e7f8a9b-2c3d-4e5f-6a7b-8c9d0e1f2a3b", "deposit_id": "3c5d6f8e-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:30:00.400Z", "completed_at": "2025-12-02T14:30:00.500Z", "failed_at": null } } } ``` Example `deposit.updated` webhook (deposit fiat to a ledger at OpenFX complete): ```json { "id": "a4b5c6d7-8e9f-4a0b-1c2d-3e4f5a6b7c8d", "type": "deposit.updated", "created_at": "2025-12-02T14:30:00.700Z", "data": { "object": { "id": "3c5d6f8e-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": null, "currency": "USD", "network": null } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "steps": [ { "id": "4d6f7a8b-1c2d-4e5f-6a7b-8c9d0e1f2a3b", "deposit_id": "3c5d6f8e-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:25:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:25:00.400Z", "completed_at": "2025-12-02T14:25:00.500Z", "failed_at": null }, { "id": "5e7f8a9b-2c3d-4e5f-6a7b-8c9d0e1f2a3b", "deposit_id": "3c5d6f8e-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:30:00.400Z", "completed_at": "2025-12-02T14:30:00.500Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.700Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` Example `step.completed` webhook (terminal step for deposit and on-ramp to a wallet via OpenFX): ```json { "id": "c2e5f3d1-7b9a-4c48-8e0d-3f4a5b6c7d8e", "type": "step.completed", "created_at": "2025-12-02T14:36:35.000Z", "data": { "object": { "id": "9d3f6a2c-8e5b-4f17-b0a6-1c4e7d9f3b8a", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.475", "currency": "USDT", "network": "POLYGON" } }, "fees": [], "transaction_hash": "0x7e8c3f2a1b4d ... 7c8d9e0f", "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:36:35.000Z", "submitted_at": "2025-12-02T14:35:05.000Z", "confirmed_at": "2025-12-02T14:35:07.500Z", "completed_at": "2025-12-02T14:36:35.000Z", "failed_at": null } } } ``` Example `deposit.updated` webhook (deposit and on-ramp to a wallet via OpenFX complete): ```json { "id": "b8d3a5f7-2c4e-4691-9d3a-8f6b1e7c4d52", "type": "deposit.updated", "created_at": "2025-12-02T14:36:35.200Z", "data": { "object": { "id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDT", "network": "POLYGON" } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.475", "currency": "USDT", "network": "POLYGON" } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:25:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:25:00.400Z", "completed_at": "2025-12-02T14:25:00.500Z", "failed_at": null }, { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:30:00.400Z", "completed_at": "2025-12-02T14:30:00.500Z", "failed_at": null }, { "id": "5c7a8e4d-1b9f-4c62-a8e3-7d6f3b4a2c9e", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 3, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": null } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USDT", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:33:00.500Z", "submitted_at": "2025-12-02T14:33:00.100Z", "confirmed_at": "2025-12-02T14:33:00.300Z", "completed_at": "2025-12-02T14:33:00.500Z", "failed_at": null }, { "id": "9d3f6a2c-8e5b-4f17-b0a6-1c4e7d9f3b8a", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.475", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.475", "currency": "USDT", "network": "POLYGON" } }, "fees": [], "transaction_hash": "0x7e8c3f2a1b4d ... 7c8d9e0f", "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:36:35.000Z", "submitted_at": "2025-12-02T14:35:05.000Z", "confirmed_at": "2025-12-02T14:35:07.500Z", "completed_at": "2025-12-02T14:36:35.000Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:36:35.200Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` The final step — Tesser-initiated transfer of USDC from your Circle ledger to your self-custodial wallet — proceeds through three webhook events before `deposit.updated`: `step.submitted`, `step.confirmed`, and `step.completed`. Example `step.submitted` webhook (Step 3 — Tesser initiates the Circle ledger → wallet transfer): ```json { "id": "a3f7e2c1-5b8d-4926-9c4f-1e6d3b8a5f02", "type": "step.submitted", "created_at": "2025-12-02T14:32:00.100Z", "data": { "object": { "id": "8e1f4b2d-5a6c-4937-a7d8-9b3e5f1c2a4d", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "submitted", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:32:00.000Z", "submitted_at": "2025-12-02T14:32:00.000Z", "confirmed_at": null, "completed_at": null, "failed_at": null } } } ``` Example `step.confirmed` webhook (Step 3 — Circle acknowledges the transfer creation): ```json { "id": "b5e9d3a7-2c8f-4641-a7b3-5f9e2d1c4b8a", "type": "step.confirmed", "created_at": "2025-12-02T14:32:05.200Z", "data": { "object": { "id": "8e1f4b2d-5a6c-4937-a7d8-9b3e5f1c2a4d", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "confirmed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:32:05.000Z", "submitted_at": "2025-12-02T14:32:00.000Z", "confirmed_at": "2025-12-02T14:32:05.000Z", "completed_at": null, "failed_at": null } } } ``` Example `step.completed` webhook (Step 3 — on-chain settlement received from Circle): ```json { "id": "c8f1e4d2-9b3a-4758-b1e7-6d4a8c2f5b9e", "type": "step.completed", "created_at": "2025-12-02T14:34:30.300Z", "data": { "object": { "id": "8e1f4b2d-5a6c-4937-a7d8-9b3e5f1c2a4d", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "fees": [], "transaction_hash": "0x9b2e7f5d3c1a ... 0f9e3a1b", "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:34:30.000Z", "submitted_at": "2025-12-02T14:32:00.000Z", "confirmed_at": "2025-12-02T14:32:05.000Z", "completed_at": "2025-12-02T14:34:30.000Z", "failed_at": null } } } ``` :::note For Circle ledger → self-custodial wallet transfers, Tesser populates `transaction_hash` on the step at `step.completed` — not at `step.confirmed`. Circle initiates the on-chain transfer asynchronously after acknowledging the API call (`step.confirmed`); Tesser receives the transaction hash via SNS once the chain confirms settlement, which coincides with the step reaching `completed`. 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`. ::: Example `deposit.updated` webhook (deposit and on-ramp to a wallet via Circle complete): ```json { "id": "e2f5b8a1-7c3d-4914-9b8e-5d1f2a6c4b7e", "type": "deposit.updated", "created_at": "2025-12-02T14:34:30.500Z", "data": { "object": { "id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDC", "network": "BASE" } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "steps": [ { "id": "4f8b1a3c-7e2d-4951-a8c5-6d9e3f1b2a4c", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:25:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:25:00.400Z", "completed_at": "2025-12-02T14:25:00.500Z", "failed_at": null }, { "id": "6c2d5e9a-3f7b-4824-b1e5-8a4c7f9d2e1b", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:30:00.400Z", "completed_at": "2025-12-02T14:30:00.500Z", "failed_at": null }, { "id": "8e1f4b2d-5a6c-4937-a7d8-9b3e5f1c2a4d", "deposit_id": "9d2e5f4a-8c3b-4710-b9e6-2f7a1d6c4b5e", "step_sequence": 3, "step_type": "transfer", "estimated": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "actual": { "from": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": "BASE" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "1000", "currency": "USDC", "network": "BASE" } }, "fees": [], "transaction_hash": "0x9b2e7f5d3c1a ... 0f9e3a1b", "provider_key": "circle_mint", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:34:30.000Z", "submitted_at": "2025-12-02T14:32:00.000Z", "confirmed_at": "2025-12-02T14:32:05.000Z", "completed_at": "2025-12-02T14:34:30.000Z", "failed_at": null } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:34:30.500Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ## OpenFX Fiat Deposits Initiated Outside Tesser OpenFX may report a completed fiat deposit that was not preceded by a request to the Tesser Deposit API. When the deposit belongs to your connected OpenFX account, Tesser records it as a completed, fiat-only Deposit and adds the amount reported by OpenFX to your ledger's `available_balance`. You receive a `step.completed` event for each of the two completed transfer steps, followed by `deposit.updated`. The Deposit ID in these events can be used with the Deposit API. Because Tesser records the deposit after OpenFX reports it as complete, no `deposit.quote_created`, `step.submitted`, or `step.confirmed` events are emitted. This records the fiat credit only. To plan a deposit that continues with a currency conversion or transfer to a wallet, create the Deposit through the Tesser API before sending funds. This behavior is specific to fiat deposits. If OpenFX reports an on-chain deposit that does not match a submitted withdrawal or rebalance, Tesser records it as an [OpenFX ledger inbound Payment](/webhooks/payment-updates#openfx-ledger-inbound-payments), not a Deposit. ## 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. | :::note 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. | :::note 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`. | :::note 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. | :::note 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`. ::: :::note 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.failed` on the swap step (step 3 in scenario 3). Step-level `actual.*` is null; `status_reasons` carries the failure detail. - `step.failed` on the subsequent wallet-transfer step (step 4 in scenario 3). This step never entered `submitted` because there were no stablecoins to withdraw; step-level `actual.*` is null, and `status_reasons` is `[]` 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.from` matches the first completed step's `actual.from` (the original fiat source), and `actual.to` matches the last completed step's `actual.to` — the OpenFX ledger holding the source USD. `desired.to.currency` stays as the originally requested stablecoin (client intent is never overwritten). For a USD→USDT deposit, the terminal top-level shows `actual.to.currency: "USD"` and `actual.to.amount` equal to `actual.from.amount`. - The fiat balance at the ledger with the liquidity provider is available to use for a rebalance. - A `deposit.updated` webhook fires alongside the terminal `step.failed` events, carrying the full updated Deposit object with the populated `actual.*` overlay. You can use this webhook instead of GETting the deposit to observe the terminal state. :::note The JSON examples below show steps 3 and 4 from scenario 3. ::: Example webhook schema for `step.failed` on step 3 (swap): ```json { "id": "d8a4f2c1-6e3b-4f59-a7c0-1d2e3f4a5b6c", "type": "step.failed", "created_at": "2025-12-03T04:00:00.000Z", "data": { "object": { "id": "5c7a8e4d-1b9f-4c62-a8e3-7d6f3b4a2c9e", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 3, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [ { "error_code": "transfers-9304", "error_message": "The currency conversion trade failed" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-03T04:00:00.000Z", "submitted_at": "2025-12-02T14:32:18.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-03T04:00:00.000Z" } } } ``` Example webhook schema for `step.failed` on step 4 (wallet transfer, never submitted): ```json { "id": "e4b5f3d2-8c91-4a7b-b3e5-0d1f2a3b4c5d", "type": "step.failed", "created_at": "2025-12-03T04:00:00.100Z", "data": { "object": { "id": "9d3f6a2c-8e5b-4f17-b0a6-1c4e7d9f3b8a", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-03T04:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-03T04:00:00.100Z" } } } ``` Example `deposit.updated` webhook (deposit terminated as fiat after trade give-up): ```json { "id": "c5f3a2d8-7b4e-4192-9d6c-3e8a1f2b5c7d", "type": "deposit.updated", "created_at": "2025-12-03T04:00:00.300Z", "data": { "object": { "id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": null, "currency": "USDT", "network": "POLYGON" } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:25:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:25:00.400Z", "completed_at": "2025-12-02T14:25:00.500Z", "failed_at": null }, { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "completed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-02T14:30:00.500Z", "submitted_at": null, "confirmed_at": "2025-12-02T14:30:00.400Z", "completed_at": "2025-12-02T14:30:00.500Z", "failed_at": null }, { "id": "5c7a8e4d-1b9f-4c62-a8e3-7d6f3b4a2c9e", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 3, "step_type": "swap", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [ { "error_code": "transfers-9304", "error_message": "The currency conversion trade failed" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-03T04:00:00.000Z", "submitted_at": "2025-12-02T14:32:18.000Z", "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-03T04:00:00.000Z" }, { "id": "9d3f6a2c-8e5b-4f17-b0a6-1c4e7d9f3b8a", "deposit_id": "2b4c5e7a-9f3d-41b8-8c6a-7e5f9d2a1c3b", "step_sequence": 4, "step_type": "transfer", "estimated": { "from": { "account_id": "2e8f4c6b-3a1d-48e7-9b5c-0f4d2c9a7e3b", "amount": "999.525", "currency": "USDT", "network": "POLYGON" }, "to": { "account_id": "7f3e9c2a-8d5b-4a1e-9f6c-3b8e1d4a7c5f", "amount": "999.525", "currency": "USDT", "network": "POLYGON" } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "openfx", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-03T04:00:00.100Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-03T04:00:00.100Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-03T04:00:00.300Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ``` ### 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.failed` on every step that was still in `created` status when the deposit expired. Step-level `actual.*` is null; `status_reasons` is `[]` because the step never started — the failure cause lives at the resource level (the deposit reached `expires_at` while waiting for the customer's wire to arrive). - The deposit's top-level `actual.*` is all null because no step reached `step.status = completed` — no funds ever moved. - A `deposit.updated` webhook fires alongside the terminal `step.failed` events, 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): ```json { "id": "f6a8c4e2-3b9d-4f15-a7c8-5d2e9b1f3a4c", "type": "deposit.updated", "created_at": "2025-12-01T14:00:00.500Z", "data": { "object": { "id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "workspace_id": "b53f6690-3242-4942-9907-885779632832", "organization_reference_id": null, "direction": "inbound", "desired": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": null, "currency": "USDC", "network": null } }, "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "steps": [ { "id": "8a4f2c1e-9b6d-4e35-b7a0-3c5d1e9f2b8a", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 1, "step_type": "transfer", "estimated": { "from": { "account_id": "55042f8f-e527-56f1-b57c-eef23ca862db", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" }, { "id": "3b7e9d2f-1c4a-4f68-a5b0-6e8c1d3f9a2b", "deposit_id": "1c8e4a6f-9b2d-4f53-a0e7-5d3c1b9f2a8e", "step_sequence": 2, "step_type": "transfer", "estimated": { "from": { "account_id": "a81bc1f4-7e3d-4926-b04f-3d2e8a9c5f17", "amount": "1000", "currency": "USD", "network": null }, "to": { "account_id": "44031e7e-d416-45f0-a46b-ded12b9751ca", "amount": "1000", "currency": "USDC", "network": null } }, "actual": { "from": { "account_id": null, "amount": null, "currency": null, "network": null }, "to": { "account_id": null, "amount": null, "currency": null, "network": null } }, "fees": [], "transaction_hash": null, "provider_key": "circle_mint", "status": "failed", "status_reasons": [], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.000Z", "submitted_at": null, "confirmed_at": null, "completed_at": null, "failed_at": "2025-12-01T14:00:00.000Z" } ], "created_at": "2025-12-01T10:00:00.000Z", "updated_at": "2025-12-01T14:00:00.500Z", "expires_at": "2025-12-01T14:00:00.000Z" } } } ```