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 URL | https://valtu.io/v1 |
| Format | JSON bodies and responses, UTF-8. Send content-type: application/json on POST. |
| Amounts | Integer strings in base units (wei, sun, lamports, satoshi, token units). Never decimals or numbers. |
| Ids | UUIDs. Your own ids go in externalId (up to 128 characters). |
| Times | ISO 8601 in UTC, e.g. 2026-10-08T09:30:00.000Z. |
| Environment | Set by the workspace (test or live), not by the URL. A key belongs to one workspace. |
| Rate limit | 50 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
- 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). - Sign the payload with ECDSA P-256 / SHA-256. Hex-encode the DER signature.
- Send three headers:
| Header | Value |
|---|---|
X-Valtu-Timestamp | Milliseconds since 1970. Must be within 60 seconds of Valtu's clock. |
X-Valtu-Nonce | 16–64 random URL-safe characters, new for every request (including retries). |
X-Stamp | base64url 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
| Role | Can |
|---|---|
read | Read the workspace, wallets, transfers, deposits and assets. |
operate | Also create wallets and transfers, and cancel its own transfers. |
admin | Also 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 …" }| HTTP | Code | Meaning and what to do |
|---|---|---|
| 400 | BAD_REQUEST | A field is missing or malformed. The message names it. |
| 400 | BAD_ADDRESS | Not a valid address on that network. |
| 400 | UNSUPPORTED | The coin or network isn't available to this workspace or wallet. |
| 400 | BELOW_MINIMUM | Below the coin's minimum withdrawal. The message gives the minimum. |
| 400 | INSUFFICIENT | The wallet doesn't have enough of the coin available. |
| 400 | NO_GAS | A 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. |
| 400 | INSUFFICIENT_FOR_FEE | Sending the network's own coin: amount plus fee is more than the wallet holds. The message says the most you can send. |
| 401 | UNAUTHENTICATED | Missing or wrong signature, or an unknown, revoked or expired key. |
| 401 | STALE_REQUEST | Timestamp more than 60 s off. Check the server clock (NTP). |
| 401 | REPLAYED | The nonce was used before. Use a new nonce for every attempt. |
| 403 | FORBIDDEN | The key's role can't do this. |
| 403 | IP_NOT_ALLOWED | Request from an address outside the key's allowlist. |
| 403 | WALLET_SCOPE | The key is limited to other wallets. |
| 403 | WORKSPACE_INACTIVE | The workspace isn't active (in review, suspended or rejected). |
| 403 | POLICY_DENIED | One of your policies blocks it. The message names the rule. |
| 404 | NOT_FOUND | No such object, or the key can't see it. |
| 409 | DUPLICATE | The externalId was already used with different details. |
| 409 | BAD_STATE, WALLET_FROZEN, NOT_ACTIVE | The object is in the wrong state (e.g. cancelling a signed transfer, sending from a frozen wallet). |
| 429 | RATE_LIMITED | Slow down and retry with backoff. |
| 5xx | Retry 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
{
"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
| Field | Type | Description |
|---|---|---|
namerequired | string, 1–80 | Shown in the dashboard. |
typerequired | user | operational | treasury | See wallet types. |
externalIdoptional | string, 1–128 | Your 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
Query: type, externalId, q (name, external id, wallet id or address), cursor, limit (≤ 200). Returns {"wallets": [...], "nextCursor": …}.
Get a wallet
Returns {"wallet": {...}}.
Wallet balances
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
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
| Status | Meaning |
|---|---|
PENDING_APPROVAL | Your policies require approvals; members approve in the dashboard with a passkey. |
QUEUED | Approved (or no approval needed), waiting to be signed. lastError says why if it waits, e.g. for gas. |
SIGNING | Being signed in the enclave. |
BROADCAST | Sent to the network, waiting for confirmations. Fees are bumped automatically if it's slow. |
CONFIRMED | Final. networkFee and feeUsd show what it cost. |
FAILED | Didn't go through; failure says why. Funds stay in the wallet. |
REJECTED | An approver rejected it. |
CANCELLED | Cancelled before signing, by you or automatically after waiting 30 minutes for funds or gas. |
NEEDS_REVIEW | Broadcast but not mined after rebroadcasts and fee bumps. Valtu support investigates; don't resend it. |
AWAITING_OFFICER | Only on older workspaces still on Valtu co-signing. |
Send a payout
| Field | Type | Description |
|---|---|---|
walletIdrequired | uuid | The wallet that pays. |
assetrequired | string | Asset id from /v1/assets, e.g. USDT-TRC20. |
amountrequired | integer string | Base units, greater than zero. |
to | string | Destination address. Give to or addressBookId, not both. |
addressBookId | uuid | A saved address (Security → Address book). |
externalIdrecommended | string, 1–128 | Your payout id. Makes retries safe. |
categoryoptional | string, ≤ 40 | A label for reports and filters, e.g. payroll. |
noteoptional | string, ≤ 500 | Shown 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
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
Includes approvals: who approved or rejected, when, and why.
Cancel a transfer
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
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
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
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
Find a deposit by transaction hash
For "I sent it, where is it?". Body: {"chain": "tron", "txHash": "…"}. Returns one of:
status | Meaning |
|---|---|
found | Recorded: deposits lists them (one transaction can pay several addresses). |
pending | The transaction is in a block Valtu hasn't reached yet; it is recorded on its own within minutes. |
rescanning | Its 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_found | The 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
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
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
| Field | Type | Description |
|---|---|---|
amountrequired | string | USD, at most 2 decimals, e.g. "49.00". |
assets | string[] | Coins the payer may use. Default: every USD stablecoin on your networks. With one coin the checkout is quoted straight away. |
externalIdrecommended | string, ≤ 128 | Your order id. The same id returns the same checkout. |
description | string, ≤ 500 | Shown to the payer. |
successUrl, cancelUrl | https URL | Buttons on the page after payment, or to leave it. |
expiresInSeconds | 300–604800 | Default 3600 (1 hour). |
metadata | object, ≤ 2 KB | Returned 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": "…"
}| Status | Meaning | Event |
|---|---|---|
OPEN | Waiting for the payer. | |
PROCESSING | A payment was seen and is waiting for confirmations. | checkout.payment_detected |
COMPLETED | Confirmed payments add up to the quote (within 0.5%). Fulfil the order. | checkout.completed |
UNDERPAID | The deadline passed with less than the quote received. Ask for the rest or refund. | checkout.underpaid |
EXPIRED | The deadline passed with nothing received. | checkout.expired |
CANCELLED | Cancelled 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 bycheckoutIdor yourexternalId.
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
CALLand 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.*), sowithdrawal.value_usd <= 1000never waves a zero-valueapprove()through.
| Field | Type | Description |
|---|---|---|
walletIdrequired | uuid | An operational or treasury wallet. |
contractIdrequired | uuid | From GET /v1/contracts. |
function + args | string, array | Function name (or full signature if overloaded) and its arguments: integers as decimal strings, addresses and bytes as 0x-hex, tuples as arrays. |
data | 0x-hex | Or raw calldata. It must decode to an allowed function. |
value | integer string | Native coin sent with the call, base units. Default "0". |
externalId, note, category | As 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
| Field | Example |
|---|---|
withdrawal.is_contract_call | withdrawal.is_contract_call && wallet.type == 'treasury' → Require approval (Finance, 2) |
call.function, call.signature | call.function == 'deposit' && call.contract_label == 'Aave pool' → Skip approval |
call.contract, call.contract_label | call.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
| Event | When | data |
|---|---|---|
deposit.pending · .confirmed · .orphaned | A 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 · .cancelled | A 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.low | The gas wallet fell below your warning level. | chain, asset, walletId, address, balance, threshold |
gas.blocked | A top-up was held back (daily cap or empty gas wallet). | chain, reason |
network.closing · .paused · .resumed | Valtu is retiring, has paused, or resumed a network or coin. | chain, asset?, name, closesAt, notice |
workspace.suspended · .reactivated | Valtu paused or resumed your workspace. | reason |
webhook.test | You 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.
| Header | Check 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'sstatusrather 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
idmatters. - 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.