Next lesson →

monero-wallet-rpc: The Workhorse

monero-wallet-rpc as a service: accounts and subaddresses, get_balance, create_address, get_transfers, transfer and sweep_all — the engine behind most server integrations.

If you're building a server-side Monero integration — an exchange, a payment processor, an automated payout system — you almost never talk to monerod directly to manage funds. Instead you drive monero-wallet-rpc, a headless wallet that exposes a JSON-RPC API. This is the same workhorse that powers BTCPay's Monero plugin and the deposit/withdrawal machinery at most exchanges. In this lesson we'll walk through what it is, how to launch it, and the exact methods you'll call to create wallets, generate addresses, read balances, and send funds.

What monero-wallet-rpc is

monero-wallet-rpc is a wallet program that runs as a long-lived JSON-RPC service instead of an interactive CLI. You point it at a monerod daemon (which holds the blockchain) and it manages one or more wallet files on disk — scanning for your transactions, tracking balances, and building and signing outgoing transactions. Your application speaks HTTP to it; it speaks to the daemon. This separation means your business logic never has to understand Monero's cryptography — it just makes RPC calls.

Starting the service

A typical invocation looks like this:

monero-wallet-rpc \
  --daemon-address 127.0.0.1:18081 \
  --rpc-bind-port 18083 \
  --wallet-dir ./wallets \
  --rpc-login user:pass

--daemon-address is your monerod instance. --rpc-bind-port is where your app will reach the wallet (18083 is conventional). --wallet-dir tells it which directory wallet files live in, so methods like open_wallet and create_wallet resolve names relative to it. Always set --rpc-login user:pass for digest authentication; only use --disable-rpc-login when the port is bound to a firewalled localhost and nothing else can reach it. Every method below is a POST to the single endpoint POST /json_rpc, with a JSON body of {"jsonrpc":"2.0","id":"0","method":"...","params":{...}}.

Atomic units

One rule before any balances or transfers: all amounts in the API are in atomic units, also called piconero. 1 XMR = 1e12 atomic units. So 0.5 XMR is 500000000000. Never pass a decimal XMR value to the API — convert to atomic units first, and convert back only for display. Getting this wrong by a factor of a trillion is the classic integration bug.

Wallet lifecycle

You manage wallet files through a small set of methods. create_wallet makes a brand-new wallet, open_wallet loads an existing one into the running service, and close_wallet unloads it. To recover from a seed use restore_deterministic_wallet. To build a wallet from raw key material — for example a watch-only wallet from an address plus its private view key — use generate_from_keys; omit the spend key and the resulting wallet can detect incoming funds but cannot spend them. Call store to flush wallet state to disk, and refresh to scan the daemon for new blocks. Note that the service holds one wallet open at a time, so multi-wallet setups open and close as needed.

Accounts and subaddresses

A Monero wallet is organized into accounts, indexed by a major index, and each account contains subaddresses, indexed by a minor index. This two-level structure is how exchanges segregate users and invoices: assign each customer an account (or each invoice a fresh subaddress) and payments are automatically bucketed by where they landed. The relevant methods:

  • create_account — add a new account (new major index).
  • get_accounts — list accounts with per-account balances.
  • create_address — generate a new subaddress within an account.
  • get_address — list addresses, optionally filtered by account and address indices.
  • label_address — attach a human-readable label to a subaddress.

Because every subaddress is unlinkable on-chain, handing each invoice its own fresh subaddress is both good accounting and good privacy.

Reading funds

get_balance returns two figures: balance (everything the wallet has seen) and unlocked_balance (what you can actually spend right now). The difference matters: incoming outputs are subject to the standard 10-block lock, so freshly received funds sit in balance but not unlocked_balance until they mature. Credit a user's deposit only when it's unlocked, or you risk crediting funds you can't yet move.

For transaction history, get_transfers returns lists keyed by category — in, out, pending, failed, and pool (unconfirmed, in the mempool). To inspect one transaction use get_transfer_by_txid. incoming_transfers lists the wallet's received outputs at the UTXO level. The older payment-id-based methods get_payments and get_bulk_payments still exist for legacy integrations, but subaddresses have largely replaced payment IDs for routing deposits.

Sending

To send, call transfer with a destinations array of {address, amount} objects (amount in atomic units), plus a priority and usually get_tx_key: true so you receive a transaction key for later proofs. If a transfer is too large to fit in one transaction, transfer_split breaks it across several. A successful transfer returns tx_hash, fee, amount, and tx_key. A minimal request:

{
  "method": "transfer",
  "params": {
    "destinations": [
      { "address": "4...", "amount": 500000000000 }
    ],
    "priority": 1,
    "get_tx_key": true
  }
}

To empty a wallet or account use sweep_all; sweep_dust consolidates tiny unspendable outputs. If you build a transaction without broadcasting it, relay_tx pushes it to the network later. The wallet computes fees automatically based on priority and size, so you rarely set them by hand — though the daemon's get_fee_estimate is available if you want to preview rates.

Helpers worth knowing

  • make_integrated_address — bundle a payment ID into an address (legacy routing).
  • validate_address — check an address before accepting a withdrawal request.
  • check_tx_key — verify a payment proof using a tx hash, address, and tx key.
  • get_height — the wallet's current scan height, useful for sync monitoring.

Detecting new funds: polling vs ZMQ

The wallet auto-refreshes on an interval, and you can force a scan with refresh. Most integrations simply poll get_transfers or get_balance on a schedule. For lower latency, monerod can publish ZMQ notifications that you subscribe to, then trigger a refresh and re-check balances on each new block. Either way, the pattern is the same: refresh, read transfers, and act only once funds are confirmed and unlocked.

Next: Receiving Payments: Subaddresses & Watch-Only Wallets

Created June 30, 2026

Comments

Log in or create a free account to comment.

No comments yet — be the first.

🎓 Graduate from Monero Academy

Create a free account, ace every quiz across all courses, and earn your place on the Graduates wall — with your own Monero address for donations. An account also tracks your progress through the courses, and graduating is the prize for finishing.