Customer withdrawals, seller payouts and contractor pay all come down to the same job: send a stablecoin to an address, once, with the right people's approval, and know when it has landed. This guide builds that in Node.js and Postgres:
- a fee and policy preview before a payout is committed;
- single payouts and batches of up to 100 that can't be sent twice;
- a webhook handler that settles each payout, or gives the money back when it doesn't go out.
We ran this code against Postgres with retries, invalid addresses, an empty wallet, approvals and rejections.
Before you start
- A payout wallet: an operational wallet, funded with what you'll pay out. Put its id in
VALTU_PAYOUT_WALLET_ID. - Fees: a USDT payout pays its network fee in the network's own coin (TRX on Tron, ETH on Ethereum). Either hold some in the payout wallet, or turn on gas sponsorship in the gas station so a gas wallet tops it up per payout.
- Policies: decide what goes out automatically and what needs a person. For example, payouts up to $2,000 pass, larger ones need one approver, and more than $50,000 in an hour needs two. See approval policy.
- An API key with the
operaterole, ideally scoped to the payout wallet only, and a webhook endpoint with thetransfer.*events.
npm install @valtu/wallet-kit express pg
npm install -D tsx @types/express @types/pg
npm pkg set type=module
1. A payouts table
CREATE TABLE payouts (
id text PRIMARY KEY, -- your id: sent to Valtu as externalId
customer_id text NOT NULL,
asset text NOT NULL, -- e.g. USDT-TRC20
amount text NOT NULL, -- "250.00", in whole coins
to_address text NOT NULL,
status text NOT NULL DEFAULT 'new', -- new · sent · approval · paid · failed
transfer_id text,
tx_hash text,
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE valtu_events (id text PRIMARY KEY, type text NOT NULL, received_at timestamptz NOT NULL DEFAULT now());
Write the payout row before calling Valtu, and use its id as the externalId. If your process crashes between the two, calling send() again for the same row can't create a second payout. See idempotency.
2. Preview, send and batch
import { Valtu, ValtuError, type CreateTransferInput } from "@valtu/wallet-kit";
import pg from "pg";
export const valtu = Valtu.fromEnv();
export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const WALLET = process.env.VALTU_PAYOUT_WALLET_ID!; // an operational wallet, funded with what you pay out
type Payout = { id: string; asset: string; amount: string; to_address: string };
const input = async (p: Payout): Promise<CreateTransferInput & { externalId: string }> => ({
walletId: WALLET,
asset: p.asset,
amount: await valtu.assets.toBaseUnits(p.asset, p.amount), // "250.00" → "250000000" for USDT on Tron
to: p.to_address,
externalId: p.id, // retrying with the same id returns the same payout, never a second one
});
/** What the payout will cost in fees, and whether your policies let it through, before you commit. */
export async function quote(p: Payout) {
const preview = await valtu.transfers.preview(await input(p));
return { outcome: preview.outcome, reason: preview.reason, fee: preview.fee };
}
/** Send one payout. Safe to call again for the same payout after a timeout or a crash. */
export async function send(p: Payout): Promise<void> {
try {
const t = await valtu.transfers.create(await input(p));
const status = t.status === "PENDING_APPROVAL" ? "approval" : "sent";
await db.query("UPDATE payouts SET status = $2, transfer_id = $3 WHERE id = $1 AND status = 'new'", [p.id, status, t.id]);
} catch (e) {
if (!(e instanceof ValtuError)) throw e;
switch (e.code) {
case "BAD_ADDRESS": // not an address on this network
case "BELOW_MINIMUM": // under the coin's minimum withdrawal
case "POLICY_DENIED": // one of your policies blocks it; the message names the rule
case "INSUFFICIENT_FOR_FEE":
await db.query("UPDATE payouts SET status = 'failed', reason = $2 WHERE id = $1", [p.id, e.message]);
return;
default:
// INSUFFICIENT (fund the payout wallet), NO_GAS (fund it with the network's coin, or turn on
// gas sponsorship), NETWORK_ERROR, TIMEOUT…: the payout stays 'new'; call send() again later.
throw e;
}
}
}
/** Pay many at once: up to 100 per call. Each payout succeeds or fails on its own. */
export async function sendBatch(list: Payout[]): Promise<void> {
for (let i = 0; i < list.length; i += 100) {
const chunk = list.slice(i, i + 100);
const { results } = await valtu.transfers.createBatch(await Promise.all(chunk.map(input)));
for (const r of results) {
const p = chunk[r.index]!;
if (r.ok) await db.query("UPDATE payouts SET status = $2, transfer_id = $3 WHERE id = $1 AND status = 'new'", [p.id, r.transfer.status === "PENDING_APPROVAL" ? "approval" : "sent", r.transfer.id]);
else await db.query("UPDATE payouts SET status = 'failed', reason = $2 WHERE id = $1", [p.id, `${r.error.code}: ${r.error.message}`]);
}
}
}
What each error means:
| Code | Meaning | What the code does |
|---|---|---|
BAD_ADDRESS | Not an address on this network | Fails the payout with the reason |
BELOW_MINIMUM | Under the coin's minimum withdrawal | Fails it |
POLICY_DENIED | One of your policies blocks it; the message names the rule | Fails it |
INSUFFICIENT_FOR_FEE | The amount can't cover its own fee; the message says the most you can send | Fails it |
INSUFFICIENT | The payout wallet is short of funds | Leaves it new to retry after you top up |
NO_GAS | No coin for the network fee, and sponsorship is off | Leaves it new |
NETWORK_ERROR, TIMEOUT | Couldn't reach Valtu, or no answer | Leaves it new: retrying is safe |
A batch isn't all-or-nothing: each payout in it is created or refused on its own, and sending the same batch again returns the payouts already created. Stay under 50 requests a second per API key.
3. Follow each payout to the end
import express from "express";
import { constructEvent, WebhookVerificationError, type WebhookEvent } from "@valtu/wallet-kit";
import { db } from "./payouts.ts";
const app = express();
app.post("/valtu/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
let event: WebhookEvent;
try {
event = constructEvent(req.body, req.headers, { publicKey: process.env.VALTU_WEBHOOK_PUBLIC_KEY });
} catch (e) {
if (e instanceof WebhookVerificationError) return res.sendStatus(400);
throw e;
}
const client = await db.connect();
try {
await client.query("BEGIN");
const first = await client.query("INSERT INTO valtu_events (id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING", [event.id, event.type]);
// Only payouts you made carry your externalId: sweeps and gas top-ups have none.
const update = async (externalId: string | null, sql: string, ...args: unknown[]) => {
if (first.rowCount === 1 && externalId) await client.query(sql, [externalId, ...args]);
};
switch (event.type) {
case "transfer.confirmed":
await update(event.data.externalId, "UPDATE payouts SET status = 'paid', tx_hash = $2 WHERE id = $1", event.data.txHash);
break;
case "transfer.failed":
case "transfer.rejected": // an approver said no
case "transfer.cancelled": // couldn't be sent within 30 minutes, or cancelled by your team
// Nothing left the wallet: give the amount back to the customer's balance here.
await update(event.data.externalId, "UPDATE payouts SET status = 'failed', reason = $2 WHERE id = $1 AND status <> 'paid'", event.data.failure ?? event.type);
break;
case "transfer.queued": // approved, or needed no approval: on its way to signing
await update(event.data.externalId, "UPDATE payouts SET status = 'sent' WHERE id = $1 AND status IN ('new', 'approval')");
break;
}
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
res.sendStatus(200);
});
app.listen(Number(process.env.PORT ?? 3000));
A payout moves through these statuses, with a webhook at each step:
| Status | Meaning |
|---|---|
PENDING_APPROVAL | Waiting for your approvers. They confirm with a passkey, bound to the exact amount and destination. Whoever created the API key that proposed it can't approve it. |
QUEUED, SIGNING | Approved, or needed no approval: going to the enclave to be signed. |
BROADCAST | Sent to the network. Slow ones have their fees raised automatically. |
CONFIRMED | Final on its network. txHash is the receipt to show your customer. |
REJECTED | An approver said no. |
CANCELLED | Cancelled by your team, or couldn't be sent within 30 minutes because the wallet lacked funds or gas. |
FAILED | Couldn't be completed. failure says why. |
In the last three, nothing left the wallet: return the amount to the customer's balance in the same transaction.
Network notes
- Tron: USDT transfers need energy. Connect an energy provider key in the gas station, and payouts rent energy whenever that's cheaper than burning TRX. A first payout to an address that has never held USDT costs more.
- Bitcoin: fees come from the coins being sent, and a payout still unconfirmed after 30 minutes is replaced with a higher fee. The transfer keeps one id.
- EVM networks: payouts from one wallet go out in nonce order, so one waiting for approval holds back that wallet's later payouts on that network.
Going live
- Keep only a day or two of payouts in the payout wallet, and refill it from treasury with two approvers.
- Velocity rules, such as a maximum per hour and per day, limit the damage if a payout key is ever misused.
- Screen destinations with your own provider if your compliance program needs it.
- Use Activity → Export to give finance every payout with its network fee.
More: the payouts and remittance guide in the docs, and how a payout gets signed.