API guide · v1

Valtu Wallet API

A small server-to-server API: wallets, deposits, payouts and coins, plus webhooks for everything that happens. Everything here can also be done in the dashboard.

Machine-readable. The full API is described in OpenAPI 3.1 (import it into Postman, Insomnia or a code generator), and the docs are available as text for AI coding tools at llms.txt.

Basics

Base URLhttps://valtu.io/v1
FormatJSON bodies and responses, UTF-8. Send content-type: application/json on POST.
AmountsInteger strings in base units (wei, sun, lamports, satoshi, token units). Never decimals or numbers.
IdsUUIDs. Your own ids go in externalId (up to 128 characters).
TimesISO 8601 in UTC, e.g. 2026-10-08T09:30:00.000Z.
EnvironmentSet by the workspace (test or live), not by the URL. A key belongs to one workspace.
Rate limit50 requests per second per key, bursts up to 100. Over it: 429 RATE_LIMITED.

Authentication

Create a key in the dashboard (Developers → API keys). The key pair is P-256 (ECDSA); Valtu stores only the public key. Every request is signed, so it can't be forged, altered or replayed by anyone in between, including Valtu.

Signing a request

  1. Build the payload: five lines joined by \n: the method, the path with its query string exactly as sent, a timestamp in milliseconds, a nonce, and the hex SHA-256 of the body (of the empty string for GET).
  2. Sign the payload with ECDSA P-256 / SHA-256. Hex-encode the DER signature.
  3. Send three headers:
HeaderValue
X-Valtu-TimestampMilliseconds since 1970. Must be within 60 seconds of Valtu's clock.
X-Valtu-Nonce16–64 random URL-safe characters, new for every request (including retries).
X-Stampbase64url of {"publicKey": "<compressed hex>", "scheme": "SIGNATURE_SCHEME_P256", "signature": "<DER hex>"}
import { Valtu } from "@valtu/wallet-kit";

const valtu = Valtu.fromKeyFile("./valtu-server.json"); // signs every call
const { items } = await valtu.wallets.list({ type: "user", limit: 20 });
import { createHash, createPrivateKey, createPublicKey, randomBytes, sign } from "node:crypto";

const d = Buffer.from(process.env.VALTU_PRIVATE_KEY!, "hex"); // 32 bytes
const key = createPrivateKey({ key: Buffer.concat([Buffer.from("30310201010420", "hex"), d, Buffer.from("a00a06082a8648ce3d030107", "hex")]), format: "der", type: "sec1" });
const jwk = createPublicKey(key).export({ format: "jwk" });
const y = Buffer.from(jwk.y!, "base64url");
const publicKey = Buffer.concat([Buffer.from([y[31]! & 1 ? 3 : 2]), Buffer.from(jwk.x!, "base64url")]).toString("hex");

async function call(method: string, path: string, body?: object) {
  const raw = body ? JSON.stringify(body) : "";
  const ts = String(Date.now());
  const nonce = randomBytes(18).toString("base64url");
  const payload = [method, path, ts, nonce, createHash("sha256").update(raw).digest("hex")].join("\n");
  const signature = sign("sha256", Buffer.from(payload), key).toString("hex");
  const stamp = Buffer.from(JSON.stringify({ publicKey, scheme: "SIGNATURE_SCHEME_P256", signature })).toString("base64url");
  const res = await fetch("https://valtu.io" + path, {
    method,
    headers: { "x-valtu-timestamp": ts, "x-valtu-nonce": nonce, "x-stamp": stamp, ...(body ? { "content-type": "application/json" } : {}) },
    body: body ? raw : undefined,
  });
  return res.json();
}

await call("GET", "/v1/wallets?type=user&limit=20");
# pip install cryptography requests
import base64, hashlib, json, os, secrets, time, requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec

key = ec.derive_private_key(int(os.environ["VALTU_PRIVATE_KEY"], 16), ec.SECP256R1())
public_key = key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

def call(method, path, body=None):
    raw = json.dumps(body, separators=(",", ":")) if body is not None else ""
    ts = str(int(time.time() * 1000))
    nonce = secrets.token_urlsafe(18)
    payload = "\n".join([method, path, ts, nonce, hashlib.sha256(raw.encode()).hexdigest()])
    signature = key.sign(payload.encode(), ec.ECDSA(hashes.SHA256())).hex()  # DER
    stamp = base64.urlsafe_b64encode(json.dumps({"publicKey": public_key, "scheme": "SIGNATURE_SCHEME_P256", "signature": signature}).encode()).rstrip(b"=").decode()
    headers = {"x-valtu-timestamp": ts, "x-valtu-nonce": nonce, "x-stamp": stamp}
    if body is not None:
        headers["content-type"] = "application/json"
    r = requests.request(method, "https://valtu.io" + path, headers=headers, data=raw or None, timeout=30)
    return r.json()

