Accept Monero Payments Yourself With monero-wallet-rpc and C#



Last updated: October 28th, 2026

This tutorial is for educational purposes. Make sure accepting Monero (XMR) is legal where you operate.

A payment processor such as NOWPayments is the fastest way to accept Monero. But you can also accept it with no third party at all: your own wallet software tells your app when an order has been paid, there are no processor fees, and no one else ever sees your sales. This guide shows how to do that from C# with monero-wallet-rpc, the JSON-RPC server that ships with the official Monero software.

How It Works

  1. You run a Monero node (monerod) and monero-wallet-rpc with a view-only copy of your shop wallet. A view-only wallet can see incoming payments but cannot spend, so a compromised web server can't steal your funds.
  2. For each order your app asks the wallet for a new subaddress (create_address) and shows it to the buyer with the XMR amount.
  3. A background service polls get_transfers for those subaddresses and marks an order paid once the full amount has 10 confirmations.

Why Subaddresses Instead of Payment IDs?

Older Monero integrations identified orders with a payment ID attached to each transaction. Standalone (long, unencrypted) payment IDs were removed from the Monero wallet software in 2019 because they hurt privacy. The modern approach is one subaddress per order: every subaddress belongs to the same wallet, nobody can link them together, and the wallet tells you which subaddress received each payment. (Integrated addresses with short encrypted payment IDs still exist, but subaddresses are simpler.)

Step 1: Run monerod and monero-wallet-rpc

Download the official CLI tools from getmonero.org and verify the hashes. Run a full or pruned monerod on the same server (or one you control). Using someone else's remote node works but leaks some information to that node.

Create a view-only wallet from your main wallet's primary address and private view key (in the GUI or CLI wallet: Settings > Seed & keys), then start the RPC server bound to localhost with a login:

# Create a view-only wallet for the shop (it can see incoming payments but cannot spend):
monero-wallet-cli --generate-from-view-key /var/lib/monero/shop-viewonly \
    --daemon-address 127.0.0.1:18081
#   (enter your PRIMARY address, then the PRIVATE VIEW key from your main wallet)

# Run the wallet RPC server, bound to localhost only, with a login
monero-wallet-rpc \
    --wallet-file /var/lib/monero/shop-viewonly \
    --password-file /etc/monero/wallet.pass \
    --daemon-address 127.0.0.1:18081 \
    --rpc-bind-ip 127.0.0.1 \
    --rpc-bind-port 18082 \
    --rpc-login shop:CHANGE_THIS_PASSWORD

Run both as services (systemd on Linux, a Windows service on Windows) so they restart automatically. Test the RPC server with curl; --digest is required because --rpc-login uses HTTP Digest authentication:

curl -s --digest -u shop:CHANGE_THIS_PASSWORD http://127.0.0.1:18082/json_rpc \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"0","method":"create_address","params":{"account_index":0,"label":"order:1042"}}'

# {"id":"0","jsonrpc":"2.0","result":{"address":"8...","address_index":7, ...}}

Step 2: A C# Client for monero-wallet-rpc

All calls go to one endpoint, /json_rpc, with a method name and parameters. Amounts are integers in atomic units (piconero): 1 XMR = 1,000,000,000,000. Never use double for them.

using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;

// Minimal JSON-RPC client for monero-wallet-rpc.
public sealed class MoneroWalletRpcClient(HttpClient http)
{
    public const decimal AtomicUnitsPerXmr = 1_000_000_000_000m; // 1 XMR = 10^12 piconero

    public async Task<T> CallAsync<T>(string method, object parameters, CancellationToken ct = default)
    {
        var request = new { jsonrpc = "2.0", id = "0", method, @params = parameters };

        using HttpResponseMessage response = await http.PostAsJsonAsync("json_rpc", request, ct);
        response.EnsureSuccessStatusCode(); // 401 here means wrong --rpc-login credentials

        RpcResponse<T>? body = await response.Content.ReadFromJsonAsync<RpcResponse<T>>(ct);
        if (body?.Error is { } error)
        {
            throw new InvalidOperationException($"monero-wallet-rpc {method} failed: {error.Code} {error.Message}");
        }
        return body!.Result!;
    }

    // A fresh subaddress per order, labeled with your order id.
    public Task<CreateAddressResult> CreateAddressAsync(string label, CancellationToken ct = default) =>
        CallAsync<CreateAddressResult>("create_address", new { account_index = 0, label }, ct);

