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.
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.
- Invite your team (Workspace → Team) and give each person the smallest role they need. Every member signs in with a passkey.
- Create approval groups (Security → Approval groups), for example "Finance" with 2 of 3 members. The default group approves policy changes.
- Write policies (Security → Policies). Rules are expressions with an effect: Block, Require approval or Skip approval.
| Rule | Effect | Why |
|---|---|---|
principal.kind == 'api_key' && withdrawal.value_usd <= 1000 | Skip approval | Small automated payouts go straight out. |
withdrawal.value_usd > 10000 | Require approval (Finance, 2) | Two people check large payouts. |
!withdrawal.is_whitelisted && wallet.type == 'treasury' | Block | Treasury 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:
readfor reporting,operatefor creating wallets and payouts,adminto 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.tgzimport { 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.PENDINGmeans 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.idwith 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.blockedandgas.lowevents.
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.orphanedhandled - 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