call("GET", "/v1/wallets?type=user&limit=20")

Sign what you send. Sign the exact body bytes and the exact path and query you put on the wire. Re-serializing JSON after signing, or letting a library re-encode the query, breaks the signature (401 UNAUTHENTICATED).

Key roles and scopes

RoleCan
readRead the workspace, wallets, transfers, deposits and assets.
operateAlso create wallets and transfers, and cancel its own transfers.
adminAlso cancel any transfer.

A key can be limited to IP ranges, given an expiry, and limited to some wallets (optionally plus every user wallet). A wallet-limited key sees only its wallets and their transfers and deposits: anything else answers 404, as if it didn't exist. It can create wallets only if it may use user wallets, and then only user wallets (403 WALLET_SCOPE). API payouts never count as an approval from the member who created the key.

Errors

Every non-2xx response has the same shape. Branch on code; show message to people. Each response carries an x-request-id header to quote to support.

{ "code": "NO_GAS", "message": "the wallet needs 3.2 TRX for the network fee …" }
HTTPCodeMeaning and what to do
400BAD_REQUESTA field is missing or malformed. The message names it.
400BAD_ADDRESSNot a valid address on that network.
400UNSUPPORTEDThe coin or network isn't available to this workspace or wallet.
400BELOW_MINIMUMBelow the coin's minimum withdrawal. The message gives the minimum.
400INSUFFICIENTThe wallet doesn't have enough of the coin available.
400NO_GASA token transfer whose wallet can't pay the network fee in the network's coin, and the gas station isn't sponsoring it. Fund the wallet or turn on sponsorship.
400INSUFFICIENT_FOR_FEESending the network's own coin: amount plus fee is more than the wallet holds. The message says the most you can send.
401UNAUTHENTICATEDMissing or wrong signature, or an unknown, revoked or expired key.
401STALE_REQUESTTimestamp more than 60 s off. Check the server clock (NTP).
401REPLAYEDThe nonce was used before. Use a new nonce for every attempt.
403FORBIDDENThe key's role can't do this.
403IP_NOT_ALLOWEDRequest from an address outside the key's allowlist.
403WALLET_SCOPEThe key is limited to other wallets.
403WORKSPACE_INACTIVEThe workspace isn't active (in review, suspended or rejected).
403POLICY_DENIEDOne of your policies blocks it. The message names the rule.
404NOT_FOUNDNo such object, or the key can't see it.
409DUPLICATEThe externalId was already used with different details.
409BAD_STATE, WALLET_FROZEN, NOT_ACTIVEThe object is in the wrong state (e.g. cancelling a signed transfer, sending from a frozen wallet).
429RATE_LIMITEDSlow down and retry with backoff.
5xxRetry with backoff. Retry POSTs only if they carry an externalId.

Idempotency and retries

POST /v1/wallets and POST /v1/transfers are idempotent by externalId: repeating the request returns the original object, and never creates a second wallet or sends twice. The same externalId with different details returns 409 DUPLICATE. Always send an externalId with transfers, and store it with your payout before calling.

GET requests are always safe to retry. Each attempt needs a fresh timestamp and nonce.

Pagination

List endpoints return newest first, with nextCursor. Pass it as cursor to get the next page; it's null on the last page. limit defaults to 50 (wallets up to 200, transfers and deposits up to 1000).

GET /v1/transfers?status=QUEUED,SIGNING,BROADCAST&limit=100
GET /v1/transfers?status=QUEUED,SIGNING,BROADCAST&limit=100&cursor=eyJ0Ijoi…

Filters after and before take ISO timestamps; status and kind take one value or several separated by commas.

Workspace

GET/v1/workspaceread
{
  "workspace": {
    "id": "6f1c0e5e-1d7b-4c55-9a3e-2b8f0c1a7d42",
    "name": "Acme Pay",
    "slug": "acme-pay",
    "environment": "live",
    "status": "ACTIVE",
    "createdAt": "2026-10-01T10:00:00.000Z",
    "activatedAt": "2026-10-01T14:12:09.000Z"
  }
}

