Integration guide

From an empty workspace to live payouts

Follow these steps in a test workspace first. The same code runs in live: only the API key and the coins change. Expect about a day of work for a first integration.

Steps 1–3Set upWorkspace, team, approvals, API key.
Steps 4–5WalletsSDK installed, a wallet per customer.
Steps 6–7DepositsWebhooks credit customers; sweeps collect.
Steps 8–9PayoutsFees, payouts, reconciliation.
Step 10Go liveThe checklist.

1. Create a workspace

Sign up with your work email and add a passkey (Face ID, Touch ID, Windows Hello or a security key). There are no passwords. Then create a workspace and choose Test: it uses testnets, so nothing has real value.

Valtu reviews each new workspace and activates it, which creates its keys in the enclave. Until then the API answers 403 WORKSPACE_INACTIVE. The dashboard shows when it's active.

2. Add your team and approval rules

Decide who can move money before you write any code. Everything here is in the dashboard and is itself approved by your team.

  1. Invite your team (Workspace → Team) and give each person the smallest role they need. Every member signs in with a passkey.
  2. Create approval groups (Security → Approval groups), for example "Finance" with 2 of 3 members. The default group approves policy changes.
  3. Write policies (Security → Policies). Rules are expressions with an effect: Block, Require approval or Skip approval.
RuleEffectWhy
principal.kind == 'api_key' && withdrawal.value_usd <= 1000Skip approvalSmall automated payouts go straight out.
withdrawal.value_usd > 10000Require approval (Finance, 2)Two people check large payouts.
!withdrawal.is_whitelisted && wallet.type == 'treasury'BlockTreasury only pays saved addresses.

When no rule matches, the wallet's approval group (or the workspace default group) approves. Rules are checked when a payout is created, at each approval and again right before signing.

3. Create an API key

Developers → API keys → Create key. The key pair is generated in your browser and only the public key is sent to Valtu. Download the key file (valtu-<name>.json) and put it in your secret manager. Valtu can't show it again.

  • Role: read for reporting, operate for creating wallets and payouts, admin to also cancel anyone's payouts.
  • IP allowlist: your servers' egress addresses. Requests from anywhere else get 403 IP_NOT_ALLOWED.
  • Wallet scope (optional): limit a key to some wallets, plus every user wallet if you want it to create them.
  • Expiry: rotate keys at least yearly.

Keep keys on servers. Never put an API key in a browser, mobile app or git repository. Your frontend calls your backend; your backend calls Valtu.

4. Install the Wallet kit

npm install https://valtu.io/site/downloads/valtu-wallet-kit-latest.tgz
import { Valtu } from "@valtu/wallet-kit";

// VALTU_KEY_FILE=/run/secrets/valtu-server.json (or VALTU_PRIVATE_KEY=<hex>)
export const valtu = Valtu.fromEnv();

const ws = await valtu.workspace();
console.log(ws.name, ws.environment, ws.status); // "Acme" "test" "ACTIVE"

Not on Node.js? Every call is plain HTTPS with a signature header; see Authentication for the signing recipe and a Python example.

5. Give each customer a wallet

Create one user wallet per customer, keyed by your own customer id. The call is idempotent, so you can call it every time a customer opens their deposit screen.

import { depositAddress } from "@valtu/wallet-kit";
import { valtu } from "./valtu.ts";

// GET /me/deposit-address?network=tron   (your API, called by your app)
export async function depositAddressFor(customerId: string, network: string) {
  const wallet = await valtu.wallets.forCustomer(customerId); // same wallet every time
  const address = depositAddress(wallet, network);              // one EVM address covers ethereum, bsc, polygon, base, lisk
  if (!address) throw new Error(`no ${network} address`);
  const coins = (await valtu.assets.list()).filter((a) => a.chain === network);
  return { address, network, coins: coins.map((c) => c.asset), closesAt: coins.find((c) => c.closesAt)?.closesAt ?? null };
}

Also create, in the dashboard or the API, one operational wallet for payouts and one treasury wallet for reserves.

Show the network. Always print the network next to the address ("USDT on Tron") and the coins it accepts. Coins sent on the wrong network can't be credited automatically.

6. Credit deposits from webhooks

Developers → Webhooks → Add endpoint with your public https URL and the events deposit.* and transfer.*. Copy the workspace's Ed25519 public key from the same page (it isn't secret).

import express from "express";
import { constructEvent, WebhookVerificationError } from "@valtu/wallet-kit";

const app = express();

// The raw body is needed to check the signature: register this before express.json().
app.post("/valtu/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = constructEvent(req.body, req.headers, { publicKey: process.env.VALTU_WEBHOOK_PUBLIC_KEY });
  } catch (e) {
    if (e instanceof WebhookVerificationError) return res.sendStatus(401);
    throw e;
  }

  // Delivery is at least once: record the event id first, skip it if seen.
  const firstTime = await db.events.insertIfNew(event.id);
  if (!firstTime) return res.sendStatus(200);

  switch (event.type) {
    case "deposit.confirmed":
      // Credit exactly once per deposit id, in the same database transaction.
      await db.ledger.credit({
        customerWalletId: event.data.walletId,
        asset: event.data.asset,
        amount: event.data.amount,      // base units
        reference: event.data.depositId,
      });
      break;
    case "deposit.pending":
      await db.notify.pendingDeposit(event.data);   // show "on its way", don't credit
      break;
    case "deposit.orphaned":
      await db.ledger.reverseIfCredited(event.data.depositId); // a chain reorganization removed it
      break;
    case "transfer.confirmed":
    case "transfer.failed":
    case "transfer.rejected":
    case "transfer.cancelled":
      await db.payouts.settle(event.data.externalId, event.data.status, event.data.txHash);
      break;
  }
  res.sendStatus(200);
});
  • Credit only on deposit.confirmed. PENDING means seen on-chain but not final.
  • Answer within 10 seconds. Do slow work in a queue. Failed deliveries are retried with backoff for about a day.
  • De-duplicate on event.id with a unique index, so redeliveries and replays are harmless.
  • Use Send test event on the endpoint page to check your handler. Every delivery, with your response, is in the delivery log for 30 days.

