Receiving Payments: Subaddresses & Watch-Only Wallets
The payment-processor pattern: a fresh subaddress per invoice plus a watch-only (view-key) wallet to detect payments securely, with confirmations and the 10-block lock.
This is the heart of building a payment processor. Receiving Monero programmatically is fundamentally different from receiving Bitcoin, because amounts and recipients are hidden on-chain. You can't watch a public block explorer for "did 0.5 XMR arrive at this address?" — the answer simply isn't visible to anyone but you. Instead, your wallet, holding the right keys, is the only thing that can see incoming payments. This lesson covers the standard processor pattern: a fresh subaddress per invoice, a watch-only wallet that can see but never spend, and polling the wallet RPC to confirm payments.
The processor pattern: one subaddress per invoice
When a customer starts checkout, you generate a brand-new subaddress for that invoice by calling create_address on monero-wallet-rpc. The call returns an address string and an address_index. You store that index against the order in your database, then show the customer the subaddress (as text and a QR code).
The point is that every invoice gets its own unique receiving address. When funds later land on a subaddress, you know exactly which invoice was paid simply by looking at which subaddress received them — the address_index maps straight back to your order. There's no ambiguity, no shared address, and no need to ask the customer to attach anything.
This is why subaddresses made the old approach obsolete. Before subaddresses, processors reused one integrated address with a distinct payment ID per customer to tell payments apart. That was fragile and leaked metadata. Today you just mint a fresh subaddress per invoice — it's cleaner, more private, and the wallet handles all of them under one account.
The watch-only wallet: see everything, spend nothing
The secure merchant model is to run a watch-only wallet on your payment server. A Monero wallet is controlled by two private keys: the private view key (lets you see incoming transactions and amounts) and the private spend key (lets you spend). A watch-only wallet is built from the primary address plus the private view key only — the spend key is never given to the server.
You create one with the RPC method generate_from_keys (supplying the address and view key, but no spend key), or on the command line with monero-wallet-cli --generate-from-view-key. The resulting wallet can detect and decode every payment to any of its subaddresses, report exact amounts, and track confirmations — but it physically cannot construct a spend, because it lacks the spend key.
The security win is enormous. Your internet-facing payment server holds only the view key. The spend key stays offline, in cold storage or on a hardware device. If an attacker breaches your server, they can see your incoming payments (a minor privacy loss) but they cannot move a single piski. Compare this to a hot wallet with spend ability, which a breach would drain instantly.
Detecting a payment
With the watch-only wallet synced against a node, you detect payments by polling the wallet RPC. The workhorse is get_transfers with in: true, which returns all incoming transfers. For each one you check the subaddr_index to find the matching order, then compare the amount against what's owed. You can also call get_transfer_by_txid if you already know a transaction hash.
Each transfer record carries the fields you need to make decisions:
amount— the value received, in atomic units.confirmations— how many blocks have buried the transaction.height— the block height the transaction was mined in.unlock_time— any extra lock the sender attached.
For lower-latency detection you can subscribe to the node's ZMQ feed instead of polling on a timer, reacting as new blocks and transactions arrive rather than asking repeatedly. Polling get_transfers every few seconds is perfectly adequate for most processors, though.
Confirmations and locks
A payment first appears with confirmations of 0 — it's in the mempool, broadcast but not yet mined. You decide how many confirmations to require before crediting an order, scaling with value: a coffee might be fine at 0 (accepting mempool risk), while a large order should wait for several confirmations to be safe against reorgs.
Separately from your own confirmation policy, received funds are locked for 10 blocks (roughly 20 minutes) by consensus before they become spendable. This lock is about when you could spend the coins, not about when you should credit the customer — don't confuse the two. You can mark an order paid after your chosen number of confirmations even though the output won't be spendable until the 10-block lock clears. See block confirmations and locks for the underlying mechanics.
Verifying the amount
All Monero amounts are expressed in atomic units, where 1 XMR = 1,000,000,000,000 (1e12) atomic units. Always do your math in integers — never in floating-point XMR — to avoid rounding errors. When a transfer arrives, verify the exact amount against the invoice total and handle the edge cases: an underpayment (credit partially, or hold the invoice open for a top-up) and an overpayment (credit the order and flag the excess for refund or account credit).
Why the view key is non-negotiable
Because amounts are hidden on-chain by RingCT, only a wallet holding your view key can decrypt how much arrived. There is no public explorer lookup that tells you an invoice was paid — the network sees an output to a stealth address with a concealed value. This is precisely why the view-key watch-only wallet is essential infrastructure, not an optional convenience: it is the only component that can confirm a payment. To later hand a customer or auditor cryptographic proof that a specific payment occurred, you'd generate a payment proof.
This is exactly how BTCPay works
Everything here is the same flow the BTCPay Server Monero plugin and comparable processors use under the hood: generate a fresh subaddress per invoice, connect a view-only wallet so the server can watch without spend ability, poll get_transfers (or listen on ZMQ) to detect the payment, check the amount and confirmations, then mark the invoice settled. If you'd rather run a packaged solution than build your own, the accepting Monero with BTCPay lesson walks through deploying it. Building it yourself, though, gives you full control over confirmation policy, underpayment handling, and how payments tie into your order system.
Created June 30, 2026
Comments
Log in or create a free account to comment.
No comments yet — be the first.