Wallets

Keys live in Valtu's hardware enclaves and never leave them. A wallet has one address per network family; networks lists where each address is used.

The wallet object

{
  "id": "b2d7c3a0-8e51-4d0f-9f61-0c3a9a8f2e11",
  "name": "Customer cus_42",
  "type": "user",
  "externalId": "cus_42",
  "approvalGroupId": null,
  "disabledAt": null,
  "createdAt": "2026-10-08T09:30:00.000Z",
  "addresses": [
    { "addressFormat": "ADDRESS_FORMAT_ETHEREUM", "address": "0x5aC3…9e1F", "networks": ["ethereum", "bsc", "polygon", "base", "lisk"] },
    { "addressFormat": "ADDRESS_FORMAT_TRON", "address": "TQm8…3xVb", "networks": ["tron"] },
    { "addressFormat": "ADDRESS_FORMAT_SOLANA", "address": "7Hk2…pQ9s", "networks": ["solana"] },
    { "addressFormat": "ADDRESS_FORMAT_BITCOIN_MAINNET_P2WPKH", "address": "bc1q…7d4k", "networks": ["bitcoin"] }
  ]
}

disabledAt is set when the wallet is frozen in the dashboard: deposits still arrive, payouts are refused.

Create a wallet

POST/v1/walletsoperate
FieldTypeDescription
namerequiredstring, 1–80Shown in the dashboard.
typerequireduser | operational | treasurySee wallet types.
externalIdoptionalstring, 1–128Your id. Makes the call idempotent: the same id returns the existing wallet.
const wallet = await valtu.wallets.forCustomer("cus_42"); // type "user", externalId "cus_42"
POST /v1/wallets
{"name": "Customer cus_42", "type": "user", "externalId": "cus_42"}

200 OK
{"wallet": { …the wallet object… }}

List wallets

GET/v1/walletsread

Query: type, externalId, q (name, external id, wallet id or address), cursor, limit (≤ 200). Returns {"wallets": [...], "nextCursor": …}.

Get a wallet

GET/v1/wallets/:idread

Returns {"wallet": {...}}.

Wallet balances

GET/v1/wallets/:id/balancesread

Every coin on every network the wallet has an address for, read live from the network (cached for about 30 seconds). available is what a new payout can use: the balance less onHold, the amount reserved by payouts not yet sent.

{
  "walletId": "c1a9…",
  "balances": [
    { "asset": "TRX", "chain": "tron", "address": "TJx9…kQ2r", "decimals": 6, "balance": "82500000", "onHold": "0", "available": "82500000", "usd": 27.72, "closesAt": null },
    { "asset": "USDT-TRC20", "chain": "tron", "address": "TJx9…kQ2r", "decimals": 6, "balance": "1250000000", "onHold": "25500000", "available": "1224500000", "usd": 1250, "closesAt": null }
  ],
  "totalUsd": 1277.72
}

If a network can't be read at that moment, its balance is null with an error; the rest still come back.

Workspace totals

GET/v1/balancesread

Per coin: what treasury and operational wallets hold (held), and confirmed deposits in user wallets that haven't been swept yet (awaitingSweep). Keys limited to some wallets get 403 WALLET_SCOPE.

Transfers

A transfer moves coins out of one of your wallets. kind is TRANSFER for payouts you request, SWEEP for your automations, and GAS for gas station top-ups.

The transfer object

{
  "id": "0e9b5d8c-2f44-4a7e-8d1b-4f1d2c3b4a5e",
  "kind": "TRANSFER",
  "walletId": "c1a9…", "walletName": "Payouts",
  "chain": "tron", "asset": "USDT-TRC20", "decimals": 6,
  "amount": "25500000",
  "to": "TJx9…kQ2r", "addressBookId": null, "addressLabel": null,
  "note": null, "category": "withdrawal", "externalId": "payout_981",
  "usdValue": 25.5,
  "status": "CONFIRMED",
  "requiredApprovals": 0, "approvalCount": 0,
  "approvalGroupId": null, "approvalGroupName": null,
  "policyReason": "Small API payouts (principal.kind == 'api_key' && withdrawal.value_usd <= 1000)",
  "proposedBy": "api_key:4b0e…", "proposerName": "payout-server", "proposerAccountId": "a7f2…",
  "txHash": "9f2c…e81a",
  "networkFee": "1305000", "feeAsset": "TRX", "feeUsd": 0.44,
  "confirmations": 20,
  "failure": null, "lastError": null, "waitingSince": null,
  "createdAt": "2026-10-08T09:31:02.000Z", "updatedAt": "2026-10-08T09:32:15.000Z", "confirmedAt": "2026-10-08T09:32:15.000Z"
}