To find a customer from a deposit, store the wallet id with your customer, or use walletExternalId on GET /v1/deposits/:id.

7. Collect funds and pay network fees

Sweeps (Automation → Automations) move confirmed deposits from user wallets into your treasury or operational wallet on a schedule, once they're worth your minimum. Sweeps skip approvals because funds stay in your workspace, and appear as transfers with kind: "SWEEP".

Gas station (Automation → Gas station). Token transfers pay fees in the network's own coin: TRX for USDT on Tron, ETH for ERC-20, SOL for SPL. Pick an operational wallet as the gas wallet, fund it, and per network turn on:

  • Sponsor token withdrawals: a token payout from a wallet without enough native coin first gets the fee it needs.
  • Auto-refill: keep operational wallets topped up to a target.
  • Daily cap and Warn below: you get gas.blocked and gas.low events.

8. Send payouts

Use your own payout id as externalId. If a request times out, send it again with the same id: you get the same payout back, never a second one.

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

export async function sendPayout(p: { id: string; asset: string; amount: string; to: string }) {
  try {
    const transfer = await valtu.transfers.create({
      walletId: process.env.VALTU_PAYOUT_WALLET_ID!,
      asset: p.asset,                                        // e.g. "USDT-TRC20"
      amount: await valtu.assets.toBaseUnits(p.asset, p.amount), // "25.50" → "25500000"
      to: p.to,
      externalId: p.id,                                      // your payout id: retries are safe
      category: "withdrawal",
    });
    return { status: transfer.status, transferId: transfer.id };
    // PENDING_APPROVAL: waiting for your team; QUEUED: going to signing now.
  } catch (e) {
    if (!(e instanceof ValtuError)) throw e;
    switch (e.code) {
      case "BAD_ADDRESS":          return { status: "rejected", reason: "Invalid address for this network" };
      case "BELOW_MINIMUM":        return { status: "rejected", reason: e.message };
      case "INSUFFICIENT":         return { status: "retry_later", reason: "Payout wallet needs funds" };
      case "NO_GAS":               return { status: "retry_later", reason: "Payout wallet needs gas (or turn on sponsorship)" };
      case "INSUFFICIENT_FOR_FEE": return { status: "rejected", reason: e.message }; // says the most you can send
      case "POLICY_DENIED":        return { status: "rejected", reason: e.message }; // names your rule
      default: throw e;
    }
  }
}

To show the network fee before your customer confirms, call valtu.transfers.preview() with the same fields: it returns the fee and whether the wallet can pay it, without creating anything. For many payouts at once, use transfers.createBatch() (up to 100 per call).

A payout's status then moves through PENDING_APPROVAL → QUEUED → SIGNING → BROADCAST → CONFIRMED, and you get a webhook at each step. Approvers approve in the dashboard (Transfers → Needs approval) with their passkey. A payout that waits more than 30 minutes because the wallet can't pay for it is cancelled automatically, with the reason in failure.

9. Reconcile every day

Webhooks are fast, but a daily job that compares your records with Valtu catches anything missed (an endpoint that was down longer than the retry window, a bug in a handler).

const since = new Date(Date.now() - 2 * 24 * 3600_000).toISOString();

for await (const d of valtu.deposits.iterate({ status: "CONFIRMED", after: since })) {
  if (!(await db.ledger.hasCredit(d.id))) await db.ledger.credit({ customerWalletId: d.walletId, asset: d.asset, amount: d.amount, reference: d.id });
}
for await (const t of valtu.transfers.iterate({ kind: "TRANSFER", after: since })) {
  if (t.externalId) await db.payouts.syncStatus(t.externalId, t.status, t.txHash);
}

The dashboard's Activity page exports the same data to CSV for your finance team.

"I sent it, but it isn't there." Give your support team a tool that calls valtu.deposits.lookup(network, txHash). It tells them whether the deposit is recorded, still waiting for its block, or not on that network at all, and re-reads the block if Valtu missed it.

10. Go live

Create a second workspace with Live as its environment (Valtu reviews and activates it), then repeat steps 2, 3 and 6 there. Your code doesn't change.

  • Live API key in your secret manager, with an IP allowlist and an expiry
  • Approval groups and policies set, including a limit for API-created payouts
  • Webhook endpoint on the live workspace, signature checked, events de-duplicated by id
  • Deposits credited only on deposit.confirmed; deposit.orphaned handled
  • Every payout sent with an externalId
  • Sweep task to your treasury wallet; gas station funded with a daily cap
  • Alerts (Automation → Alerts) for large deposits, failed payouts and low gas, to email, Slack or Telegram
  • Daily reconciliation job running
  • A small real deposit and payout on each network you'll offer