Quickstart
Your first calls to the V3 Custody API: find your Vault, check a balance, and send a transfer through the Policy Engine.
In a few minutes you'll make your first calls to the V3 Custody REST API: find your Vault, check a balance, create an address, and send a transfer. All operations go through a single HTTP endpoint and follow the same rules as the web console.
The examples use the base URL [BASE_URL], which is the address of your self‑hosted instance.
Prerequisites
- The address of your V3 Custody instance, shown as
[BASE_URL]in the examples. - An access token for the
Authorizationheader. See Authentication for how requests are authenticated. - Any HTTP client:
curl, JavaScript, or Python.
Set up your environment
Store the base URL and token in environment variables so you don't have to repeat them in every request.
export V3_BASE_URL="[BASE_URL]"
export V3_TOKEN="YOUR_API_TOKEN"
const BASE_URL = process.env.V3_BASE_URL; // [BASE_URL]
const TOKEN = process.env.V3_TOKEN;
const headers = {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
};
import os, requests
BASE_URL = os.environ["V3_BASE_URL"] # [BASE_URL]
TOKEN = os.environ["V3_TOKEN"]
headers = {
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
}
Never commit your token to a repository or hardcode it. Keep it in environment variables or a secrets manager.
Step 1. Find your Vault
A Vault is your organization's address tree. Almost every route starts with its code (code, a slug). Get the list of Vaults available to you:
curl "$V3_BASE_URL/api/v1/vaults" \
-H "Authorization: Bearer $V3_TOKEN"
const res = await fetch(`${BASE_URL}/api/v1/vaults`, { headers });
const vaults = await res.json();
console.log(vaults);
res = requests.get(f"{BASE_URL}/api/v1/vaults", headers=headers)
vaults = res.json()
print(vaults)
[
{
"id": "cmr3f41z20001psp7phmyapw3",
"code": "demo_vault",
"name": "Demo Vault",
"lockedAt": null
}
]
Note the code: from here on, it goes into every request as {code}.
Step 2. Check your balance
In V3 Custody, a balance isn't stored as a separate number. It's computed from ledger entries and split into available (can be spent) and reserved (locked by pending transfers, but still yours).
curl "$V3_BASE_URL/api/v1/vaults/demo_vault/balances?summary=true" \
-H "Authorization: Bearer $V3_TOKEN"
const res = await fetch(
`${BASE_URL}/api/v1/vaults/demo_vault/balances?summary=true`,
{ headers },
);
console.log(await res.json());
res = requests.get(
f"{BASE_URL}/api/v1/vaults/demo_vault/balances",
params={"summary": "true"},
headers=headers,
)
print(res.json())
{
"vaultId": "cmr3f41z20001psp7phmyapw3",
"totals": [
{ "token": "usdt", "available": "186000.00", "reserved": "24000.00" }
]
}
true returns only per-token totals, without the list of accounts.
truefalseBalance of a specific address.
Limit the result to a single token (for example, usdt).
Need a balance as of a past date? The separate endpoint GET /api/v1/vaults/{code}/balances/as-of?at=... reconstructs the position from entries recorded before the chosen point in time.
Step 3. Create an address (optional)
A new address is ready to use from the first second. No activation or prefunding is needed, since Gas Station covers the fees. An address is defined by a network and a set of static derivation segments.
curl -X POST "$V3_BASE_URL/api/v1/vaults/demo_vault/addresses" \
-H "Authorization: Bearer $V3_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"networkCode": "tron_network",
"staticSegments": { "purpose": "invoices", "asset": "usdt" }
}'
const res = await fetch(`${BASE_URL}/api/v1/vaults/demo_vault/addresses`, {
method: "POST",
headers,
body: JSON.stringify({
networkCode: "tron_network",
staticSegments: { purpose: "invoices", asset: "usdt" },
}),
});
console.log(await res.json());
res = requests.post(
f"{BASE_URL}/api/v1/vaults/demo_vault/addresses",
headers=headers,
json={
"networkCode": "tron_network",
"staticSegments": {"purpose": "invoices", "asset": "usdt"},
},
)
print(res.json())
{
"id": "cmr3f41z20001psp7phmyapw3",
"address": "TQ5kohbBSdorGoDBmCGUmDL7RUvNr75mBC",
"network": "tron",
"derivationPath": "m/44/195/0/0/1/1/0"
}
Step 4. Send a transfer
A transfer isn't broadcast to the network right away. First the amount is reserved, then the request goes through the Policy Engine, which returns one of three outcomes: allow, queue (approval required), or block. Only after that do the MPC nodes sign the transaction.
Pass a unique idempotencyKey: retrying the request with the same key returns the transfer that was already created and never duplicates the payment.
curl -X POST "$V3_BASE_URL/api/v1/vaults/demo_vault/transfers" \
-H "Authorization: Bearer $V3_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fromAddressId": "cmr3f41z20001psp7phmyapw3",
"toAddress": "TQ2m...p4rX",
"token": "usdt",
"amount": "1350.00",
"idempotencyKey": "quickstart-transfer-001"
}'
const res = await fetch(`${BASE_URL}/api/v1/vaults/demo_vault/transfers`, {
method: "POST",
headers,
body: JSON.stringify({
fromAddressId: "cmr3f41z20001psp7phmyapw3",
toAddress: "TQ2m...p4rX",
token: "usdt",
amount: "1350.00",
idempotencyKey: "quickstart-transfer-001",
}),
});
console.log(await res.json());
res = requests.post(
f"{BASE_URL}/api/v1/vaults/demo_vault/transfers",
headers=headers,
json={
"fromAddressId": "cmr3f41z20001psp7phmyapw3",
"toAddress": "TQ2m...p4rX",
"token": "usdt",
"amount": "1350.00",
"idempotencyKey": "quickstart-transfer-001",
},
)
print(res.json())
The request body fields shown here are for illustration only. For the exact, up-to-date list of parameters and formats, see the create transfer page in the API Reference.
If the Policy Engine returns queue, the transfer waits for approval. You can see the approval request and its status via GET /api/v1/vaults/{code}/approval-requests. While the request is pending, the amount stays reserved; if it's rejected, the amount returns to available.