Lifecycle

StatusMeaning
PENDING_APPROVALYour policies require approvals; members approve in the dashboard with a passkey.
QUEUEDApproved (or no approval needed), waiting to be signed. lastError says why if it waits, e.g. for gas.
SIGNINGBeing signed in the enclave.
BROADCASTSent to the network, waiting for confirmations. Fees are bumped automatically if it's slow.
CONFIRMEDFinal. networkFee and feeUsd show what it cost.
FAILEDDidn't go through; failure says why. Funds stay in the wallet.
REJECTEDAn approver rejected it.
CANCELLEDCancelled before signing, by you or automatically after waiting 30 minutes for funds or gas.
NEEDS_REVIEWBroadcast but not mined after rebroadcasts and fee bumps. Valtu support investigates; don't resend it.
AWAITING_OFFICEROnly on older workspaces still on Valtu co-signing.

Send a payout

POST/v1/transfersoperate
FieldTypeDescription
walletIdrequireduuidThe wallet that pays.
assetrequiredstringAsset id from /v1/assets, e.g. USDT-TRC20.
amountrequiredinteger stringBase units, greater than zero.
tostringDestination address. Give to or addressBookId, not both.
addressBookIduuidA saved address (Security → Address book).
externalIdrecommendedstring, 1–128Your payout id. Makes retries safe.
categoryoptionalstring, ≤ 40A label for reports and filters, e.g. payroll.
noteoptionalstring, ≤ 500Shown to approvers.
const transfer = await valtu.transfers.create({
  walletId: payoutWallet.id,
  asset: "USDT-TRC20",
  amount: await valtu.assets.toBaseUnits("USDT-TRC20", "25.50"),
  to: "TJx9…kQ2r",
  externalId: "payout_981",
});
POST /v1/transfers
{"walletId": "c1a9…", "asset": "USDT-TRC20", "amount": "25500000", "to": "TJx9…kQ2r", "externalId": "payout_981"}

200 OK
{"transfer": { …status "QUEUED" or "PENDING_APPROVAL"… }}

Before accepting, Valtu checks the address, the coin's minimum, the wallet's available balance (balance minus payouts in flight), the network fee (NO_GAS, INSUFFICIENT_FOR_FEE) and your policies.

List transfers

GET/v1/transfersread

Query: status, kind, walletId, externalId, category, asset, after, before, q (id, external id, address, tx hash, note), cursor, limit (≤ 1000). Returns {"transfers": [...], "nextCursor": …}.

Get a transfer

GET/v1/transfers/:idread

Includes approvals: who approved or rejected, when, and why.

Cancel a transfer

POST/v1/transfers/:id/canceloperate

Only while PENDING_APPROVAL or QUEUED; afterwards 409 BAD_STATE. operate keys cancel transfers they created; admin keys cancel any. Send an empty JSON body {}.

Preview a payout

POST/v1/transfers/previewread

Same body as Send a payout; nothing is created. Shows the network fee and whether the wallet can pay it, the USD value, and what your policies would do. Use it to show the fee before a customer confirms a withdrawal.

{
  "preview": {
    "outcome": "ALLOW", "reason": "Payout rules: \"Small API payouts\"", "approvals": 0, "groupName": null, "usdValue": 25.5,
    "fee": { "asset": "TRX", "symbol": "TRX", "decimals": 6, "fee": "6420000", "usd": 2.16, "payer": "wallet", "ok": true, "code": null, "message": null }
  }
}

outcome: ALLOW (no approval), REQUIRE_APPROVAL or DEFAULT (approvals from the named group), DENY (blocked; reason names the rule). When fee.ok is false, fee.code is NO_GAS or INSUFFICIENT_FOR_FEE, and for a network's own coin fee.maxSendable is the most you can send.

Batch payouts

POST/v1/transfers/batchoperate

Up to 100 payouts in one request: {"transfers": [ … ]}, each with the same fields as a single payout and a required externalId. The batch is not atomic: each payout is created or refused on its own, and the response says which. Sending the same batch again returns the payouts already created, so retrying after a timeout is safe.

