Wallet kit · SDK
@valtu/wallet-kit
The Valtu Wallet SDK for your servers. It signs every call with your API key, retries safely, verifies webhooks and converts amounts, with TypeScript types for every object and event. No dependencies.
| Version | 0.5.0 · MIT licence · changelog |
| Runtime | Node.js 18.17 or newer (also Bun). ES modules, with types included. |
| Download | valtu-wallet-kit-latest.tgz |
Install
npm install https://valtu.io/site/downloads/valtu-wallet-kit-0.5.0.tgz
pnpm add https://valtu.io/site/downloads/valtu-wallet-kit-0.5.0.tgz
yarn add @valtu/wallet-kit@https://valtu.io/site/downloads/valtu-wallet-kit-0.5.0.tgz
Pin the versioned file (as above) in production, so your build never changes underneath you. -latest.tgz always points at the newest release.
Configure
Create an API key in the dashboard (Developers → API keys) and download its key file. Then create one client and share it across your app:
import { Valtu } from "@valtu/wallet-kit";
export const valtu = Valtu.fromKeyFile("/run/secrets/valtu-server.json");
// VALTU_PRIVATE_KEY=<64 hex chars> or VALTU_KEY_FILE=/run/secrets/valtu-server.json
// VALTU_BASE_URL is optional (default https://valtu.io)
export const valtu = Valtu.fromEnv();
export const valtu = new Valtu({
privateKey: process.env.VALTU_PRIVATE_KEY!,
baseUrl: "https://valtu.io", // default
timeoutMs: 30_000, // per attempt
maxRetries: 2, // network errors, 429, 502–504; safe requests only
});
Want to make the key pair yourself, on the server that will use it? Run npx valtu-wallet-kit keygen and paste the public key under "Paste a public key" when creating the API key.
Server-side only. The client holds your private key. Never bundle it into a browser or mobile app.
Wallets
import { depositAddress } from "@valtu/wallet-kit";
const wallet = await valtu.wallets.forCustomer("cus_42"); // a "user" wallet, created once
depositAddress(wallet, "tron"); // "TQm8…"
depositAddress(wallet, "bsc"); // the EVM address (same on ethereum, polygon, base, lisk)
await valtu.wallets.create({ name: "Payouts", type: "operational", externalId: "payouts" });
await valtu.wallets.get(id);
await valtu.wallets.findByExternalId("cus_42"); // Wallet | null
const page = await valtu.wallets.list({ type: "user", limit: 100 }); // { items, nextCursor }
for await (const w of valtu.wallets.iterate({ type: "user" })) { /* every page */ }
// Live balances: balance, onHold (reserved by unsent payouts) and available
const { balances, totalUsd } = await valtu.wallets.balances(payoutWallet.id);
const usdt = balances.find((b) => b.asset === "USDT-TRC20");
// Workspace totals per coin, and network status
const totals = await valtu.balances(); // held + awaitingSweep
const networks = await valtu.networks(); // status, typical fee and confirmation timeTransfers
const t = await valtu.transfers.create({
walletId, asset: "USDC-BASE",
amount: await valtu.assets.toBaseUnits("USDC-BASE", "120.75"),
to: "0x9f…",
externalId: "payout_1043", // with it, the SDK retries safely; without it, never
});
await valtu.transfers.get(t.id);
await valtu.transfers.findByExternalId("payout_1043");
await valtu.transfers.list({ status: ["QUEUED", "SIGNING", "BROADCAST"] });
for await (const x of valtu.transfers.iterate({ kind: "TRANSFER", after: "2026-10-01T00:00:00Z" })) { /* … */ }
await valtu.transfers.cancel(t.id); // before signing only
// Before a customer confirms: the fee, and whether a policy blocks it or needs approval
const preview = await valtu.transfers.preview({ walletId, asset: "USDT-TRC20", amount, to });
if (!preview.fee?.ok) showError(preview.fee?.message); // NO_GAS / INSUFFICIENT_FOR_FEE
// Up to 100 payouts per call; each needs an externalId; check each result
const batch = await valtu.transfers.createBatch(payouts);
for (const r of batch.results) if (!r.ok) markFailed(r.externalId, r.error.code);
// Scripts and tests: poll until final (use webhooks in production)
const done = await valtu.transfers.wait(t.id, { timeoutMs: 10 * 60_000 });Deposits and assets
for await (const d of valtu.deposits.iterate({ status: "CONFIRMED", walletId })) { /* … */ }
await valtu.deposits.get(depositId);
await valtu.deposits.findByTxHash("0x9f…"); // recorded deposits from one transaction
const look = await valtu.deposits.lookup("tron", txHash); // found | pending | rescanning | not_found
const assets = await valtu.assets.list(); // cached 5 minutes
await valtu.assets.get("USDT-TRC20"); // { decimals: 6, contract: "TR7N…", closesAt: null, … }
await valtu.assets.fromBaseUnits("ETH", "1500000000000000000"); // "1.5"Contract calls
// Contracts on your allowlist (added in the dashboard, with approval)
const contracts = await valtu.contracts.list();
// Call an allowed function: a transfer of kind CALL, under your policies
const t = await valtu.contracts.call({ walletId, contractId, function: "deposit", args: ["1000000", treasuryAddress], externalId: "vault-1" });Checkouts
// A payment link for an order: redirect the customer to checkout.url
const checkout = await valtu.checkouts.create({ amount: "49.00", externalId: order.id, successUrl });
// In your webhook handler
if (event.type === "checkout.completed") fulfil(event.data.externalId, event.data.received);Amounts
Conversions use integer arithmetic, so there's no floating-point rounding. Too many decimals throws instead of silently rounding.
import { toBaseUnits, fromBaseUnits } from "@valtu/wallet-kit";
toBaseUnits("25.5", 6); // "25500000"
toBaseUnits("0.1", 18); // "100000000000000000"
fromBaseUnits("25500000", 6); // "25.5"
toBaseUnits("1.2345678", 6); // throws RangeError: more than 6 decimalsWebhooks
constructEvent checks the Ed25519 signature (or the HMAC, with the endpoint secret) and the timestamp, then returns a typed event. Narrow on event.type and TypeScript knows the shape of event.data.
import express from "express";
import { constructEvent, EventDeduper } from "@valtu/wallet-kit";
const seen = new EventDeduper(); // in-memory; use a unique index in your database with several servers
app.post("/valtu/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = constructEvent(req.body, req.headers, { publicKey: process.env.VALTU_WEBHOOK_PUBLIC_KEY });
} catch {
return res.sendStatus(401);
}
res.sendStatus(200);
if (!seen.first(event.id)) return;
if (event.type === "deposit.confirmed") queue.add("credit", event.data); // data: DepositEventData
});
import { constructEvent } from "@valtu/wallet-kit";
app.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => done(null, body));
app.post("/valtu/webhooks", async (req, reply) => {
let event;
try {
event = constructEvent(req.body as string, req.headers, { publicKey: process.env.VALTU_WEBHOOK_PUBLIC_KEY });
} catch {
return reply.code(401).send();
}
await handle(event);
return reply.code(200).send();
});
// app/api/valtu/route.ts
import { constructEvent } from "@valtu/wallet-kit";
export async function POST(req: Request) {
const raw = await req.text();
try {
const event = constructEvent(raw, req.headers, { publicKey: process.env.VALTU_WEBHOOK_PUBLIC_KEY });
await handle(event);
return new Response(null, { status: 200 });
} catch {
return new Response(null, { status: 401 });
}
}
Get the verification key from the API instead of configuration: const { publicKey } = await valtu.webhooks.publicKey(). valtu.webhooks.list() shows your endpoints and their delivery health, and valtu.webhooks.test(id) sends a test event.
Lower-level helpers return a boolean: verifyWebhookEd25519(rawBody, header, publicKey) and verifyWebhook(rawBody, header, secret).
Errors
Every failed call throws ValtuError:
import { ValtuError } from "@valtu/wallet-kit";
try {
await valtu.transfers.create(input);
} catch (e) {
if (e instanceof ValtuError) {
e.status; // 400
e.code; // "NO_GAS" (see the API guide for every code)
e.message; // human-readable
e.requestId; // quote this to support
}
}Network failures throw with code: "NETWORK_ERROR" (status 0), and transfers.wait timeouts with code: "TIMEOUT".
Retries
GET requests, and POSTs that carry an externalId, are retried on network errors, 429 and 502–504, up to maxRetries times with exponential backoff (honouring Retry-After). Each attempt is signed again with a new nonce. POSTs without an externalId are never retried, because a retry could create a second payout.
Reference
| Export | What it does |
|---|---|
Valtu | The client: workspace(), balances(), networks(), wallets, transfers, deposits, assets, webhooks, and request() for any /v1 path. |
Valtu.fromKeyFile · fromEnv | Create a client from the dashboard key file or environment variables. |
depositAddress(wallet, chain) | A wallet's address on a network, or null. |
toBaseUnits · fromBaseUnits | Exact amount conversion. |
constructEvent | Verify and parse a webhook delivery (throws WebhookVerificationError). |
verifyWebhookEd25519 · verifyWebhook | Signature checks returning a boolean. |
EventDeduper | Skip redelivered events in a single process. |
generateApiKey() | A new P-256 key pair ({ privateKey, publicKey }, hex). |
canonicalRequest | The string that is signed, for debugging your own signer. |
| Types | Wallet, Transfer, Deposit, Asset, Workspace, TransferStatus, WebhookEvent (a union narrowed by type), and more. |
Changelog
0.5.0: renamed to @valtu/wallet-kit (Peerlyx is now Valtu). Valtu and ValtuError replace Peerlyx and PeerlyxError, which still work for now. Sends X-Valtu-* headers and reads VALTU_* environment variables (PEERLYX_* still read).
0.4.0: contract calls (contracts.list / get / call), call on transfers.
0.3.0: checkouts (payment links) and typed checkout.* events.
0.2.0: wallet balances and workspace totals, networks, transfer preview, batch payouts, deposit lookup by transaction hash, webhook endpoints and public key.
0.1.0: first release. Wallets, transfers, deposits, assets, webhook verification, amounts, retries, keygen command.
All changes to the API and SDK: changelog.