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
| Wallet | Type | Notes |
|---|---|---|
| Payouts | operational | Funded before each run with what the run needs. |
| Reserve | treasury | Funds the payouts wallet; two approvers. |
| Gas | operational | Pays token-transfer fees through the gas station. |
Choosing the network
Fees decide most payout economics. As a rough guide for USDT or USDC:
| Network | Typical fee per payout | Notes |
|---|---|---|
| Tron (USDT) | About 2 USD in TRX if the receiver already holds USDT, roughly double for an address that has never held it | Most widely held USDT. Fee depends on energy. |
| Base, Polygon (USDC) | Cents | Cheapest EVM options. |
| Solana (USDC, USDT) | Under 1 cent, plus a one-time token-account rent for new receivers | Fast and cheap. |
| BNB Chain | Cents | |
| Ethereum | Varies widely | Use 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 > 5000requires 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.