Node.js · Payouts · Postgres

Send USDT payouts from Node.js

Send stablecoin payouts and batches from Node.js that can't pay twice: fee previews, approvals, error handling and webhooks that settle or refund each payout.

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

  1. A payout wallet: an operational wallet, funded with what you'll pay out. Put its id in VALTU_PAYOUT_WALLET_ID.
  2. 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.
  3. 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.
  4. An API key with the operate role, ideally scoped to the payout wallet only, and a webhook endpoint with the transfer.* 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:

CodeMeaningWhat the code does
BAD_ADDRESSNot an address on this networkFails the payout with the reason
BELOW_MINIMUMUnder the coin's minimum withdrawalFails it
POLICY_DENIEDOne of your policies blocks it; the message names the ruleFails it
INSUFFICIENT_FOR_FEEThe amount can't cover its own fee; the message says the most you can sendFails it
INSUFFICIENTThe payout wallet is short of fundsLeaves it new to retry after you top up
NO_GASNo coin for the network fee, and sponsorship is offLeaves it new
NETWORK_ERROR, TIMEOUTCouldn't reach Valtu, or no answerLeaves 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:

StatusMeaning
PENDING_APPROVALWaiting 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, SIGNINGApproved, or needed no approval: going to the enclave to be signed.
BROADCASTSent to the network. Slow ones have their fees raised automatically.
CONFIRMEDFinal on its network. txHash is the receipt to show your customer.
REJECTEDAn approver said no.
CANCELLEDCancelled by your team, or couldn't be sent within 30 minutes because the wallet lacked funds or gas.
FAILEDCouldn'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.

More guides

Build the next piece.

Put it into practice.

Every workspace starts on testnets with free test coins: same dashboard, same API. Go live when your team is ready.