Solutions · Payouts and remittance

Pay thousands of people, safely

For payroll, contractor and marketplace platforms, and remittance companies. Your system decides who gets paid; Valtu sends each payout once, under the approval rules your finance team set, and reports back when it lands.

Wallet layout

WalletTypeNotes
PayoutsoperationalFunded before each run with what the run needs.
ReservetreasuryFunds the payouts wallet; two approvers.
GasoperationalPays token-transfer fees through the gas station.

Choosing the network

Fees decide most payout economics. As a rough guide for USDT or USDC:

NetworkTypical fee per payoutNotes
Tron (USDT)About 2 USD in TRX if the receiver already holds USDT, roughly double for an address that has never held itMost widely held USDT. Fee depends on energy.
Base, Polygon (USDC)CentsCheapest EVM options.
Solana (USDC, USDT)Under 1 cent, plus a one-time token-account rent for new receiversFast and cheap.
BNB ChainCents
EthereumVaries widelyUse for large payouts only.

Exact fees change with network load; the dashboard's Send dialog and your transfer's networkFee show the real figure.

Running a payout batch

Submit each payout with its own externalId. A crash halfway through is safe: run the batch again and already-created payouts come back unchanged.

import { ValtuError } from "@valtu/wallet-kit";

export async function runBatch(batchId: string) {
  for (const p of await db.payouts.pending(batchId)) {
    try {
      const t = await valtu.transfers.create({
        walletId: PAYOUTS_WALLET_ID,
        asset: p.asset,
        amount: await valtu.assets.toBaseUnits(p.asset, p.amount),
        to: p.address,
        externalId: p.id,                   // safe to re-run the whole batch
        category: "payroll",
        note: `${batchId} · ${p.payeeName}`,
      });
      await db.payouts.submitted(p.id, t.id, t.status);
    } catch (e) {
      if (e instanceof ValtuError && ["BAD_ADDRESS", "BELOW_MINIMUM", "POLICY_DENIED"].includes(e.code)) await db.payouts.invalid(p.id, e.message);
      else if (e instanceof ValtuError && ["INSUFFICIENT", "NO_GAS"].includes(e.code)) return db.batches.paused(batchId, e.message); // fund, then re-run
      else throw e;
    }
  }
}

Stay under 50 requests per second per key. For very large batches, run a few payouts in parallel rather than all at once.

Approvals for a batch

Two common setups:

  • Approve the funding, not each payout. Payouts from the payouts wallet skip approval up to a per-payout limit; moving money from the reserve into the payouts wallet needs two approvers. The approval is the decision to fund this run.
  • Approve each payout above a threshold. A rule such as withdrawal.value_usd > 5000 requires approval; approvers see the note and category in the dashboard's Transfers → Needs approval.

Add an address book for recurring payees: !withdrawal.is_whitelisted && withdrawal.value_usd > 1000 → Require approval. New addresses get a cooldown before they count as saved.

Tracking and reconciliation

Use the transfer.confirmed and transfer.failed webhooks to update each payout, with the transaction hash as the receipt you show the payee. At the end of the run, list everything in the batch to confirm nothing is left in flight:

for await (const t of valtu.transfers.iterate({ category: "payroll", after: batchStartedAt })) {
  if (!["CONFIRMED", "FAILED", "REJECTED", "CANCELLED"].includes(t.status)) inFlight.push(t.externalId);
}

Activity → Export gives your finance team the same data as CSV. Each transfer's networkFee and feeUsd from the API show what the network charged.