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:

AssetprecisionSmallest unit
BTC8satoshi (1e-8 BTC)
USDT, USDC61 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_ready is true immediately. Sandbox wallets don't need any liquidity provisioning.
  • Live: is_ready flips to true once 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

FieldTypeNotes
encrypted_symmetric_keyString!Team-level. ChaCha20-encrypted with the master key derived from the team password.
nodes[].encrypted_macaroonString!Per-node, CLIENT-encrypted with the team symmetric key above.
nodes[].macaroon_idString!Identifier of the macaroon row — useful for audit logging and rotation.
nodes[].networkDeployedNodeNetwork!MAINNET, MUTINYNET, etc.
nodes[].tls_certStringOptional. PEM-encoded TLS certificate for the daemon.
nodes[].sockets.lndSocketEndpointsgrpc + rest for the LND daemon. May be null if the node only exposes LITD.
nodes[].sockets.litdSocketEndpointsgrpc + 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_hash on every query. If the team has no team password, only the master account can read credentials and no password_hash is required.
  • Service API key. The key must have both WALLETS: READ and WALLET_CREDENTIALS: READ, and must be scoped to the wallet being queried. The team must have a team password set, and password_hash is 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:

  1. Derive a 32-byte master key with Argon2id over the master-account password, using the lowercased team_id as the salt. Parameters match Bitwarden's defaults: 3 iterations, 64 MiB memory, 4 lanes, 32-byte output.
  2. Decrypt encrypted_symmetric_key with the master key under NIP-44 v2. The result is a 128-character hex string (the team symmetric key — 64 bytes).
  3. Decrypt each per-node encrypted_macaroon with 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, macaroon

Amount 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.

Next steps