    // Incoming transfers (confirmed + mempool) to the given subaddress indexes.
    public Task<GetTransfersResult> GetIncomingAsync(IEnumerable<uint> subaddressIndexes, CancellationToken ct = default) =>
        CallAsync<GetTransfersResult>("get_transfers", new
        {
            @in = true,
            pool = true,
            account_index = 0,
            subaddr_indices = subaddressIndexes.ToArray()
        }, ct);

    // monero: URI for QR codes / "open in wallet" links.
    public Task<MakeUriResult> MakeUriAsync(string address, ulong amountAtomic, string description, CancellationToken ct = default) =>
        CallAsync<MakeUriResult>("make_uri", new { address, amount = amountAtomic, tx_description = description }, ct);
}

public sealed record RpcError([property: JsonPropertyName("code")] int Code,
                              [property: JsonPropertyName("message")] string Message);

public sealed record RpcResponse<T>([property: JsonPropertyName("result")] T? Result,
                                    [property: JsonPropertyName("error")] RpcError? Error);

public sealed record CreateAddressResult([property: JsonPropertyName("address")] string Address,
                                         [property: JsonPropertyName("address_index")] uint AddressIndex);

public sealed record MakeUriResult([property: JsonPropertyName("uri")] string Uri);

public sealed record SubaddressIndex([property: JsonPropertyName("major")] uint Major,
                                     [property: JsonPropertyName("minor")] uint Minor);

public sealed record Transfer(
    [property: JsonPropertyName("txid")] string TxId,
    [property: JsonPropertyName("amount")] ulong Amount,           // atomic units
    [property: JsonPropertyName("confirmations")] ulong Confirmations,
    [property: JsonPropertyName("height")] ulong Height,           // 0 while in the mempool
    [property: JsonPropertyName("unlock_time")] ulong UnlockTime,
    [property: JsonPropertyName("subaddr_index")] SubaddressIndex SubaddrIndex);

public sealed record GetTransfersResult(
    [property: JsonPropertyName("in")] List<Transfer>? In,
    [property: JsonPropertyName("pool")] List<Transfer>? Pool);

Step 3: Create a Payment Request per Order

Register the client with IHttpClientFactory and give the handler your RPC credentials; HttpClient performs the Digest handshake automatically. The endpoint creates a subaddress labeled with the order ID and a monero: URI you can render as a QR code:

// Program.cs
using System.Net;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<MoneroWalletRpcClient>(http =>
{
    http.BaseAddress = new Uri(builder.Configuration["Monero:RpcUrl"] ?? "http://127.0.0.1:18082/");
    http.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    // monero-wallet-rpc --rpc-login uses HTTP Digest auth, which HttpClient handles for you.
    Credentials = new NetworkCredential(
        builder.Configuration["Monero:RpcUser"],
        builder.Configuration["Monero:RpcPassword"])
});

builder.Services.AddHostedService<PaymentWatcher>();
var app = builder.Build();

// Create a payment request for an order (in a real app, load the order and its XMR price).
app.MapPost("/orders/{orderId}/pay", async (string orderId, decimal xmrAmount, MoneroWalletRpcClient wallet) =>
{
    CreateAddressResult sub = await wallet.CreateAddressAsync($"order:{orderId}");
    ulong atomic = (ulong)(xmrAmount * MoneroWalletRpcClient.AtomicUnitsPerXmr);
    MakeUriResult uri = await wallet.MakeUriAsync(sub.Address, atomic, $"Order {orderId}");

    // Save orderId -> sub.AddressIndex, expected atomic amount and an expiry time in your database.
    return Results.Ok(new { address = sub.Address, amountXmr = xmrAmount, paymentUri = uri.Uri });
});

app.Run();

Store the order's subaddress index, the expected amount in atomic units, and an expiry time (for example 60 minutes). Convert your fiat price to XMR when the order is created, using any XMR price API, and show buyers the exact amount.

Step 4: Watch for Payments

A BackgroundService checks every pending order's subaddress in a single get_transfers call. Transfers in the pool (mempool) or with fewer than 10 confirmations are "seen but not final"; once the confirmed total reaches the expected amount, the order is paid:

// Polls the wallet every 30 seconds and marks orders paid once enough XMR is confirmed.
public sealed class PaymentWatcher(MoneroWalletRpcClient wallet, ILogger<PaymentWatcher> logger) : BackgroundService
{
    private const ulong RequiredConfirmations = 10; // Monero outputs unlock after 10 blocks (~20 min)

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
        do
        {
            try
            {
                await CheckPendingOrdersAsync(stoppingToken);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Payment check failed; will retry.");
            }
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }

    private async Task CheckPendingOrdersAsync(CancellationToken ct)
    {
        // Replace with a query for unpaid, unexpired orders: (orderId, subaddress index, expected atomic amount)
        List<(string OrderId, uint Index, ulong ExpectedAtomic)> pending = [];
        if (pending.Count == 0) return;

        GetTransfersResult transfers = await wallet.GetIncomingAsync(pending.Select(p => p.Index), ct);

        foreach (var order in pending)
        {
            ulong confirmed = (transfers.In ?? [])
                .Where(t => t.SubaddrIndex.Minor == order.Index
                            && t.Confirmations >= RequiredConfirmations
                            && t.UnlockTime == 0)
                .Aggregate(0UL, (sum, t) => sum + t.Amount);

            ulong unconfirmed = (transfers.Pool ?? [])
                .Concat(transfers.In ?? [])
                .Where(t => t.SubaddrIndex.Minor == order.Index && t.Confirmations < RequiredConfirmations)
                .Aggregate(0UL, (sum, t) => sum + t.Amount);

            if (confirmed >= order.ExpectedAtomic)
            {
                logger.LogInformation("Order {OrderId} paid ({Xmr} XMR).", order.OrderId,
                    confirmed / MoneroWalletRpcClient.AtomicUnitsPerXmr);
                // Mark the order paid (idempotently) and fulfill it.
            }
            else if (unconfirmed > 0)
            {
                // Optional: show "payment seen, waiting for confirmations" to the buyer.
            }
        }
    }
}

How many confirmations?

  • 10 confirmations (about 20 minutes) is the safe default: it is when Monero considers outputs spendable.
  • For low-value digital goods, some merchants accept 1 to 3 confirmations, or even a mempool transaction, to deliver faster. That is a business decision that trades a small double-spend risk for speed.
  • Ignore transfers with a non-zero unlock_time (locked outputs), which a normal wallet never creates.

Handling Real-World Cases

  • Underpayment: wallets and exchanges sometimes send slightly less because of rounding or fees. Decide on a tolerance, or show the buyer the remaining amount to send to the same subaddress.
  • Overpayment and late payments: the funds are in your wallet either way; refund manually if needed.
  • Exchange-rate changes: lock the XMR price for a short window (for example 30 to 60 minutes) and create a new quote after it expires.
  • Restarts: because the watcher asks the wallet for all transfers to pending subaddresses every time, nothing is lost if your app restarts. Keep fulfillment idempotent.

Security Checklist

  • Use a view-only wallet on the web server. Keep the spend key and seed offline.
  • Bind monero-wallet-rpc to 127.0.0.1, always set --rpc-login, and never expose port 18082 to the internet.
  • Keep the RPC password in configuration (user secrets or environment variables), not in source code.
  • Back up the wallet's seed phrase securely; the view-only wallet can always be recreated from it.

Self-Hosted vs. a Payment Processor

monero-wallet-rpcProcessor (e.g. NOWPayments)
FeesNone (only network fees, paid by the buyer)Processor fee per payment
PrivacyNo third party sees your salesThe processor sees orders and amounts
SetupRun a node and wallet RPC, write a watcherAPI key and a webhook
Hosted checkout pageBuild your ownIncluded
Other coinsMonero onlyMany coins

If you want a ready-made, self-hosted option with a checkout UI, BTCPay Server has a Monero plugin that uses the same monerod + monero-wallet-rpc pair under the hood.

Frequently Asked Questions

Are Monero payment IDs deprecated?

Standalone long payment IDs were removed from the official wallet in 2019. Use a unique subaddress per order instead, or an integrated address if your system requires a payment ID.

Can a view-only wallet see incoming payments?

Yes. A view-only wallet, created from the primary address and private view key, detects all incoming transfers and can generate subaddresses. It cannot spend funds or reliably report outgoing transfers.

How do I convert XMR to atomic units?

Multiply by 1012. 0.25 XMR is 250,000,000,000 piconero. Use decimal or integer types, never floating point.

How long does a Monero payment take to confirm?

A new block arrives about every two minutes. Ten confirmations, the point at which outputs become spendable, take roughly 20 minutes.

Related: accepting Monero with the NOWPayments API in C# and offering your site over Tor.


Comments

No Comments

CAPTCHA



Join my email list!



ryan
About Me

With over 15 years in tech, I've excelled as a senior software engineer, specializing in ASP.NET, C#, SQL, Azure, and front-end technologies. I've led diverse projects across various sectors, from startups to global corporations, particularly during my decade in the San Francisco Bay Area.


Sign Up Hostinger Email