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.

Version0.5.0 · MIT licence · changelog
RuntimeNode.js 18.17 or newer (also Bun). ES modules, with types included.
Downloadvaltu-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 time

Transfers

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 decimals

Webhooks

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

ExportWhat it does
ValtuThe client: workspace(), balances(), networks(), wallets, transfers, deposits, assets, webhooks, and request() for any /v1 path.
Valtu.fromKeyFile · fromEnvCreate a client from the dashboard key file or environment variables.
depositAddress(wallet, chain)A wallet's address on a network, or null.
toBaseUnits · fromBaseUnitsExact amount conversion.
constructEventVerify and parse a webhook delivery (throws WebhookVerificationError).
verifyWebhookEd25519 · verifyWebhookSignature checks returning a boolean.
EventDeduperSkip redelivered events in a single process.
generateApiKey()A new P-256 key pair ({ privateKey, publicKey }, hex).
canonicalRequestThe string that is signed, for debugging your own signer.
TypesWallet, 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.