{
  "results": [
    { "index": 0, "externalId": "pay_1", "ok": true, "transfer": { "id": "…", "status": "QUEUED", "…": "…" } },
    { "index": 1, "externalId": "pay_2", "ok": false, "error": { "status": 400, "code": "BAD_ADDRESS", "message": "not a valid tron address" } }
  ],
  "created": 1,
  "failed": 1
}

Deposits

Incoming transfers to your wallets, found by Valtu's chain indexers. PENDING when first seen, CONFIRMED after the network's confirmation depth, ORPHANED if a chain reorganization removed it (don't credit it). Sweeps and gas top-ups between your own wallets are transfers, not deposits.

{
  "id": "5d3e…", "walletId": "b2d7…", "walletName": "Customer cus_42", "walletExternalId": "cus_42",
  "chain": "tron", "asset": "USDT-TRC20", "decimals": 6, "amount": "100000000", "usdValue": 100,
  "address": "TQm8…3xVb", "from": "TPa1…", "txHash": "c41d…", "outputIndex": 0,
  "blockNumber": "66120345", "confirmations": 19,
  "status": "CONFIRMED", "createdAt": "2026-10-08T09:20:11.000Z", "confirmedAt": "2026-10-08T09:21:08.000Z"
}

List deposits

GET/v1/depositsread

Query: status, walletId, asset, txHash (exact), after, before, q (tx hash, address, sender, wallet name or customer id), cursor, limit (≤ 1000). Returns {"deposits": [...], "nextCursor": …}.

Get a deposit

GET/v1/deposits/:idread

Find a deposit by transaction hash

POST/v1/deposits/lookupread

For "I sent it, where is it?". Body: {"chain": "tron", "txHash": "…"}. Returns one of:

statusMeaning
foundRecorded: deposits lists them (one transaction can pay several addresses).
pendingThe transaction is in a block Valtu hasn't reached yet; it is recorded on its own within minutes.
rescanningIts block was already read but nothing was recorded, so the block is being read again. If it paid one of your addresses, the deposit and its webhooks follow within minutes.
not_foundThe network doesn't know the transaction: check the hash and the network. A transaction still waiting to be mined shows up once it is.

Re-reading a block is limited to 20 lookups per workspace per hour (429 RATE_LIMITED beyond that).

Assets

GET/v1/assetsread

The coins your workspace can use on its environment's networks. Use decimals to convert amounts.

{
  "assets": [
    { "asset": "TRX", "chain": "tron", "network": "mainnet", "addressFormat": "ADDRESS_FORMAT_TRON", "decimals": 6, "contract": null, "closesAt": null },
    { "asset": "USDT-TRC20", "chain": "tron", "network": "mainnet", "addressFormat": "ADDRESS_FORMAT_TRON", "decimals": 6, "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "closesAt": null }
  ]
}

closesAt is set when Valtu is retiring the coin or its network. After that date it is paused: deposits are no longer credited and payouts are refused.

Networks

GET/v1/networksread

Each network your workspace uses, as Valtu sees it right now: status (operational, degraded, down), whether deposits are watched and payouts can be sent, the typical confirmation time and fee over the last week, and a closing date if the network is being retired. Show a notice in your app when a network isn't operational.

{ "networks": [ { "chain": "tron", "name": "Tron", "nativeAsset": "TRX", "status": "operational", "deposits": true, "sends": true, "closing": null, "typicalConfirmSeconds": 62, "typicalFeeUsd": 2.1 } ] }

Checkouts (payment links)

Ask for a payment in USD without building a payment screen. Valtu hosts the page: your customer picks a coin and network, sees the exact amount, address and QR code, and the page follows the payment until it's confirmed. Each checkout gets its own user wallet, so a payment can only belong to it, and your sweep tasks collect it like any other deposit.

Create a checkout

FieldTypeDescription
amountrequiredstringUSD, at most 2 decimals, e.g. "49.00".
assetsstring[]Coins the payer may use. Default: every USD stablecoin on your networks. With one coin the checkout is quoted straight away.
externalIdrecommendedstring, ≤ 128Your order id. The same id returns the same checkout.
descriptionstring, ≤ 500Shown to the payer.
successUrl, cancelUrlhttps URLButtons on the page after payment, or to leave it.
expiresInSeconds300–604800Default 3600 (1 hour).
metadataobject, ≤ 2 KBReturned in every checkout.* event.
const checkout = await valtu.checkouts.create({
  amount: "49.00",
  externalId: order.id,
  description: "Pro plan, 1 month",
  successUrl: `https://shop.example/orders/${order.id}`,
});
res.redirect(checkout.url);      // the hosted payment page
POST /v1/checkouts
{"amount": "49.00", "externalId": "order_1001", "description": "Pro plan, 1 month", "successUrl": "https://shop.example/orders/1001"}

200 OK
{"checkout": {"id": "dcba…", "url": "https://valtu.io/app/pay/dcba…", "status": "OPEN", "…": "…"}}

The checkout object and its lifecycle

{
  "id": "dcba9462-…", "url": "https://valtu.io/app/pay/dcba9462-…",
  "status": "COMPLETED", "amount": "49.00", "currency": "USD", "assets": ["USDT-TRC20", "USDC-BASE", "…"],
  "description": "Pro plan, 1 month", "externalId": "order_1001", "metadata": null,
  "quote": { "asset": "USDT-TRC20", "chain": "tron", "amount": "49000000", "decimals": 6, "address": "TNGW…BBXK", "expiresAt": "…" },
  "received": "49000000", "pending": "0", "depositIds": ["5d3e…"], "paidLate": false,
  "walletId": "…", "expiresAt": "…", "completedAt": "…", "createdAt": "…"
}
StatusMeaningEvent
OPENWaiting for the payer.
PROCESSINGA payment was seen and is waiting for confirmations.checkout.payment_detected
COMPLETEDConfirmed payments add up to the quote (within 0.5%). Fulfil the order.checkout.completed
UNDERPAIDThe deadline passed with less than the quote received. Ask for the rest or refund.checkout.underpaid
EXPIREDThe deadline passed with nothing received.checkout.expired
CANCELLEDCancelled before any payment.checkout.cancelled
  • Pricing: USD stablecoins are 1:1 with USD. Other coins are priced when the payer picks them, and that price holds for 15 minutes; the payer can ask for a new price after that.
  • Late payments: a payment that arrives within 7 days after the deadline still updates the checkout (paidLate: true), so you can decide whether to fulfil or refund.
  • A payment seen at the deadline gets an hour to confirm before the checkout settles as underpaid.
  • Fulfil on checkout.completed, keyed by checkoutId or your externalId.

Contract calls

Call functions of smart contracts (EVM networks) from your operational or treasury wallets: approve a token for a protocol, deposit into a vault, settle on a payment contract. Two safeguards come first:

  • An allowlist. Only contracts added in the dashboard (Security → Contracts) can be called, and only the functions chosen there. Adding or removing a contract is a governed change your default approval group approves.
  • Your policies. A call is a transfer of kind CALL and goes through your rules and approval groups like a payout. A "skip approval" rule only applies to calls if it looks at the call (call.*), so withdrawal.value_usd <= 1000 never waves a zero-value approve() through.
FieldTypeDescription
walletIdrequireduuidAn operational or treasury wallet.
contractIdrequireduuidFrom GET /v1/contracts.
function + argsstring, arrayFunction name (or full signature if overloaded) and its arguments: integers as decimal strings, addresses and bytes as 0x-hex, tuples as arrays.
data0x-hexOr raw calldata. It must decode to an allowed function.
valueinteger stringNative coin sent with the call, base units. Default "0".
externalId, note, categoryAs for payouts.
const [usdc] = await valtu.contracts.list();          // e.g. "USDC" on base, approve() allowed
const call = await valtu.contracts.call({
  walletId: treasury.id,
  contractId: usdc.id,
  function: "approve",
  args: ["0xA238Dd80C259a72e81d7e4664a9801593F98d1c5", "250000000"],   // spender, 250 USDC
  externalId: "vault-approve-1",
});
// call.kind === "CALL"; call.status: PENDING_APPROVAL or QUEUED → … → CONFIRMED
POST /v1/contract-calls
{"walletId": "…", "contractId": "…", "function": "approve", "args": ["0xA238…d1c5", "250000000"], "externalId": "vault-approve-1"}

200 OK
{"transfer": {"kind": "CALL", "status": "PENDING_APPROVAL", "to": "0x8335…2913", "amount": "0",
  "call": {"function": "approve", "signature": "approve(address,uint256)", "selector": "095ea7b3",
           "args": [{"name": "spender", "type": "address", "value": "0xa238…d1c5"}, {"name": "amount", "type": "uint256", "value": "250000000"}], "data": "0x095e…"}, "…": "…"}}

Errors: 404 NOT_FOUND (not on the allowlist), 403 FUNCTION_NOT_ALLOWED, 400 BAD_ARGS (arguments don't fit the function), plus everything a payout can return (NO_GAS, POLICY_DENIED, …). Calls appear in transfer.* webhooks with kind: "CALL".

Policies for calls

FieldExample
withdrawal.is_contract_callwithdrawal.is_contract_call && wallet.type == 'treasury' → Require approval (Finance, 2)
call.function, call.signaturecall.function == 'deposit' && call.contract_label == 'Aave pool' → Skip approval
call.contract, call.contract_labelcall.function == 'approve' → Require approval

Webhooks

Add endpoints in Developers → Webhooks: a public https URL and the events you want (wildcards like deposit.* work). Valtu POSTs JSON to it.

{
  "id": "2c5f8a1e-…",
  "type": "deposit.confirmed",
  "workspaceId": "6f1c…",
  "createdAt": "2026-10-08T09:21:08.000Z",
  "data": {
    "depositId": "5d3e…", "walletId": "b2d7…", "asset": "USDT-TRC20", "chain": "tron",
    "amount": "100000000", "address": "TQm8…3xVb", "fromAddress": "TPa1…",
    "txHash": "c41d…", "outputIndex": 0, "confirmations": 19, "status": "CONFIRMED"
  }
}

Webhooks in the API

Endpoints are added and removed in the dashboard (each change is confirmed with a passkey). From the API you can read them and get the signing key:

Your server can fetch the public key at startup instead of keeping a copy in its configuration.

Events

EventWhendata
deposit.pending · .confirmed · .orphanedA deposit is seen, final, or removed by a reorganization.depositId, walletId, asset, chain, amount, address, fromAddress, txHash, outputIndex, confirmations, status
transfer.<status>A payout changes status: pending_approval, queued, signing, broadcast, confirmed, failed, rejected, cancelled, needs_review.transferId, walletId, externalId, asset, chain, amount, toAddress, status, txHash, failure
checkout.payment_detected · .completed · .underpaid · .expired · .cancelledA checkout changes status.checkoutId, externalId, status, amountUsd, asset, quotedAmount, received, depositIds, walletId, paidLate, metadata
sweep.<status>Same, for sweeps.as transfers, plus kind
gas.<status>Same, for gas top-ups.as transfers, plus kind
gas.lowThe gas wallet fell below your warning level.chain, asset, walletId, address, balance, threshold
gas.blockedA top-up was held back (daily cap or empty gas wallet).chain, reason
network.closing · .paused · .resumedValtu is retiring, has paused, or resumed a network or coin.chain, asset?, name, closesAt, notice
workspace.suspended · .reactivatedValtu paused or resumed your workspace.reason
webhook.testYou pressed Send test event.message

Verifying signatures

Every delivery is signed twice over "<t>.<raw body>". Check either one, and reject timestamps more than 5 minutes old.

HeaderCheck with
X-Valtu-Signature-Ed25519: t=<unix>,v1=<base64url>Your workspace's Ed25519 public key (Webhooks page). Nothing secret to store. Recommended.
X-Valtu-Signature: t=<unix>,v1=<hex>HMAC-SHA256 with the endpoint's secret, shown once when you add the endpoint.
import { constructEvent } from "@valtu/wallet-kit";

// rawBody: the request body exactly as received (Buffer or string)
const event = constructEvent(rawBody, req.headers, { publicKey: process.env.VALTU_WEBHOOK_PUBLIC_KEY });
import base64, time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

def b64url(s):
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def verify(raw_body: bytes, header: str, public_key: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    try:
        Ed25519PublicKey.from_public_bytes(b64url(public_key)).verify(b64url(parts["v1"]), parts["t"].encode() + b"." + raw_body)
        return True
    except Exception:
        return False

Delivery

  • Answer with any 2xx within 10 seconds. Anything else is retried with exponential backoff for about a day, then marked given up.
  • Delivery is at least once and not strictly ordered. De-duplicate on the event id, and use the object's status rather than the order events arrive in.
  • A valid signature proves a delivery came from Valtu in the last 5 minutes. Replays inside that window are possible, which is why de-duplicating by id matters.
  • The dashboard keeps every attempt for 30 days (status, latency, your response) and can resend one delivery, resend everything given up, or pause an endpoint.