In this guide you'll build the deposit side of a Node.js app with Express and Postgres:
- an endpoint that gives each customer their own USDT and USDC deposit addresses;
- a webhook handler that credits each deposit exactly once, only when it's final;
- an hourly job that catches any deposit a webhook missed.
Every snippet below runs as written. We tested it against Postgres with signed webhooks, redeliveries and tampered requests.
Before you start
- A Valtu workspace. Every workspace starts on testnets with free test coins, so nothing here touches real money. Create one.
- An API key with the
operaterole: Developers → API keys → Create key. Keep the downloaded key file in your secret manager, never in your repository. - A webhook endpoint: Developers → Webhooks → Add endpoint, with your public https URL ending in
/valtu/webhooksand thedeposit.*events. Copy the workspace's Ed25519 public key from the same page. It isn't secret. - Node.js 20 or later and a Postgres database.
Your server reads three settings:
VALTU_PRIVATE_KEY=… # from the API key file (or VALTU_KEY_FILE=./valtu-server.json)
VALTU_WEBHOOK_PUBLIC_KEY=… # Developers → Webhooks
DATABASE_URL=postgres://…
1. Install
npm install @valtu/wallet-kit express pg
npm install -D tsx @types/express @types/pg
npm pkg set type=module
The code is TypeScript. Run it with npx tsx server.ts, or node server.ts on Node 24, which runs TypeScript directly.
2. Two tables
-- Each webhook event once: a redelivery finds its id here and stops.
CREATE TABLE valtu_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
-- One credit per deposit, ever. Amounts are whole numbers of the coin's smallest unit.
CREATE TABLE deposit_credits (
deposit_id text PRIMARY KEY,
customer_id text NOT NULL,
asset text NOT NULL, -- e.g. USDT-TRC20
amount numeric(78, 0) NOT NULL, -- base units: 25.5 USDT on Tron is 25500000
tx_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON deposit_credits (customer_id);
Amounts are stored as whole numbers of the coin's smallest unit, exactly as the API sends them, so nothing is lost to rounding. See token decimals. A customer's balance is then SELECT asset, sum(amount) FROM deposit_credits WHERE customer_id = $1 GROUP BY asset, plus whatever else your ledger records.
3. The client and one credit function
import { Valtu, type Deposit } from "@valtu/wallet-kit";
import pg from "pg";
// VALTU_PRIVATE_KEY (or VALTU_KEY_FILE) from the API key you created in the dashboard.
export const valtu = Valtu.fromEnv();
export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// Screening results that mean "don't credit yet" (when you've turned on deposit screening).
const NOT_YET = new Set(["pending", "held", "rejected"]);
/** Credit a confirmed deposit to the customer whose wallet received it, once. */
export async function credit(q: pg.ClientBase | pg.Pool, d: Deposit): Promise<void> {
// walletExternalId is the customer id you passed to wallets.forCustomer(). Deposits to your
// own treasury or payout wallets have none: there's nobody to credit.
if (d.status !== "CONFIRMED" || !d.walletExternalId || (d.screening && NOT_YET.has(d.screening))) return;
await q.query(
`INSERT INTO deposit_credits (deposit_id, customer_id, asset, amount, tx_hash)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (deposit_id) DO NOTHING`,
[d.id, d.walletExternalId, d.asset, d.amount, d.txHash],
);
}
deposits.get() returns the deposit with walletExternalId: the customer id you gave the wallet. That's how a deposit finds its customer, without a lookup table of your own.
If you've turned on deposit screening, deposits still pending, held or rejected wait for your team's decision. Once released, the reconcile job in step 5 credits them.
4. Addresses and the webhook
import express from "express";
import { constructEvent, depositAddress, WebhookVerificationError, type WebhookEvent } from "@valtu/wallet-kit";
import { credit, db, valtu } from "./valtu.ts";
const app = express();
// What you show customers: the network next to every address, and the coins you take on it.
const NETWORKS = [
{ chain: "tron", label: "USDT on Tron (TRC-20)" },
{ chain: "ethereum", label: "USDT or USDC on Ethereum (ERC-20)" },
{ chain: "base", label: "USDC on Base" },
];
app.get("/customers/:id/deposit-addresses", async (req, res) => {
// Check here that the signed-in user is this customer.
const wallet = await valtu.wallets.forCustomer(req.params.id); // created on first use
res.json(NETWORKS.map((n) => ({ ...n, address: depositAddress(wallet, n.chain) })));
});
// The signature covers the raw bytes, so this route reads the body raw, before any JSON parser.
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;
}
await handle(event); // an error here answers 500, and Valtu delivers the event again later
res.sendStatus(200);
});
async function handle(event: WebhookEvent) {
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]);
if (first.rowCount === 1 && event.type === "deposit.confirmed") await credit(client, await valtu.deposits.get(event.data.depositId));
// The event id and the credit commit together: if crediting fails, the event isn't marked as
// seen either, and the redelivery tries again.
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
app.use(express.json()); // other routes can parse JSON as usual
app.listen(Number(process.env.PORT ?? 3000));
What makes this safe:
- The raw body. The signature covers the exact bytes Valtu sent.
express.raw()on this one route keeps them. Ifexpress.json()ran first, the check would fail. - One transaction. The event id and the credit are written together. A redelivered event finds its id and stops. If crediting fails, neither is saved, the handler answers 500, and Valtu delivers the event again.
- The primary key on
deposit_id. Even a second, different event about the same deposit can't credit it twice. See idempotency. - Only
deposit.confirmedcredits.deposit.pendingmeans seen but not final. Show "on its way", nothing more.
The other deposit events, if you subscribe to them:
| Event | What to do |
|---|---|
deposit.pending | Show the deposit as on its way. Don't credit. |
deposit.orphaned | A reorganization removed it before it was final. It was never credited; tell the customer if you showed it as pending. |
deposit.needs_review | Rare: a confirmed deposit's block left the chain. Alert a person. |
Answer within 10 seconds. If your crediting does slow work, queue it and answer first.
5. Catch anything a webhook missed
import { credit, db, valtu } from "./valtu.ts";
// Run every hour (cron, a scheduled job): credits any confirmed deposit whose webhook you missed.
// The window overlaps earlier runs on purpose; deposits already credited are skipped.
const since = new Date(Date.now() - 48 * 3600_000).toISOString();
let checked = 0;
for await (const d of valtu.deposits.iterate({ status: "CONFIRMED", after: since })) {
await credit(db, d);
checked++;
}
console.log(`checked ${checked} confirmed deposits since ${since}`);
await db.end();
Webhooks are a notification, not the record. Endpoints go down and deploys restart. Run this every hour. Because crediting is keyed on the deposit id, running it as often as you like is harmless. More in reconciliation.
6. Try it
- Expose your local server with a tunnel and set the endpoint's URL to it.
- In Developers → Webhooks, use Send test event: you should see a
webhook.testdelivery answered with 200. - Call
GET /customers/cus_42/deposit-addresses, send test USDT to the Tron address, and watchdeposit.pendingthendeposit.confirmedarrive. One row appears indeposit_credits. - Resend the same delivery from the delivery log. The handler answers 200, and there's still one row.
Going live
- Open a live workspace, create a new API key and a new endpoint there, and use its public key. Test and live never share keys.
- Show the network name next to every address: "USDT on Tron (TRC-20)", not just "USDT". The networks page lists what live workspaces can accept.
- Set up sweeps and a gas wallet so deposits are collected into your treasury.
- Schedule the reconcile job, and alert on failures.
Next: send payouts from Node.js, or read the full integration guide.