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.
Setup
Complete either Sandbox or Production setup before starting the Payment Workflow. Each environment uses separate accounts and IDs.
Sandbox
Send sandbox requests to https://sandbox.tesserx.co/v1 with a sandbox access token, using the sandbox token endpoint and audience provided by your Tesser contact.
1. Connect Kraken in Sandbox
Connect Kraken with the following sandbox request. Keep provider and key exactly as shown. The request requires the complete value structure, but sandbox ignores the supplied credentials and deposit details and uses its own simulated configuration. You can send these placeholder values unchanged.
Code
Successful setup provisions the simulated Kraken ledger. Real Kraken credentials and withdrawal-address verification are not required in sandbox.
2. Complete Zuba Onboarding
Ask your Tesser contact to enable Zuba onboarding for your sandbox workspace, then complete the application through the API. Use your sandbox API access token for requests to https://sandbox.tesserx.co/v1/organizations/zuba/onboarding. These endpoints return 404 when onboarding is not enabled for the workspace.
Create the application. Send POST /v1/organizations/zuba/onboarding/application with a kyb object containing your business information, applicant information, and associated persons. The following example shows the fields Tesser validates. Replace the illustrative values with your application details; Zuba may require additional information or documents during review.
Code
This call creates the Zuba sub-account if needed and creates or resumes its application. Save the returned persons[].person_id; kyb_index is the zero-based index of the corresponding person in your submitted kyb.persons array. If outcome is already_submitted, continue to checking the onboarding status.
Request a document upload. Send POST /v1/organizations/zuba/onboarding/documents for each required document. Set file_size to the actual size in bytes and mime_type to the file's content type. Supported files are PDF, PNG, and JPEG, up to 10 MiB.
Code
For a person's document, also include person_id from the application response. Save the returned document_id as DOCUMENT_ID and upload_url as UPLOAD_URL.
Upload and confirm the document. Send the file bytes to the returned URL using the same content type, then confirm the upload with Tesser:
Code
Repeat the upload request, file upload, and confirmation for each required document.
Submit the application for review. Send POST /v1/organizations/zuba/onboarding/submit with your sandbox API access token. If it returns 422, complete the missing information or documents identified in the error, then submit again.
Wait for activation. Check GET /v1/organizations/zuba/onboarding for status and application_status. These are the saved statuses, refreshed by Tesser's background worker. Submission alone does not mean activation: wait until status is active, then confirm that GET /v1/accounts lists the managed Zuba ledger before requesting a payment quote.
3. Find Accounts and Register the Parties
List your sandbox accounts:
Code
Code
Save the Kraken ledger ID as KRAKEN_LEDGER_ID.
Follow Create a Counterparty to create separate counterparties for the originator and beneficiary, then register a bank account for each with POST /v1/accounts/banks (Create an Account). The beneficiary's NGN account number must be exactly ten digits:
Code
4. Register the Funding Bank Account
Register your organization's funding bank account with POST /v1/accounts/banks, following Create an Account. Omit counterparty_id and tenant_id to register it at workspace level, and save the returned data.id as FUNDING_BANK_ACCOUNT_ID. If already registered, find it with GET /v1/accounts and reuse its ID. This is the organization's account used to fund Kraken; the originator's account is supplied separately as the payment's funding_account_id.
5. Create and Simulate the CAD Deposit
Create a deposit from your organization's funding bank account into the Kraken ledger:
Code
Save data.id as DEPOSIT_ID. Wait for deposit.quote_created, or until GET /v1/treasury/deposits/{id} returns a non-empty steps[]. Then simulate the CAD deposit:
Code
The response shows the deposit's transfer step as completed. Confirm the balance with GET /v1/accounts/$KRAKEN_LEDGER_ID: the CAD asset's available_balance shows 200.
6. Sandbox Execution Differences
- Steps 2 and 3 use
TEMPO_MODERATO, the testnet equivalent of productionTEMPO. - You still sign step 3, which is a simulated onchain transfer, equivalent to the production onchain deposit to Zuba.
- Step 4 is a simulated payout, equivalent to Zuba's NGN payout to the beneficiary's bank in production.
With setup complete, follow Phase 1: Request a Quote, then Phase 2: Attach Account IDs and Commit, using your sandbox accounts and access token.
Production
Use your production workspace and production access token for all requests in this section.
1. Connect Kraken in Production
Before connecting Kraken in production, prepare:
- A Kraken API key with
query-funds,withdraw-funds,query-ledger,query-open-trades,query-closed-trades, andmodify-tradespermissions. - A Kraken account with a CAD deposit method.
- Your Kraken CAD deposit instructions, including the matching deposit method ID.
Send POST /v1/organizations/secrets with your production access token and the following request body. Replace the placeholders with your Kraken credentials and deposit details. For nullable fields, provide the value from your deposit instructions when available.
Code
Successful setup provisions your workspace's managed Kraken ledger. These deposit details identify the bank account Kraken instructs you to send CAD to. Register your organization's sending bank account separately when funding the ledger.
2. Verify the Wallet's Withdrawal Address
Before executing a production payout, add and verify your managed wallet's address in Kraken as a withdrawal destination for USDC on Tempo (USDC.e). Find the wallet address with GET /v1/accounts.
3. Complete Zuba Onboarding
Coordinate Zuba onboarding with your Tesser contact and wait for confirmation that your Zuba account is active and its managed ledger is available.
4. Find Accounts and Register the Parties
List your production accounts:
Code
Code
Save the Kraken ledger ID as KRAKEN_LEDGER_ID.
Follow Create a Counterparty to create separate counterparties for the originator and beneficiary, then register a bank account for each with POST /v1/accounts/banks (Create an Account). The beneficiary's NGN account number must be exactly ten digits:
Code
5. Fund the Kraken Ledger with CAD
Register your organization's funding bank account with POST /v1/accounts/banks, following Create an Account. Omit counterparty_id and tenant_id to register it at workspace level, and save the returned data.id as FUNDING_BANK_ACCOUNT_ID. If already registered, find it with GET /v1/accounts and reuse its ID. This is the organization's account used to fund Kraken; the originator's account is supplied separately as the payment's funding_account_id.
Send CAD from your organization's funding bank account using the Kraken CAD deposit instructions supplied during setup. Wait until GET /v1/accounts/$KRAKEN_LEDGER_ID shows enough CAD in available_balance to fund the payment.
With setup complete, follow Phase 1: Request a Quote, then Phase 2: Attach Account IDs and Commit, using your production accounts and access token.
Payment Workflow
A payout from a pre-funded fiat ledger progresses through these phases:
- Create the payment and request a quote —
POST /v1/paymentswith the CAD amount to send, net of your organization's markup, and the destination currency. - 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 yourorganization_reference_id. This is the commit point: Tesser begins executing toward the committed amount. - 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.
- 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.
- Reach terminal state — the payment's
actualoverlay populates and a finalpayment.updatedwebhook fires alongside the laststep.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_ids on the two sides. See Steps.
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=<event> followed by any fields the event changes. The strict ordering shown is illustrative; follow the emitted step.* events for actual sequencing (see Execution).
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.
The Idempotency-Key header is optional on this POST. If you provide one, use a unique value for each new payment; retrying the same request with that key returns the original payment, while reusing it for a different request is rejected. Without the header, every POST creates a new payment, including a repeated request.
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.
Code
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.
Code
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.
Code
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.amountis 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_createdis 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 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,220.07 NGN | 95,220.07 NGN | 95,220.07 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 956.99 (USDC/CAD 1.3764 at Kraken, USDC/NGN 1,321.68 at Zuba) | CAD/NGN 956.99 (USDC/CAD 1.3764 at Kraken, USDC/NGN 1,321.68 at Zuba) | CAD/NGN 956.99 (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 957.76 (USDC/CAD 1.3763 at Kraken, USDC/NGN 1,322.68 at Zuba) | CAD/NGN 956.41 (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 (0.20%) | Step 1 fees[], fee_type: provider | 0.20 CAD | 0.20 CAD | 0.20 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.144725 USDC | 72.090254 USDC | 72.199274 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.044725 USDC | 71.990254 USDC | 72.099274 USDC |
| Your gain or loss | Not reported; desired.from.amount less actual.from.amount | None | 0.08 CAD gain | 0.06 CAD loss |
- The
estimatedoverlays always carry the initial rate. They are not revised after the quote; the amounts that actually move appear on each step'sactualoverlay when it completes, and on the payment'sactualoverlay at the end. - Fee line items are recorded when a step completes. Every step's
fees[]is empty onpayment.quote_createdand stays empty until that step completes, with one exception: step 3'sgasline item can appear when Tesser observes the transaction receipt, before the step completes. Theestimatedamounts are already net of the provider fees. Kraken's trade fee is recorded as aproviderline 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 aproviderline 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'sto.amountdivided by itsfrom.amountis 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).
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). 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_idis the originator's bank account, owned by a counterparty or tenant and used to identify the ultimate source of funds for Travel Rule purposes. The workspace-levelFUNDING_BANK_ACCOUNT_IDfrom setup funds the Kraken ledger.desired.from.account_idis 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_idis the beneficiary's NGN bank account, where Zuba delivers the payout.organization_reference_idis 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_wireis 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.
Code
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.099274 USDC for the committed amount rather than the 72.044725 at the initial rate. The final amounts appear on each step's actual overlay as the step completes (see Phase 4).
Code
Your Zuba ledger is a managed ledger account that Tesser provisioned for your workspace at Zuba during onboarding (see 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 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). See Balance Statuses.
Code
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.20 CAD fee converts at USDC/CAD 1.3764 to 72.14472537 USDC.
Code
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.
Code
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.
Code
Code
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.099274 USDC. It can differ from the step's estimated.from.amount, which still carries the initial-rate figure of 72.044725. To know the amount before you sign, read step 2's actual.to.amount: it is the USDC that landed in your wallet (72.099274 here), and the transaction moves exactly that amount. You do not compute this amount; you sign what Tesser presents.
Code
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. 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 for install, the full SDK reference, and the SDKs available for other languages.
Code
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).
Code
Code
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.
Code
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.
Code
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.
If you don't consume webhooks, poll GET /v1/payments/{id} to follow the same progress.
On success:
actual.to.amountequalsestimated.to.amount. The beneficiary received exactly the committed amount.actual.from.amountis the CAD actually drawn from your Kraken ledger. It can be more or less thanestimated.from.amountanddesired.from.amount, which both still read99.50, the CAD you submitted net of your markup, priced at the initial rate. In this example the fill cost99.56CAD. That difference is your organization's cost or gain; Tesser does not reconcile it with the originator.- The
estimatedoverlays still show the initial rate. Compareactualagainstestimatedon the payment and on each step to see where execution diverged.
Code
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.
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, reported by another payment.balance_updated. 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). 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.
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 |