Wallets
Create and manage single-asset wallets in the Amboss Payments API. One wallet per asset (BTC sats or Taproot Assets like USDT).
A wallet holds balance in exactly one asset (BTC sats, or a Taproot Asset like USDT). One wallet, one asset, one balance. Create separate wallets for each asset you want to support.
Wallets are scoped to a single environment. A wallet's id is the value you pass to create_receive and create_send.
Assets
Query the live asset list at runtime — don't hard-code symbols. BTC appears as a base asset; stablecoins (USDT, USDC, …) appear as Taproot Assets.
query AvailableAssets {
taproot_assets {
list {
id
symbol
precision
type
}
}
}precision tells you the asset's smallest unit:
| Asset | precision | Smallest unit |
|---|---|---|
| BTC | 8 | satoshi (1e-8 BTC) |
| USDT, USDC | 6 | 1 micro-unit (1e-6 USD) |
Create a wallet
Wallets are created with a service API key that has WALLETS: WRITE permission. See API Keys.
mutation CreateWallet {
payment {
wallet {
create(input: {
name: "Main USDT wallet"
environment_id: "81b73615-ddf3-46e3-943a-467c3e442e04"
asset_id: "<asset_id from taproot_assets.list>"
}) {
id
name
is_ready
asset { symbol precision }
balance { balance received sent }
}
}
}
}curl -X POST https://rails.amboss.tech/graphql \
-H "Content-Type: application/json" \
-H "x-api-key: $AMBOSS_API_KEY" \
-d '{
"query": "mutation($input: CreatePaymentsWalletInput!) { payment { wallet { create(input: $input) { id name is_ready asset { symbol precision } balance { balance received sent } } } } }",
"variables": {
"input": {
"name": "Main USDT wallet",
"environment_id": "81b73615-ddf3-46e3-943a-467c3e442e04",
"asset_id": "<asset_id>"
}
}
}'import { GraphQLClient, gql } from "graphql-request";
const client = new GraphQLClient("https://rails.amboss.tech/graphql", {
headers: { "x-api-key": process.env.AMBOSS_API_KEY },
});
const CREATE_WALLET = gql`
mutation CreateWallet($input: CreatePaymentsWalletInput!) {
payment {
wallet {
create(input: $input) {
id
name
is_ready
asset { symbol precision }
balance { balance received sent }
}
}
}
}
`;
const { payment } = await client.request(CREATE_WALLET, {
input: {
name: "Main USDT wallet",
environment_id: process.env.AMBOSS_ENV_ID,
asset_id: process.env.AMBOSS_ASSET_ID_USDT,
},
});
console.log(payment.wallet.create.id);Example response:
{
"data": {
"payment": {
"wallet": {
"create": {
"id": "5e4b1e2a-9f3c-4a5b-8c7d-1234567890ab",
"name": "Main USDT wallet",
"is_ready": true,
"asset": { "symbol": "USDT", "precision": 6 },
"balance": { "balance": "0", "received": "0", "sent": "0" }
}
}
}
}
}Wallet readiness
is_ready indicates whether the wallet can receive payments:
- Sandbox:
is_readyistrueimmediately. Sandbox wallets don't need any liquidity provisioning. - Live:
is_readyflips totrueonce liquidity has been provisioned for the wallet. This takes around 30 minutes — poll the wallet or wait for the corresponding webhook event before issuing invoices.
Querying readiness later:
query GetWallet($id: String!) {
payment {
wallet {
find_one(id: $id) {
id
is_ready
balance { balance received sent }
}
}
}
}Wallet credentials
For integrations that want to drive the underlying LND/LITD daemon directly — custom routing logic, bespoke invoice generation, low-level liquidity ops — PaymentsWallet.node_permissions returns the encrypted macaroons and socket endpoints for every node attached to the wallet. Most integrations don't need this; the Payments API handles invoice creation and settlement on your behalf.
Credentials are client-encrypted with the team's symmetric key. The server never sees the cleartext macaroon, and never holds the master password used to decrypt it. See Rails Security for the full key-derivation flow (Argon2id master key → ChaCha20-decrypted team symmetric key → macaroon decryption).
What you get back
| Field | Type | Notes |
|---|---|---|
encrypted_symmetric_key | String! | Team-level. ChaCha20-encrypted with the master key derived from the team password. |
nodes[].encrypted_macaroon | String! | Per-node, CLIENT-encrypted with the team symmetric key above. |
nodes[].macaroon_id | String! | Identifier of the macaroon row — useful for audit logging and rotation. |
nodes[].network | DeployedNodeNetwork! | MAINNET, MUTINYNET, etc. |
nodes[].tls_cert | String | Optional. PEM-encoded TLS certificate for the daemon. |
nodes[].sockets.lnd | SocketEndpoints | grpc + rest for the LND daemon. May be null if the node only exposes LITD. |
nodes[].sockets.litd | SocketEndpoints | grpc + rest for the LITD (Taproot Assets) daemon. May be null for base-asset-only nodes. |
Auth paths
Two authentication paths reach this field:
- Dashboard JWT (master account). If the team has a team password, the caller must pass
password_hashon every query. If the team has no team password, only the master account can read credentials and nopassword_hashis required. - Service API key. The key must have both
WALLETS: READandWALLET_CREDENTIALS: READ, and must be scoped to the wallet being queried. The team must have a team password set, andpassword_hashis always required. See Wallet-scoped credential keys.
password_hash is the Argon2id hash of the master-account password, computed client-side — never send the raw password. The derivation is identical to the one used by Rails withdrawals (see Rails Security → Master Key).
Query the credentials
query GetWalletCredentials($id: String!, $password_hash: String!) {
payment {
wallet {
find_one(id: $id) {
id
node_permissions(password_hash: $password_hash) {
encrypted_symmetric_key
nodes {
node_id
network
macaroon_id
encrypted_macaroon
tls_cert
sockets {
lnd { grpc rest }
litd { grpc rest }
}
}
}
}
}
}
}curl -X POST https://rails.amboss.tech/graphql \
-H "Content-Type: application/json" \
-H "x-api-key: $AMBOSS_API_KEY" \
-d '{
"query": "query($id: String!, $pw: String!) { payment { wallet { find_one(id: $id) { id node_permissions(password_hash: $pw) { encrypted_symmetric_key nodes { node_id network macaroon_id encrypted_macaroon tls_cert sockets { lnd { grpc rest } litd { grpc rest } } } } } } } }",
"variables": {
"id": "5e4b1e2a-9f3c-4a5b-8c7d-1234567890ab",
"pw": "<argon2id-hash-of-master-password>"
}
}'Example response (encrypted blobs truncated):
{
"data": {
"payment": {
"wallet": {
"find_one": {
"id": "5e4b1e2a-9f3c-4a5b-8c7d-1234567890ab",
"node_permissions": {
"encrypted_symmetric_key": "v1:base64:KQX9…",
"nodes": [
{
"node_id": "f0a1c2d3-…",
"network": "MAINNET",
"macaroon_id": "a2c4…",
"encrypted_macaroon": "v1:base64:9zN3…",
"tls_cert": "-----BEGIN CERTIFICATE-----\nMIIB…\n-----END CERTIFICATE-----",
"sockets": {
"lnd": { "grpc": "node-1.example:10009", "rest": "https://node-1.example:8080" },
"litd": { "grpc": "node-1.example:8443", "rest": "https://node-1.example:8443" }
}
}
]
}
}
}
}
}
}Decrypting and using the credentials
The same recipe the dashboard uses, in three steps:
- Derive a 32-byte master key with Argon2id over the master-account password, using the lowercased
team_idas the salt. Parameters match Bitwarden's defaults: 3 iterations, 64 MiB memory, 4 lanes, 32-byte output. - Decrypt
encrypted_symmetric_keywith the master key under NIP-44 v2. The result is a 128-character hex string (the team symmetric key — 64 bytes). - Decrypt each per-node
encrypted_macaroonwith the symmetric key (raw bytes) under NIP-44 v2. The output is the cleartext macaroon (base64-encoded as LND expects).
Then call the daemon at sockets.lnd.grpc / sockets.litd.grpc (or the REST equivalents) with the cleartext macaroon and tls_cert. See Rails LND API → Calling LND directly for a gRPC + REST example.
// npm install nostr-tools @noble/hashes argon2
import argon2 from "argon2";
import { hexToBytes } from "@noble/hashes/utils";
import { nip44 } from "nostr-tools";
const ARGON_DEFAULTS = {
type: argon2.argon2id,
hashLength: 32,
timeCost: 3,
memoryCost: 64_000, // KiB
parallelism: 4,
raw: true as const,
};
export async function decryptWalletCredentials(opts: {
password: string;
teamId: string;
encryptedSymmetricKey: string;
encryptedMacaroon: string;
}): Promise<{ symmetricKeyHex: string; macaroon: string }> {
// 1. Argon2id(password, salt = lowercased team id) → 32-byte master key.
const masterKey = (await argon2.hash(opts.password.trim(), {
...ARGON_DEFAULTS,
salt: Buffer.from(opts.teamId.trim().toLowerCase()),
})) as Buffer;
// 2. NIP-44 v2 decrypt the team symmetric key (returns 128-char hex string).
const symmetricKeyHex = nip44.v2.decrypt(
opts.encryptedSymmetricKey,
new Uint8Array(masterKey),
);
// 3. NIP-44 v2 decrypt the macaroon with the symmetric key (raw bytes).
const macaroon = nip44.v2.decrypt(
opts.encryptedMacaroon,
hexToBytes(symmetricKeyHex),
);
return { symmetricKeyHex, macaroon };
}# pip install argon2-cffi cryptography
import base64
import hashlib
import hmac
from argon2.low_level import Type, hash_secret_raw
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
ARGON_DEFAULTS = dict(time_cost=3, memory_cost=64_000, parallelism=4, hash_len=32)
def _hkdf_expand_sha256(prk: bytes, info: bytes, length: int) -> bytes:
out, t, counter = b"", b"", 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest()
out += t
counter += 1
return out[:length]
def _nip44_v2_decrypt(payload_b64: str, conversation_key: bytes) -> str:
"""Minimal NIP-44 v2 decryption — see https://github.com/nostr-protocol/nips/blob/master/44.md"""
payload = base64.b64decode(payload_b64)
if payload[0] != 0x02:
raise ValueError(f"Unsupported NIP-44 version: {payload[0]:#x}")
nonce, mac, ciphertext = payload[1:33], payload[-32:], payload[33:-32]
keys = _hkdf_expand_sha256(conversation_key, nonce, 76)
chacha_key, chacha_nonce, hmac_key = keys[0:32], keys[32:44], keys[44:76]
expected = hmac.new(hmac_key, nonce + ciphertext, hashlib.sha256).digest()
if not hmac.compare_digest(expected, mac):
raise ValueError("NIP-44 MAC mismatch")
# cryptography's ChaCha20 wants a 16-byte nonce = 4-byte counter || 12-byte nonce.
full_nonce = b"\x00\x00\x00\x00" + chacha_nonce
decryptor = Cipher(algorithms.ChaCha20(chacha_key, full_nonce), mode=None).decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
plaintext_len = int.from_bytes(padded[0:2], "big")
return padded[2 : 2 + plaintext_len].decode("utf-8")
def decrypt_wallet_credentials(
password: str,
team_id: str,
encrypted_symmetric_key: str,
encrypted_macaroon: str,
) -> tuple[str, str]:
# 1. Derive 32-byte master key.
master_key = hash_secret_raw(
secret=password.strip().encode("utf-8"),
salt=team_id.strip().lower().encode("utf-8"),
type=Type.ID,
**ARGON_DEFAULTS,
)
# 2. Decrypt the team symmetric key (returns 128-char hex string).
symmetric_key_hex = _nip44_v2_decrypt(encrypted_symmetric_key, master_key)
# 3. Decrypt the macaroon with the symmetric key (raw bytes).
macaroon = _nip44_v2_decrypt(encrypted_macaroon, bytes.fromhex(symmetric_key_hex))
return symmetric_key_hex, macaroonAmount handling
Every amount in the API is a decimal string in the asset's minor units.
- BTC: sats.
"100000"= 0.001 BTC. - USDT/USDC (precision 6): micro-units.
"1000000"= 1 USDT.
Never pass floats or JS numbers — the API rejects them. Use string-encoded integers. The validator regex is /^[1-9]\d*$/, so "0" and leading zeros are also rejected for input amounts.